Initial Email-Forwarder scaffold
This commit is contained in:
commit
91e52aecd0
22 changed files with 1007 additions and 0 deletions
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Migrations-Secrets bleiben lokal, nie ins Git (erst bei Go-Live rotieren + einlagern)
|
||||||
|
config.json
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# Lokales Cold Backup (mbox) nicht versionieren
|
||||||
|
backup/
|
||||||
|
/emailforwarder
|
||||||
|
/emailforwarder.exe
|
||||||
|
|
||||||
|
# Bun / Frontend
|
||||||
|
frontend-js/node_modules/
|
||||||
115
Codex-Briefing.md
Normal file
115
Codex-Briefing.md
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# Codex-Briefing — Email-Forwarder
|
||||||
|
|
||||||
|
Du baust ein Go-Single-Binary im Hausstil (wie WatchDove): `main.go` als
|
||||||
|
Orchestrator, nummerierte `backend/NN-*.go`, Server-gerendertes HTML + HTMX,
|
||||||
|
`config.json`, SQLite via `modernc.org/sqlite` (kein cgo). Das Gerüst steht;
|
||||||
|
fülle die `// TODO Codex`-Stellen. **Lies zuerst `Projektbeschreibung.md` – die
|
||||||
|
5 harten Regeln sind bindend.**
|
||||||
|
|
||||||
|
## Bibliotheken (lean, keine Lawine)
|
||||||
|
|
||||||
|
- IMAP: `github.com/emersion/go-imap/v2` (+ `imapclient`)
|
||||||
|
- Parsen (Message-ID, Header, Text/HTML für Viewer): `github.com/emersion/go-message`
|
||||||
|
- SQLite: `modernc.org/sqlite`
|
||||||
|
- POP3-Fallback: `github.com/knadh/go-pop3`
|
||||||
|
- SMTP-Weiterleiten: `net/smtp` (stdlib) — keine Extra-Dependency
|
||||||
|
|
||||||
|
`go mod tidy` + `go mod vendor` (Offline-Bunker, wie im Template).
|
||||||
|
|
||||||
|
## Modul-Landkarte
|
||||||
|
|
||||||
|
| Datei | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `main.go` | Flags (`--run`, `--watch`), LoadConfig→ConnectDB→InitAuth, Web oder CLI |
|
||||||
|
| `backend/00-router.go` | Routen + `renderShell` (Outlook-2013-Dreispalter) — **da steht schon das Layout** |
|
||||||
|
| `backend/01-config.go` | `config.json` (App + Forward-SMTP). Konten NICHT hier, sondern DB |
|
||||||
|
| `backend/02-database.go` | Schema + Account-CRUD + `copied`-Cache (Idempotenz) |
|
||||||
|
| `backend/03-auth.go` | Login + `AuthMiddleware` |
|
||||||
|
| `backend/04-imap-source.go` | Quelle: Ordnerbaum + Fetch `BODY[] FLAGS INTERNALDATE` |
|
||||||
|
| `backend/05-imap-target.go` | Ziel: `EnsureFolder` + `Append` (Flags + INTERNALDATE) |
|
||||||
|
| `backend/06-mbox.go` | mbox schreiben (Umzug) **und** lesen (Viewer) |
|
||||||
|
| `backend/07-migrate.go` | Motor: pro Konto/Ordner/Mail, Dedup, Doppel-Write, Fortschritt |
|
||||||
|
| `backend/08-viewer.go` | mbox im Browser: Liste, Lesen, Weiterleiten |
|
||||||
|
| `backend/09-smtp.go` | SMTP nur fürs Weiterleiten |
|
||||||
|
| `backend/10-pop3.go` | POP3-Fallback-Quelle (nur INBOX, nie DELE) |
|
||||||
|
|
||||||
|
## DB-Schema (SQLite)
|
||||||
|
|
||||||
|
```
|
||||||
|
accounts(id, name,
|
||||||
|
src_host, src_port, src_security, src_insecure, src_user, src_pass, src_proto,
|
||||||
|
dst_host, dst_port, dst_security, dst_insecure, dst_user, dst_pass,
|
||||||
|
mbox_dir, active)
|
||||||
|
folder_map(id, account_id, src_folder, dst_folder) -- optional, Vorrang vor Rollen-Automatik
|
||||||
|
copied(account_id, folder, message_id UNIQUE) -- Delta-Cache
|
||||||
|
jobs(id, account_id, started, finished, total, done, errors, state)
|
||||||
|
```
|
||||||
|
|
||||||
|
`src_security`/`dst_security` = `"tls"` | `"starttls"` | `"none"`.
|
||||||
|
`src_insecure`/`dst_insecure` = ungültige Zerts akzeptieren.
|
||||||
|
|
||||||
|
## Der Umzugs-Kern (07-migrate.go), Schritt für Schritt
|
||||||
|
|
||||||
|
1. Quelle öffnen: `src_proto=imap` → `OpenIMAPSource`, sonst `OpenPOP3Source`.
|
||||||
|
2. `Folders()` → rekursiver Ordnerbaum.
|
||||||
|
3. Pro Ordner: Zielordner via `MapSourceToTarget` (11-folders.go) bestimmen —
|
||||||
|
Rollen-Zuordnung gegen die vorhandenen Ziel-Ordner (keine Dubletten),
|
||||||
|
Trenner umhängen, `folder_map` als Override. Dann `EnsureFolder`.
|
||||||
|
4. `Fetch(folder)` streamend. Pro Mail: `AlreadyCopied(id,folder,msgID)`?
|
||||||
|
→ ja: überspringen. Nein: **beide** schreiben — `TargetMailbox.Append`
|
||||||
|
(IMAP, mit Flags+INTERNALDATE) **und** `MboxWriter.Append` (lokal), dann
|
||||||
|
`MarkCopied`. Fehler pro Mail zählen, nicht den ganzen Lauf abbrechen.
|
||||||
|
5. `jobs` fortschreiben (Browser pollt `/migrate/status` alle 3 s).
|
||||||
|
6. `--watch`: Schleife mit Pause; dank `copied` kopiert jeder Durchlauf nur Neues.
|
||||||
|
|
||||||
|
## UI — Outlook 2013 (Vorlage)
|
||||||
|
|
||||||
|
Look sitzt schon in `frontend-js/dist/style.css` + `renderShell`:
|
||||||
|
**Ribbon** (Outlook-Blau `#0072c6`) mit Reitern *Umzug · Postfächer · Betrachten
|
||||||
|
· Einstellungen*, darunter **Dreispalter**: links Ordnerbaum (Konto → Ordner),
|
||||||
|
Mitte Nachrichtenliste (ungelesen = fett blau), rechts Lesebereich; unten
|
||||||
|
Statusleiste. Flach, Segoe UI, blaue Auswahlzeile `#cde6f7`. Panes per HTMX
|
||||||
|
nachladen (`#tree` / `#list` / `#read`). Klassen stehen im CSS: `.tree-node`,
|
||||||
|
`.msg-row(.unread/.active)`, `.read-*`, `.grid/.input/.btn`.
|
||||||
|
|
||||||
|
Reiter **Postfächer** = Konten-CRUD (Formular wie WatchDove `targetForm`, Quelle-
|
||||||
|
und Ziel-Block, „Test"-Button prüft beide Logins). Reiter **Umzug** = Konten
|
||||||
|
starten + Fortschritt. Reiter **Betrachten** = mbox-Viewer, im Lesebereich ein
|
||||||
|
„Weiterleiten"-Button (öffnet Formular → `09-smtp.go`).
|
||||||
|
|
||||||
|
`frontend-js/dist/htmx.min.js` per Bun holen (wie Template) und mit einchecken.
|
||||||
|
|
||||||
|
## Sonderfälle — die drei Umzugs-Fallen (zwingend)
|
||||||
|
|
||||||
|
1. **Unverschlüsselte Alt-Provider.** Verbinden nach `src_security`:
|
||||||
|
`tls`=DialTLS (993), `starttls`=DialStartTLS (143), `none`=Dial (reiner
|
||||||
|
Klartext). `src_insecure` → `tls.Config{InsecureSkipVerify:true}` für
|
||||||
|
kaputte/selbstsignierte Zerts. Muss ohne TLS funktionieren — alte Hoster
|
||||||
|
können oft nichts anderes. (04/05-imap-*.go, POP3 analog 110/995.)
|
||||||
|
2. **Sonderzeichen in Ordnernamen.** IMAP-Ordner sind auf der Leitung
|
||||||
|
modified-UTF-7 (`Gelöschte`→`Gel&APY-schte`). go-imap dekodiert/kodiert das —
|
||||||
|
intern IMMER mit dem Klartext-Namen arbeiten, **nie doppelt kodieren**.
|
||||||
|
Hierarchie-Trenner (`.` vs `/`) je Server aus LIST lesen und beim Umhängen
|
||||||
|
übersetzen (`11-folders.go`).
|
||||||
|
3. **Standardordner nicht doppeln.** `11-folders.go` bildet jeden Ordner auf
|
||||||
|
eine Rolle ab (SPECIAL-USE `\Sent \Drafts \Junk \Trash \Archive`, sonst
|
||||||
|
mehrsprachige Namensliste). `MapSourceToTarget` sortiert Gesendet/Entwürfe/
|
||||||
|
Spam/Papierkorb/Archiv/Posteingang in den **vorhandenen** Rollen-Ordner des
|
||||||
|
Ziels statt „Gesendete Objekte" **neben** „Gesendete Elemente" zu legen.
|
||||||
|
Manuelles `folder_map` schlägt die Automatik.
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
Tool hält fremde IMAP-Passwörter und kann Mails senden. Default `bind=127.0.0.1`
|
||||||
|
+ Login. Falls je remote (hinter Caddy): `/vadmin`-Mail-2FA-Muster aus dem
|
||||||
|
Web-Deploy-Kit vorschalten. `config.json`/DB nie committen (`.gitignore` steht).
|
||||||
|
|
||||||
|
## Fertig-Kriterien (Durchstich zuerst)
|
||||||
|
|
||||||
|
1. **Durchstich:** ein Konto, nur `INBOX`, Quelle→Ziel-`APPEND` + mbox,
|
||||||
|
Message-ID-Dedup, zweimal laufbar ohne Dubletten.
|
||||||
|
2. Alle Ordner rekursiv + `EnsureFolder` im Ziel.
|
||||||
|
3. Konten-CRUD + Test-Button im Browser.
|
||||||
|
4. Viewer (Liste/Lesen) + Weiterleiten.
|
||||||
|
5. `--watch`-Delta-Schleife.
|
||||||
|
6. Optional: POP3-Fallback.
|
||||||
34
Codex-Prompt-Durchstich.md
Normal file
34
Codex-Prompt-Durchstich.md
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Codex-Prompt — Durchstich
|
||||||
|
|
||||||
|
Kopiervorlage für Codex. Erst `Codex-Briefing.md` + `Projektbeschreibung.md`
|
||||||
|
lesen, dann NUR den Durchstich bauen (nicht mehr).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Baue den **Durchstich** des Email-Forwarder (Go-Single-Binary, Hausstil steht als
|
||||||
|
Stub-Gerüst). Ziel: **ein Konto, nur den Posteingang, sauber und wiederholbar
|
||||||
|
umziehen.** Lies zuerst `Codex-Briefing.md` und `Projektbeschreibung.md`; die 6
|
||||||
|
harten Regeln sind bindend.
|
||||||
|
|
||||||
|
Umfang des Durchstichs:
|
||||||
|
|
||||||
|
1. `02-database.go`: SQLite-Schema anlegen (accounts, folder_map, copied, jobs),
|
||||||
|
plus `GetAccount`, `ListAccounts`, `SaveAccount`, `AlreadyCopied`, `MarkCopied`.
|
||||||
|
2. `04-imap-source.go`: `OpenIMAPSource` für IMAP mit allen drei Security-Modi
|
||||||
|
(`tls`/`starttls`/`none`) und `SrcInsecure`. `Folders()` (mit SPECIAL-USE +
|
||||||
|
Delim) und `Fetch("INBOX")` (`UID FETCH BODY[] FLAGS INTERNALDATE`, streamend).
|
||||||
|
3. `05-imap-target.go`: `OpenIMAPTarget` (gleiche Security-Logik), `Folders()`,
|
||||||
|
`Delim()`, `EnsureFolder`, `Append` mit Flags + INTERNALDATE.
|
||||||
|
4. `06-mbox.go`: `MboxWriter` + `Append` (korrektes "From "-Trennen, >From-Quoting).
|
||||||
|
5. `07-migrate.go`: `migrateAccount` für nur "INBOX": pro Mail Message-ID gegen
|
||||||
|
`copied` prüfen; sonst `Append` (IMAP) **und** mbox schreiben, dann `MarkCopied`.
|
||||||
|
`RunMigration(name, false)` ruft es für ein Konto auf.
|
||||||
|
6. `main.go --run <konto>` muss durchlaufen.
|
||||||
|
|
||||||
|
Bewusst NOCH NICHT: Web-CRUD, Viewer, Weiterleiten, alle Ordner rekursiv,
|
||||||
|
Rollen-Mapping (11-folders.go), `--watch`, POP3. Nur INBOX, nur CLI.
|
||||||
|
|
||||||
|
**Abnahme:** Konto per SQL/Seed eintragen (Klartext-IMAP-Quelle testen!),
|
||||||
|
`./emailforwarder --run test` zweimal laufen lassen → beim zweiten Mal werden
|
||||||
|
0 Mails kopiert (Idempotenz), Ziel-INBOX und lokale mbox enthalten dieselben
|
||||||
|
Mails mit erhaltenen Flags und Original-Datum.
|
||||||
59
INSTALL.md
Normal file
59
INSTALL.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Email-Forwarder — bauen & benutzen
|
||||||
|
|
||||||
|
## 1. Bauen
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./compile.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Holt htmx ins `frontend-js/dist`, macht `go mod tidy` und baut ein statisches
|
||||||
|
Binary für Linux **und** Windows (`emailforwarder` / `emailforwarder.exe`).
|
||||||
|
|
||||||
|
## 2. Konfig
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp config.json.example config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`config.json` (bleibt lokal, ist ge-`.gitignore`-t) setzt nur App-Sachen:
|
||||||
|
Bind/Port, Admin-Login, DB-Pfad, mbox-Wurzel und das **Forward-SMTP** (nur fürs
|
||||||
|
manuelle Weiterleiten aus dem Viewer). Die **Umzugs-Konten** legst du im Browser
|
||||||
|
an, nicht hier.
|
||||||
|
|
||||||
|
## 3. Starten (Web)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./emailforwarder # lauscht auf 127.0.0.1:8087
|
||||||
|
```
|
||||||
|
|
||||||
|
Browser auf `http://127.0.0.1:8087`, mit Admin-Login aus `config.json`.
|
||||||
|
Oberfläche im Outlook-2013-Look: Reiter **Postfächer** (Konten anlegen: Quelle
|
||||||
|
alt → Ziel neu, Sicherheits-Modus `tls`/`starttls`/`none`, „Test" prüft beide
|
||||||
|
Logins), Reiter **Umzug** (starten + Fortschritt), Reiter **Betrachten**
|
||||||
|
(lokale mbox durchsehen, einzeln weiterleiten).
|
||||||
|
|
||||||
|
## 4. Starten (CLI — Cutover)
|
||||||
|
|
||||||
|
Ohne Web, z. B. als Cronjob oder Dauerlauf im Umzugsfenster:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./emailforwarder --run all # einmalig alle Konten (Delta)
|
||||||
|
./emailforwarder --run dr-gold --watch # ein Konto, Delta-Schleife bis Stop
|
||||||
|
```
|
||||||
|
|
||||||
|
Dank Message-ID-Cache kopiert jeder Lauf nur Neues — beliebig oft wiederholbar
|
||||||
|
ohne Dubletten. Für den Dauerlauf reicht `nohup`/systemd/Aufgabenplanung.
|
||||||
|
|
||||||
|
## 5. Sicherheit
|
||||||
|
|
||||||
|
Tool hält fremde IMAP-Passwörter und kann Mails senden → Default bind
|
||||||
|
`127.0.0.1`. Falls remote nötig: hinter Caddy + `/vadmin`-Mail-2FA. `config.json`
|
||||||
|
und `*.db` nie committen.
|
||||||
|
|
||||||
|
## 6. Reihenfolge bei einem echten Umzug
|
||||||
|
|
||||||
|
1. Ziel-Postfächer beim neuen Hoster anlegen (`archiv-…`, `temp-…`).
|
||||||
|
2. Konten im Browser eintragen, „Test" grün.
|
||||||
|
3. `--run` laufen lassen, im **Betrachten**-Reiter / mbox stichprobenartig prüfen.
|
||||||
|
4. Während der Umstellung `--watch` für das Delta laufen lassen.
|
||||||
|
5. Domain portieren; danach zeigt der neue Hoster auf die Zielpostfächer.
|
||||||
47
Projektbeschreibung.md
Normal file
47
Projektbeschreibung.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Email-Forwarder
|
||||||
|
|
||||||
|
Kleines Go-Single-Binary für **Postfach-Umzüge zwischen Hosts**. Holt Mails aus
|
||||||
|
Quell-Konten (IMAP, POP3 nur als Notnagel) und spielt sie **1:1 in Ziel-Konten
|
||||||
|
auf dem neuen Host** ein – Ordnerstruktur, Flags und Original-Datum bleiben
|
||||||
|
erhalten. Gleichzeitig wird jede Mail lokal als **mbox** (Cold Backup)
|
||||||
|
weggeschrieben. Bedienung komplett im Browser im **Outlook-2013-Look**; die
|
||||||
|
lokalen mbox-Postfächer lassen sich dort auch betrachten und bei Bedarf einzeln
|
||||||
|
weiterleiten.
|
||||||
|
|
||||||
|
## Wozu
|
||||||
|
|
||||||
|
Reiner Host-Umzug. Beispiel dr-gold:
|
||||||
|
|
||||||
|
| Quelle (Intersolute, IMAP) | Ziel (All-Inkl, IMAP) | Fall |
|
||||||
|
|----------------------------|------------------------------------|-------------------|
|
||||||
|
| `hans-peter@dr-gold.de` | `archiv-hans-peter@dr-gold.com` | Ex-Mitarbeiter |
|
||||||
|
| `jane-doe@dr-gold.de` | `temp-jane-doe@dr-gold.com` | aktiv, Übergang |
|
||||||
|
|
||||||
|
Zieladresse ist **frei gemappt** (eine Zeile pro Konto). Nach dem Umzug wird die
|
||||||
|
Domain portiert; für das Tool ist das egal – es arbeitet nur mit dem Mapping,
|
||||||
|
das zum Umzugszeitpunkt gilt.
|
||||||
|
|
||||||
|
## Die harten Regeln (nicht verhandelbar)
|
||||||
|
|
||||||
|
1. **Umzug = IMAP `APPEND`, niemals SMTP.** Nur `APPEND` erhält Ordner, Flags
|
||||||
|
(gelesen/beantwortet) und `INTERNALDATE`. SMTP würde alles als neu/ungelesen
|
||||||
|
mit falschem Datum zustellen und die Ordner platt machen.
|
||||||
|
2. **SMTP nur fürs manuelle Weiterleiten** einzelner Mails aus dem Viewer –
|
||||||
|
sauber getrennt vom Umzug (`09-smtp.go`).
|
||||||
|
3. **Lokales Format = mbox** (eine Datei pro Ordner). Kein PST – in Go nicht
|
||||||
|
lean schreibbar. mbox öffnet eM Client / Thunderbird direkt.
|
||||||
|
4. **Idempotenz über Message-ID.** Zweiter Lauf kopiert nur das Delta → erlaubt
|
||||||
|
`--watch` als Cutover-Delta-Sync bis zur Portierung.
|
||||||
|
5. **Secrets bleiben lokal.** `config.json` und die DB (mit den Postfach-
|
||||||
|
Passwörtern) sind ge-`.gitignore`-t. Erst bei Go-Live rotieren + einlagern.
|
||||||
|
6. **Alt-Provider-tauglich.** Muss mit **unverschlüsseltem** IMAP/POP3 (Klartext
|
||||||
|
143/110, STARTTLS, kaputte Zerts) klarkommen. Ordnernamen mit **Sonderzeichen**
|
||||||
|
(modified-UTF-7) sauber übertragen. **Standardordner** (Posteingang/Gesendet/
|
||||||
|
Entwürfe/Spam/Papierkorb/Archiv/Postausgang) werden per Rolle zusammengeführt,
|
||||||
|
nicht wegen Namensunterschied gedoppelt. Details: `backend/11-folders.go`.
|
||||||
|
|
||||||
|
## Bedienung
|
||||||
|
|
||||||
|
- **Web (Default):** `./emailforwarder` → Browser auf `127.0.0.1:8087`.
|
||||||
|
- **CLI (Cronjob/Cutover):** `./emailforwarder --run all` (einmalig) oder
|
||||||
|
`./emailforwarder --run dr-gold --watch` (Delta-Schleife).
|
||||||
79
backend/00-router.go
Normal file
79
backend/00-router.go
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/", homeHandler)
|
||||||
|
mux.HandleFunc("/login", loginHandler)
|
||||||
|
|
||||||
|
// Umzug (Konten pflegen + starten/beobachten)
|
||||||
|
mux.HandleFunc("/accounts", accountsHandler) // TODO Codex (02/07)
|
||||||
|
mux.HandleFunc("/accounts/save", accountSaveHandler) // TODO Codex
|
||||||
|
mux.HandleFunc("/accounts/delete", accountDeleteHandler) // TODO Codex
|
||||||
|
mux.HandleFunc("/accounts/test", accountTestHandler) // TODO Codex: Quelle+Ziel-Login pruefen
|
||||||
|
mux.HandleFunc("/migrate/run", migrateRunHandler) // TODO Codex: RunMigration im Hintergrund
|
||||||
|
mux.HandleFunc("/migrate/status", migrateStatusHandler) // TODO Codex: jobs-Fortschritt (HTMX-Poll)
|
||||||
|
|
||||||
|
// Viewer (lokale mbox betrachten + weiterleiten)
|
||||||
|
mux.HandleFunc("/view", viewerHandler)
|
||||||
|
mux.HandleFunc("/view/message", messageHandler)
|
||||||
|
mux.HandleFunc("/view/forward", forwardHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// homeHandler rendert die Outlook-2013-Oberflaeche (Dreispalter). Der linke
|
||||||
|
// Baum, die Nachrichtenliste und der Lesebereich werden per HTMX nachgeladen.
|
||||||
|
func homeHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// "active" ist die CSS-Klasse fuer den hervorgehobenen Reiter. Wenn Codex
|
||||||
|
// weitere Seiten baut, jeweils den passenden Reiter aktiv setzen.
|
||||||
|
renderShell(w, "active", `<div class="ef-empty">Konto links waehlen oder unter „Postfaecher" anlegen.</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderShell ist das Grundgeruest im Outlook-2013-Look:
|
||||||
|
// [ Ribbon: Umzug | Postfaecher | Betrachten | Weiterleiten | Einstellungen ]
|
||||||
|
// [ Ordnerbaum (links) | Nachrichtenliste (mitte) | Lesebereich (rechts) ]
|
||||||
|
func renderShell(w http.ResponseWriter, active, readingPane string) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Email-Forwarder</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<script src="/static/htmx.min.js"></script></head>
|
||||||
|
<body class="ol2013">
|
||||||
|
<header class="ribbon">
|
||||||
|
<div class="ribbon-title">Email-Forwarder</div>
|
||||||
|
<nav class="ribbon-tabs">
|
||||||
|
<a class="tab %s" href="/">Umzug</a>
|
||||||
|
<a class="tab" href="/accounts">Postfächer</a>
|
||||||
|
<a class="tab" href="/view">Betrachten</a>
|
||||||
|
<a class="tab" href="/accounts">Einstellungen</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<div class="three-pane">
|
||||||
|
<aside class="pane pane-tree" id="tree"
|
||||||
|
hx-get="/view" hx-trigger="load" hx-target="#tree" hx-swap="innerHTML">
|
||||||
|
<div class="pane-head">Postfächer</div>
|
||||||
|
</aside>
|
||||||
|
<section class="pane pane-list" id="list">
|
||||||
|
<div class="pane-head">Nachrichten</div>
|
||||||
|
</section>
|
||||||
|
<section class="pane pane-read" id="read">
|
||||||
|
<div class="pane-head">Lesebereich</div>
|
||||||
|
<div class="read-body">%s</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<footer class="statusbar"><span id="mig-status"
|
||||||
|
hx-get="/migrate/status" hx-trigger="every 3s" hx-swap="innerHTML">bereit</span></footer>
|
||||||
|
</body></html>`, active, readingPane)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Stubs fuer die noch nicht gebauten Handler (TODO Codex) ---
|
||||||
|
|
||||||
|
func accountsHandler(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func accountSaveHandler(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func accountDeleteHandler(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func accountTestHandler(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func migrateRunHandler(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func migrateStatusHandler(w http.ResponseWriter, r *http.Request) {}
|
||||||
51
backend/01-config.go
Normal file
51
backend/01-config.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cfg ist die globale App-Konfiguration (config.json). Enthaelt NUR
|
||||||
|
// App-/Weiterleitungs-Einstellungen. Die Umzugs-Konten (Quelle->Ziel) leben
|
||||||
|
// in der DB (Tabelle accounts) und werden im Browser gepflegt.
|
||||||
|
var Cfg Config
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Bind string `json:"bind"`
|
||||||
|
Port string `json:"port"`
|
||||||
|
AdminUser string `json:"admin_user"`
|
||||||
|
AdminPass string `json:"admin_pass"`
|
||||||
|
DBPath string `json:"db_path"`
|
||||||
|
MboxRoot string `json:"mbox_root"`
|
||||||
|
ForwardSMTP SMTPConfig `json:"forward_smtp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMTPConfig ist NUR fuer das manuelle Weiterleiten einzelner Mails aus dem
|
||||||
|
// Viewer. Der Umzug selbst nutzt NIEMALS SMTP, sondern IMAP APPEND.
|
||||||
|
type SMTPConfig struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Pass string `json:"pass"`
|
||||||
|
From string `json:"from"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig(path string) error {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &Cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if Cfg.Bind == "" {
|
||||||
|
Cfg.Bind = "127.0.0.1"
|
||||||
|
}
|
||||||
|
if Cfg.Port == "" {
|
||||||
|
Cfg.Port = "8087"
|
||||||
|
}
|
||||||
|
if Cfg.MboxRoot == "" {
|
||||||
|
Cfg.MboxRoot = "./backup"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
59
backend/02-database.go
Normal file
59
backend/02-database.go
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DB *sql.DB
|
||||||
|
|
||||||
|
// Account = eine Umzugs-Zeile: Quelle (alt) -> Ziel (neu), frei gemapptes
|
||||||
|
// Zieladress-Postfach. Wird im Browser gepflegt (Tabelle accounts).
|
||||||
|
type Account struct {
|
||||||
|
ID int64
|
||||||
|
Name string // z.B. "hans-peter"
|
||||||
|
// Quelle (alter Provider). Security: "tls" (implizit, 993/995),
|
||||||
|
// "starttls" (Klartext-Port 143/110 + Upgrade), "none" (reiner Klartext).
|
||||||
|
// SrcInsecure = ungueltige/selbstsignierte Zerts akzeptieren (alte Hoster).
|
||||||
|
SrcHost string
|
||||||
|
SrcPort int
|
||||||
|
SrcSecurity string
|
||||||
|
SrcInsecure bool
|
||||||
|
SrcUser string // hans-peter@dr-gold.de
|
||||||
|
SrcPass string
|
||||||
|
SrcProto string // "imap" (default) oder "pop3" (Fallback-Quelle)
|
||||||
|
// Ziel (neuer Provider). Meist "tls"; "none"/"starttls" ebenso moeglich.
|
||||||
|
DstHost string
|
||||||
|
DstPort int
|
||||||
|
DstSecurity string
|
||||||
|
DstInsecure bool
|
||||||
|
DstUser string // archiv-hans-peter@dr-gold.com
|
||||||
|
DstPass string
|
||||||
|
MboxDir string // Unterordner unter Cfg.MboxRoot; leer = Cfg.MboxRoot/Name
|
||||||
|
Active bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectDB oeffnet die SQLite-DB (modernc, kein cgo) und legt das Schema an.
|
||||||
|
//
|
||||||
|
// Schema (TODO Codex):
|
||||||
|
//
|
||||||
|
// accounts(...) -- die Umzugs-Zeilen (oben), inkl.
|
||||||
|
// src_security/src_insecure etc.
|
||||||
|
// folder_map(account_id, src, dst) -- optionales manuelles Umbenennen
|
||||||
|
// (Vorrang vor der Rollen-Automatik
|
||||||
|
// aus 11-folders.go)
|
||||||
|
// copied(account_id, folder, message_id) -- Idempotenz-/Delta-Cache: schon
|
||||||
|
// kopierte Message-IDs, UNIQUE.
|
||||||
|
// Zweiter Lauf kopiert nur das Delta.
|
||||||
|
// jobs(account_id, started, finished, -- Lauf-Historie fuer den Fortschritt
|
||||||
|
// total, done, errors, state) im Browser.
|
||||||
|
func ConnectDB() error {
|
||||||
|
// TODO Codex: sql.Open("sqlite", Cfg.DBPath), PRAGMA journal_mode=WAL,
|
||||||
|
// CREATE TABLE IF NOT EXISTS ... siehe Schema oben.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO Codex: ListAccounts / SaveAccount / DeleteAccount / GetAccount,
|
||||||
|
// AlreadyCopied(accountID, folder, messageID) bool,
|
||||||
|
// MarkCopied(accountID, folder, messageID).
|
||||||
28
backend/03-auth.go
Normal file
28
backend/03-auth.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package backend
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// InitAuth bereitet die Session-/Login-Verwaltung vor.
|
||||||
|
//
|
||||||
|
// Sicherheitslage: Dieses Tool haelt IMAP-Zugangsdaten fremder Postfaecher und
|
||||||
|
// kann per SMTP Mails verschicken. Es darf NIE offen im Netz stehen.
|
||||||
|
// - Default: bind 127.0.0.1 (nur lokal), Login mit admin_user/admin_pass.
|
||||||
|
// - Wenn spaeter remote erreichbar (hinter Caddy): /vadmin-Mail-2FA-Muster
|
||||||
|
// aus dem Web-Deploy-Kit vorschalten.
|
||||||
|
func InitAuth() error {
|
||||||
|
// TODO Codex: Session-Cookie-Store initialisieren.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthMiddleware schuetzt alle Routen ausser /login und /static.
|
||||||
|
func AuthMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// TODO Codex: /login + /static durchlassen, sonst Session pruefen,
|
||||||
|
// sonst Redirect /login.
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// TODO Codex: Formular, Abgleich mit Cfg.AdminUser/AdminPass, Cookie setzen.
|
||||||
|
}
|
||||||
46
backend/04-imap-source.go
Normal file
46
backend/04-imap-source.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
package backend
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// RawMessage ist eine 1:1 aus der Quelle geholte Nachricht: der Rohkoerper
|
||||||
|
// plus die Metadaten, die den sauberen Umzug ausmachen.
|
||||||
|
type RawMessage struct {
|
||||||
|
MessageID string // aus Header, Schluessel fuer Idempotenz
|
||||||
|
Body []byte // kompletter RFC822-Rohtext, wird NICHT umgeschrieben
|
||||||
|
Flags []string // \Seen \Answered \Flagged ... 1:1 uebernehmen
|
||||||
|
InternalDate time.Time // Original-Zeit, per APPEND erhalten
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folder ist ein Ordner der Quelle MIT den Infos, die 11-folders.go fuer die
|
||||||
|
// Rollen-Zuordnung braucht: dekodierter Name (Umlaute als Klartext, NICHT
|
||||||
|
// modified-UTF-7), SPECIAL-USE-Attribute und der Hierarchie-Trenner.
|
||||||
|
type Folder struct {
|
||||||
|
Name string // dekodiert, z.B. "Gelöschte Objekte" oder "INBOX.Sent"
|
||||||
|
Attrs []string // z.B. ["\\Sent"] aus SPECIAL-USE
|
||||||
|
Delim string // "." oder "/" — servereigener Trenner
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceMailbox abstrahiert Quelle (IMAP primaer, POP3 Fallback), damit
|
||||||
|
// 07-migrate.go gegen ein Interface arbeitet.
|
||||||
|
type SourceMailbox interface {
|
||||||
|
Folders() ([]Folder, error) // rekursiver Ordnerbaum
|
||||||
|
Fetch(folder string) ([]RawMessage, error) // Stream: BODY[] FLAGS INTERNALDATE
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenIMAPSource verbindet die Quelle per go-imap/v2.
|
||||||
|
//
|
||||||
|
// Verschluesselung nach a.SrcSecurity:
|
||||||
|
// - "tls": imapclient.DialTLS (impliziter TLS, Port 993)
|
||||||
|
// - "starttls": imapclient.DialStartTLS auf Klartext-Port 143
|
||||||
|
// - "none": imapclient.Dial (REINER KLARTEXT, alte Provider!)
|
||||||
|
//
|
||||||
|
// a.SrcInsecure -> tls.Config{InsecureSkipVerify:true} fuer kaputte/selbst-
|
||||||
|
// signierte Zerts. go-imap dekodiert modified-UTF-7-Ordnernamen selbst; wir
|
||||||
|
// geben in Folder.Name den Klartext weiter.
|
||||||
|
func OpenIMAPSource(a Account) (SourceMailbox, error) {
|
||||||
|
// TODO Codex: nach SrcSecurity verbinden, Login, imapSource zurueckgeben.
|
||||||
|
// Folders() = LIST "" "*" mit SPECIAL-USE + Delim; Fetch() = SELECT +
|
||||||
|
// UID FETCH BODY[] FLAGS INTERNALDATE, streamend um RAM zu schonen.
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
24
backend/05-imap-target.go
Normal file
24
backend/05-imap-target.go
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
package backend
|
||||||
|
|
||||||
|
// TargetMailbox = das Ziel-Postfach (neuer Host). Der Umzug schreibt hier per
|
||||||
|
// IMAP APPEND hinein -- NICHT per SMTP. APPEND erhaelt Ordner, Flags und
|
||||||
|
// Original-Datum; SMTP wuerde alles als "neu/ungelesen" mit falschem Datum
|
||||||
|
// zustellen und die Ordnerstruktur zerstoeren.
|
||||||
|
type TargetMailbox interface {
|
||||||
|
Folders() ([]TargetFolder, error) // vorhandene Ziel-Ordner (fuer Rollen-Zuordnung)
|
||||||
|
Delim() string // Hierarchie-Trenner des Ziels
|
||||||
|
EnsureFolder(name string) error // anlegen + subscriben, falls fehlt
|
||||||
|
Append(folder string, m RawMessage) error // mit m.Flags und m.InternalDate
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenIMAPTarget verbindet das Ziel per go-imap/v2 (Security wie Quelle, siehe
|
||||||
|
// a.DstSecurity/a.DstInsecure; meist "tls").
|
||||||
|
func OpenIMAPTarget(a Account) (TargetMailbox, error) {
|
||||||
|
// TODO Codex: nach DstSecurity verbinden + Login. Folders()/Delim() aus
|
||||||
|
// LIST. EnsureFolder = CREATE (Fehler "existiert" schlucken) + SUBSCRIBE,
|
||||||
|
// Name in modified-UTF-7 ueberlaesst go-imap der Lib (Klartext uebergeben!).
|
||||||
|
// Append = APPEND mit FLAGS + INTERNALDATE. Zielordner NIE selbst waehlen —
|
||||||
|
// 07-migrate.go liefert ihn via MapSourceToTarget (11-folders.go).
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
38
backend/06-mbox.go
Normal file
38
backend/06-mbox.go
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
package backend
|
||||||
|
|
||||||
|
// mbox ist das lokale Cold-Backup-Format: eine Datei pro IMAP-Ordner, der
|
||||||
|
// Ordnerbaum 1:1 auf der Platte. eM Client / Thunderbird importieren mbox
|
||||||
|
// direkt. (Kein PST -- das laesst sich in Go nicht lean schreiben.)
|
||||||
|
|
||||||
|
// MboxWriter haengt Nachrichten an die mbox-Datei eines Ordners an.
|
||||||
|
type MboxWriter struct {
|
||||||
|
// TODO Codex: root-Verzeichnis des Kontos.
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMboxWriter legt <mboxRoot>/<account>/ an und spiegelt den Ordnerbaum.
|
||||||
|
func NewMboxWriter(dir string) (*MboxWriter, error) {
|
||||||
|
// TODO Codex.
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append schreibt eine Mail im mbox-Format ("From "-Trennzeile, >From-Quoting)
|
||||||
|
// in <ordner>.mbox.
|
||||||
|
func (w *MboxWriter) Append(folder string, m RawMessage) error {
|
||||||
|
// TODO Codex.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Viewer-Seite: mbox wieder lesen fuers Browser-Betrachten (08-viewer.go) ---
|
||||||
|
|
||||||
|
// MboxEntry ist eine Nachricht wie im Listen-Pane angezeigt.
|
||||||
|
type MboxEntry struct {
|
||||||
|
Index int
|
||||||
|
From string
|
||||||
|
Subject string
|
||||||
|
Date string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadMboxList parst die Kopfzeilen aller Mails einer mbox-Datei (fuer die
|
||||||
|
// Nachrichtenliste). ReadMboxMessage liefert eine einzelne Mail als Rohtext.
|
||||||
|
func ReadMboxList(path string) ([]MboxEntry, error) { return nil, nil } // TODO Codex
|
||||||
|
func ReadMboxMessage(path string, index int) ([]byte, error) { return nil, nil } // TODO Codex
|
||||||
28
backend/07-migrate.go
Normal file
28
backend/07-migrate.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package backend
|
||||||
|
|
||||||
|
// RunMigration ist der Motor. Pro Konto:
|
||||||
|
// 1. Quelle oeffnen (IMAP, sonst POP3-Fallback), Ziel oeffnen. Ordnerbaum der
|
||||||
|
// Quelle (mit SPECIAL-USE + Delim) und vorhandene Ziel-Ordner holen.
|
||||||
|
// 2. Pro Quell-Ordner Zielordner via MapSourceToTarget (11-folders.go)
|
||||||
|
// bestimmen — Standardordner (Gesendet/Entwuerfe/Spam/Papierkorb/Archiv/
|
||||||
|
// Posteingang) landen im vorhandenen Rollen-Ordner des Ziels statt als
|
||||||
|
// Dublette; Pfad-Trenner wird umgehaengt. Dann EnsureFolder + Mails streamen.
|
||||||
|
// 3. Pro Mail: Message-ID gegen copied-Cache pruefen -> schon da? ueberspringen.
|
||||||
|
// Sonst DOPPELT schreiben: TargetMailbox.Append (IMAP) + MboxWriter.Append
|
||||||
|
// (lokal), dann MarkCopied.
|
||||||
|
// 4. Fortschritt in jobs-Tabelle schreiben (Browser-Anzeige).
|
||||||
|
//
|
||||||
|
// name = Konto-Name oder "all". watch = true -> Delta-Schleife bis Abbruch
|
||||||
|
// (Cutover-Fenster), sonst einmaliger Durchlauf.
|
||||||
|
func RunMigration(name string, watch bool) error {
|
||||||
|
// TODO Codex: Accounts laden (name/"all"), pro Konto migrateAccount().
|
||||||
|
// Bei watch: Schleife mit Pause, bis Kontext abgebrochen; dank copied-Cache
|
||||||
|
// kopiert jeder Durchlauf nur Neues.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateAccount fuehrt einen einzelnen Umzug aus (ein Durchlauf).
|
||||||
|
func migrateAccount(a Account) error {
|
||||||
|
// TODO Codex: siehe Schritte oben. Fehler pro Mail zaehlen, nicht abbrechen.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
23
backend/08-viewer.go
Normal file
23
backend/08-viewer.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
package backend
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// Der Viewer betrachtet die LOKALEN mbox-Dateien im Browser (Outlook-2013-
|
||||||
|
// Dreispalter): links Konto/Ordner-Baum, Mitte Nachrichtenliste, rechts
|
||||||
|
// Lesebereich. Optional Weiterleiten einzelner Mails per SMTP (09-smtp.go).
|
||||||
|
|
||||||
|
func viewerHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// TODO Codex: Konto+Ordner aus Query, ReadMboxList -> Nachrichtenliste
|
||||||
|
// (Mitte) als HTMX-Fragment rendern.
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// TODO Codex: ReadMboxMessage -> Header + Body (Text/HTML sanitisiert)
|
||||||
|
// in den Lesebereich (rechts) rendern.
|
||||||
|
}
|
||||||
|
|
||||||
|
func forwardHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// TODO Codex: GET = Weiterleiten-Formular (An/Betreff/Text), POST =
|
||||||
|
// ForwardMessage() aufrufen. NUR hier kommt SMTP zum Einsatz -- der Umzug
|
||||||
|
// selbst nutzt IMAP APPEND.
|
||||||
|
}
|
||||||
13
backend/09-smtp.go
Normal file
13
backend/09-smtp.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package backend
|
||||||
|
|
||||||
|
// SMTP wird AUSSCHLIESSLICH fuers manuelle Weiterleiten einzelner Mails aus dem
|
||||||
|
// Viewer benutzt (Cfg.ForwardSMTP). Der Postfach-Umzug laeuft NIE ueber SMTP,
|
||||||
|
// sondern ueber IMAP APPEND (05-imap-target.go) -- sonst gingen Datum, Flags
|
||||||
|
// und Ordnerstruktur verloren.
|
||||||
|
|
||||||
|
// ForwardMessage leitet den mbox-Rohtext an einen Empfaenger weiter.
|
||||||
|
func ForwardMessage(rawMessage []byte, to []string, extraNote string) error {
|
||||||
|
// TODO Codex: net/smtp (stdlib) mit STARTTLS + AUTH gegen Cfg.ForwardSMTP.
|
||||||
|
// Original als Anhang oder inline weiterleiten, From = Cfg.ForwardSMTP.From.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
13
backend/10-pop3.go
Normal file
13
backend/10-pop3.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package backend
|
||||||
|
|
||||||
|
// POP3 ist NUR Fallback-Quelle, wenn ein Altserver kein IMAP kann. POP3 sieht
|
||||||
|
// nur den Posteingang, keine Ordner, keine Flags und will oft loeschen -- also
|
||||||
|
// wirklich nur der Notnagel. Ziel bleibt immer IMAP APPEND.
|
||||||
|
|
||||||
|
// OpenPOP3Source erfuellt dasselbe SourceMailbox-Interface wie die IMAP-Quelle,
|
||||||
|
// meldet aber nur einen einzigen Pseudo-Ordner "INBOX".
|
||||||
|
func OpenPOP3Source(a Account) (SourceMailbox, error) {
|
||||||
|
// TODO Codex: github.com/knadh/go-pop3, LIST + RETR, NIE DELE senden
|
||||||
|
// (Quelle unangetastet lassen).
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
112
backend/11-folders.go
Normal file
112
backend/11-folders.go
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
package backend
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Dieses Modul löst zwei Umzugs-Fallen:
|
||||||
|
//
|
||||||
|
// 1. Standardordner NICHT doppeln. Quelle "Gesendete Objekte" darf am Ziel
|
||||||
|
// nicht neben "Gesendete Elemente" als Zweitordner landen. Wir bilden jeden
|
||||||
|
// Ordner auf eine kanonische ROLLE ab und schreiben in den vorhandenen
|
||||||
|
// Rollen-Ordner des Ziels — statt einen neuen anzulegen.
|
||||||
|
// 2. Hierarchie-Trenner übersetzen. Quelle nutzt evtl. "." (INBOX.Sent.2023),
|
||||||
|
// Ziel "/" — Pfad muss umgehängt werden. Sonderzeichen (Umlaute) sind auf
|
||||||
|
// der Leitung modified-UTF-7; go-imap dekodiert/kodiert das — wir arbeiten
|
||||||
|
// hier IMMER mit dem dekodierten Klartext-Namen, nie doppelt kodieren.
|
||||||
|
|
||||||
|
type FolderRole string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleNone FolderRole = ""
|
||||||
|
RoleInbox FolderRole = "inbox"
|
||||||
|
RoleSent FolderRole = "sent"
|
||||||
|
RoleDrafts FolderRole = "drafts"
|
||||||
|
RoleJunk FolderRole = "junk"
|
||||||
|
RoleTrash FolderRole = "trash"
|
||||||
|
RoleArchive FolderRole = "archive"
|
||||||
|
RoleOutbox FolderRole = "outbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// specialUseAttr = IMAP SPECIAL-USE (RFC 6154) -> Rolle. Erste Wahl, weil
|
||||||
|
// sprachunabhängig. Outbox/Postausgang hat kein Standard-Flag (Client-lokal).
|
||||||
|
var specialUseAttr = map[string]FolderRole{
|
||||||
|
"\\Sent": RoleSent,
|
||||||
|
"\\Drafts": RoleDrafts,
|
||||||
|
"\\Junk": RoleJunk,
|
||||||
|
"\\Trash": RoleTrash,
|
||||||
|
"\\Archive": RoleArchive,
|
||||||
|
}
|
||||||
|
|
||||||
|
// nameAliases = Fallback, wenn der Alt-Server kein SPECIAL-USE meldet. Vergleich
|
||||||
|
// case-insensitiv gegen das LETZTE Pfadsegment (ohne "INBOX."-Präfix).
|
||||||
|
// Mehrsprachig, weil Provider lokalisiert benennen.
|
||||||
|
var nameAliases = map[FolderRole][]string{
|
||||||
|
RoleInbox: {"inbox", "posteingang"},
|
||||||
|
RoleSent: {"sent", "sent items", "sent messages", "gesendet", "gesendete", "gesendete objekte", "gesendete elemente", "gesendete e-mails"},
|
||||||
|
RoleDrafts: {"drafts", "draft", "entwurf", "entwürfe", "entwuerfe"},
|
||||||
|
RoleJunk: {"junk", "junk e-mail", "junk-e-mail", "spam", "bulk mail", "werbung"},
|
||||||
|
RoleTrash: {"trash", "deleted", "deleted items", "deleted messages", "papierkorb", "gelöscht", "gelöschte", "gelöschte objekte", "gelöschte elemente", "müll"},
|
||||||
|
RoleArchive: {"archive", "archiv", "archived", "archiviert"},
|
||||||
|
RoleOutbox: {"outbox", "postausgang"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleFromAttrs bestimmt die Rolle aus den SPECIAL-USE-Attributen (bevorzugt).
|
||||||
|
func RoleFromAttrs(attrs []string) FolderRole {
|
||||||
|
for _, a := range attrs {
|
||||||
|
if role, ok := specialUseAttr[a]; ok {
|
||||||
|
return role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RoleNone
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleFromName ist der Fallback über den (dekodierten) Ordnernamen. delim ist
|
||||||
|
// der Hierarchie-Trenner des jeweiligen Servers.
|
||||||
|
func RoleFromName(name, delim string) FolderRole {
|
||||||
|
leaf := name
|
||||||
|
if delim != "" {
|
||||||
|
parts := strings.Split(name, delim)
|
||||||
|
leaf = parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimPrefix(name, "INBOX"+delim), "") || strings.EqualFold(name, "INBOX") {
|
||||||
|
return RoleInbox
|
||||||
|
}
|
||||||
|
lc := strings.ToLower(strings.TrimSpace(leaf))
|
||||||
|
for role, aliases := range nameAliases {
|
||||||
|
for _, alias := range aliases {
|
||||||
|
if lc == alias {
|
||||||
|
return role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RoleNone
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role kombiniert beide: erst SPECIAL-USE, dann Namensliste.
|
||||||
|
func Role(attrs []string, name, delim string) FolderRole {
|
||||||
|
if r := RoleFromAttrs(attrs); r != RoleNone {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return RoleFromName(name, delim)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TargetFolder beschreibt einen am Ziel existierenden Ordner (für die Rollen-
|
||||||
|
// Zuordnung beim Umzug).
|
||||||
|
type TargetFolder struct {
|
||||||
|
Name string
|
||||||
|
Attrs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapSourceToTarget entscheidet, in welchen ZIEL-Ordner eine Quell-Mailbox
|
||||||
|
// wandert:
|
||||||
|
// - manuelles folder_map hat Vorrang (übergibt override != "");
|
||||||
|
// - hat die Quelle eine Rolle und existiert am Ziel ein Ordner gleicher Rolle
|
||||||
|
// -> dessen Name (kein Neuanlegen, keine Dublette);
|
||||||
|
// - sonst Pfad vom Quell- auf den Ziel-Trenner umhängen und (ggf. neu) anlegen.
|
||||||
|
//
|
||||||
|
// TODO Codex: Implementieren; srcDelim/dstDelim aus dem jeweiligen Server (LIST-
|
||||||
|
// Antwort). override kommt aus folder_map.
|
||||||
|
func MapSourceToTarget(srcName string, srcAttrs []string, srcDelim, dstDelim string,
|
||||||
|
targets []TargetFolder, override string) string {
|
||||||
|
// TODO Codex.
|
||||||
|
return ""
|
||||||
|
}
|
||||||
17
compile.sh
Normal file
17
compile.sh
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# htmx ins dist holen (wird per //go:embed eingebacken)
|
||||||
|
mkdir -p frontend-js/dist
|
||||||
|
if command -v curl >/dev/null 2>&1 && [ ! -f frontend-js/dist/htmx.min.js ]; then
|
||||||
|
curl -fsSL https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js -o frontend-js/dist/htmx.min.js
|
||||||
|
fi
|
||||||
|
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
# Statisches Binary. Der Umzug laeuft dort, wo Netz zu beiden Providern besteht
|
||||||
|
# (Workstation oder DietPi-NB) -> Linux + Windows bauen.
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w" -o emailforwarder .
|
||||||
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-s -w" -o emailforwarder.exe .
|
||||||
|
echo "built ./emailforwarder und ./emailforwarder.exe"
|
||||||
15
config.json.example
Normal file
15
config.json.example
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"bind": "127.0.0.1",
|
||||||
|
"port": "8087",
|
||||||
|
"admin_user": "vAdmin@golddata.eu",
|
||||||
|
"admin_pass": "CHANGE-ME-lokal",
|
||||||
|
"db_path": "emailforwarder.db",
|
||||||
|
"mbox_root": "./backup",
|
||||||
|
"forward_smtp": {
|
||||||
|
"host": "",
|
||||||
|
"port": 587,
|
||||||
|
"user": "",
|
||||||
|
"pass": "",
|
||||||
|
"from": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
126
frontend-js/dist/style.css
vendored
Normal file
126
frontend-js/dist/style.css
vendored
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
/* Outlook 2013 Look — flach, hellblau, Segoe UI, Dreispalter.
|
||||||
|
Farben nach dem 2013er Flat-Design: Kopfband Outlook-Blau, helle Panes,
|
||||||
|
dezente Rahmen, blaue Auswahlzeile. */
|
||||||
|
:root {
|
||||||
|
--ol-blue: #0072c6; /* Outlook-2013 Kopfband */
|
||||||
|
--ol-blue-dark: #005a9e;
|
||||||
|
--ol-bg: #f3f3f3; /* Fensterhintergrund */
|
||||||
|
--ol-pane: #ffffff;
|
||||||
|
--ol-tree: #f0f0f0; /* linke Spalte etwas grau */
|
||||||
|
--ol-border: #d9d9d9;
|
||||||
|
--ol-sel: #cde6f7; /* markierte Zeile */
|
||||||
|
--ol-sel-bd: #92c0e0;
|
||||||
|
--ol-text: #262626;
|
||||||
|
--ol-muted: #6b6b6b;
|
||||||
|
--ol-unread: #003399; /* ungelesen = kraeftiges Blau, fett */
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; margin: 0; }
|
||||||
|
body.ol2013 {
|
||||||
|
font-family: "Segoe UI", "Segoe UI Web", Tahoma, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ol-text);
|
||||||
|
background: var(--ol-bg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ribbon / Kopfband */
|
||||||
|
.ribbon {
|
||||||
|
background: var(--ol-blue);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 0 16px;
|
||||||
|
height: 44px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.ribbon-title { font-weight: 600; font-size: 15px; letter-spacing: .2px; }
|
||||||
|
.ribbon-tabs { display: flex; gap: 2px; height: 100%; }
|
||||||
|
.tab {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
padding: 0 16px; height: 100%;
|
||||||
|
color: #eaf3fb; text-decoration: none;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
}
|
||||||
|
.tab:hover { background: var(--ol-blue-dark); color: #fff; }
|
||||||
|
.tab.active { background: #fff; color: var(--ol-blue); border-bottom-color: #fff; }
|
||||||
|
|
||||||
|
/* Dreispalter */
|
||||||
|
.three-pane {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 340px 1fr;
|
||||||
|
min-height: 0; /* erlaubt Scrollen in den Panes */
|
||||||
|
}
|
||||||
|
.pane {
|
||||||
|
border-right: 1px solid var(--ol-border);
|
||||||
|
background: var(--ol-pane);
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.pane-tree { background: var(--ol-tree); }
|
||||||
|
.pane-read { border-right: 0; }
|
||||||
|
.pane-head {
|
||||||
|
position: sticky; top: 0;
|
||||||
|
background: #eef3f8;
|
||||||
|
border-bottom: 1px solid var(--ol-border);
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-weight: 600; color: var(--ol-muted);
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ordnerbaum */
|
||||||
|
.tree-node { padding: 4px 12px 4px 24px; cursor: pointer; white-space: nowrap; }
|
||||||
|
.tree-node.account { padding-left: 12px; font-weight: 600; color: var(--ol-blue-dark); }
|
||||||
|
.tree-node:hover { background: #e5eff8; }
|
||||||
|
.tree-node.active { background: var(--ol-sel); box-shadow: inset 2px 0 0 var(--ol-blue); }
|
||||||
|
|
||||||
|
/* Nachrichtenliste */
|
||||||
|
.msg-row {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid #ececec;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
|
}
|
||||||
|
.msg-row:hover { background: #eef6fd; }
|
||||||
|
.msg-row.active { background: var(--ol-sel); border-left: 3px solid var(--ol-blue); padding-left: 9px; }
|
||||||
|
.msg-from { font-weight: 600; }
|
||||||
|
.msg-subject { color: var(--ol-text); }
|
||||||
|
.msg-date { color: var(--ol-muted); font-size: 11px; }
|
||||||
|
.msg-row.unread .msg-from,
|
||||||
|
.msg-row.unread .msg-subject { color: var(--ol-unread); font-weight: 700; }
|
||||||
|
|
||||||
|
/* Lesebereich */
|
||||||
|
.read-body { padding: 16px 20px; }
|
||||||
|
.read-head { border-bottom: 1px solid var(--ol-border); padding-bottom: 10px; margin-bottom: 12px; }
|
||||||
|
.read-subject { font-size: 17px; font-weight: 600; margin: 0 0 6px; }
|
||||||
|
.read-meta { color: var(--ol-muted); font-size: 12px; }
|
||||||
|
.ef-empty { color: var(--ol-muted); padding: 24px; }
|
||||||
|
|
||||||
|
/* Buttons / Formulare (flach, 2013er) */
|
||||||
|
.btn {
|
||||||
|
background: var(--ol-blue); color: #fff; border: 0;
|
||||||
|
padding: 6px 14px; cursor: pointer; font-size: 13px;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--ol-blue-dark); }
|
||||||
|
.btn.secondary { background: #e1e1e1; color: var(--ol-text); border: 1px solid var(--ol-border); }
|
||||||
|
.btn.secondary:hover { background: #d5d5d5; }
|
||||||
|
.input, select, textarea {
|
||||||
|
border: 1px solid #abadb3; padding: 5px 7px; font: inherit; width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.input:focus { border-color: var(--ol-blue); outline: none; }
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 16px; }
|
||||||
|
.label { display: block; font-size: 12px; color: var(--ol-muted); margin-bottom: 3px; }
|
||||||
|
|
||||||
|
/* Statusleiste */
|
||||||
|
.statusbar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: var(--ol-blue); color: #fff;
|
||||||
|
padding: 4px 16px; font-size: 12px;
|
||||||
|
}
|
||||||
|
.ok { color: #107c10; }
|
||||||
|
.bad { color: #d13438; }
|
||||||
10
go.mod
Normal file
10
go.mod
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
module emailforwarder
|
||||||
|
|
||||||
|
go 1.25
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/emersion/go-imap/v2 v2.0.0-beta.5
|
||||||
|
github.com/emersion/go-message v0.18.2
|
||||||
|
github.com/knadh/go-pop3 v1.0.0
|
||||||
|
modernc.org/sqlite v1.40.1
|
||||||
|
)
|
||||||
57
main.go
Normal file
57
main.go
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"emailforwarder/backend"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed frontend-js/dist/*
|
||||||
|
var frontend embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
runOnce := flag.String("run", "", "einen Umzugs-Job einmalig ausfuehren (Konto-Name oder 'all'), ohne Web-UI")
|
||||||
|
watch := flag.Bool("watch", false, "Delta-Sync in Schleife bis Stop (Cutover-Fenster)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if err := backend.LoadConfig("config.json"); err != nil {
|
||||||
|
log.Fatalf("config: %v", err)
|
||||||
|
}
|
||||||
|
if err := backend.ConnectDB(); err != nil {
|
||||||
|
log.Fatalf("database: %v", err)
|
||||||
|
}
|
||||||
|
if err := backend.InitAuth(); err != nil {
|
||||||
|
log.Fatalf("auth: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI-Modus: ohne Web laufen lassen (Cronjob / Cutover).
|
||||||
|
if *runOnce != "" {
|
||||||
|
must(backend.RunMigration(*runOnce, *watch))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dist, err := fs.Sub(frontend, "frontend-js/dist")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(dist))))
|
||||||
|
backend.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
addr := backend.Cfg.Bind + ":" + backend.Cfg.Port
|
||||||
|
log.Printf("Email-Forwarder listening on %s", addr)
|
||||||
|
log.Fatal(http.ListenAndServe(addr, backend.AuthMiddleware(mux)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func must(err error) {
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue