From 91e52aecd0e5976486db41419bcad9c9b8792a0e Mon Sep 17 00:00:00 2001 From: DonVoo Date: Sun, 5 Jul 2026 17:58:25 +0200 Subject: [PATCH] Initial Email-Forwarder scaffold --- .gitignore | 13 ++++ Codex-Briefing.md | 115 +++++++++++++++++++++++++++++++++ Codex-Prompt-Durchstich.md | 34 ++++++++++ INSTALL.md | 59 +++++++++++++++++ Projektbeschreibung.md | 47 ++++++++++++++ backend/00-router.go | 79 +++++++++++++++++++++++ backend/01-config.go | 51 +++++++++++++++ backend/02-database.go | 59 +++++++++++++++++ backend/03-auth.go | 28 +++++++++ backend/04-imap-source.go | 46 ++++++++++++++ backend/05-imap-target.go | 24 +++++++ backend/06-mbox.go | 38 +++++++++++ backend/07-migrate.go | 28 +++++++++ backend/08-viewer.go | 23 +++++++ backend/09-smtp.go | 13 ++++ backend/10-pop3.go | 13 ++++ backend/11-folders.go | 112 +++++++++++++++++++++++++++++++++ compile.sh | 17 +++++ config.json.example | 15 +++++ frontend-js/dist/style.css | 126 +++++++++++++++++++++++++++++++++++++ go.mod | 10 +++ main.go | 57 +++++++++++++++++ 22 files changed, 1007 insertions(+) create mode 100644 .gitignore create mode 100644 Codex-Briefing.md create mode 100644 Codex-Prompt-Durchstich.md create mode 100644 INSTALL.md create mode 100644 Projektbeschreibung.md create mode 100644 backend/00-router.go create mode 100644 backend/01-config.go create mode 100644 backend/02-database.go create mode 100644 backend/03-auth.go create mode 100644 backend/04-imap-source.go create mode 100644 backend/05-imap-target.go create mode 100644 backend/06-mbox.go create mode 100644 backend/07-migrate.go create mode 100644 backend/08-viewer.go create mode 100644 backend/09-smtp.go create mode 100644 backend/10-pop3.go create mode 100644 backend/11-folders.go create mode 100644 compile.sh create mode 100644 config.json.example create mode 100644 frontend-js/dist/style.css create mode 100644 go.mod create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..88e9f43 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Codex-Briefing.md b/Codex-Briefing.md new file mode 100644 index 0000000..9e8a3b0 --- /dev/null +++ b/Codex-Briefing.md @@ -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. diff --git a/Codex-Prompt-Durchstich.md b/Codex-Prompt-Durchstich.md new file mode 100644 index 0000000..8716f00 --- /dev/null +++ b/Codex-Prompt-Durchstich.md @@ -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 ` 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. diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..88dd9bf --- /dev/null +++ b/INSTALL.md @@ -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. diff --git a/Projektbeschreibung.md b/Projektbeschreibung.md new file mode 100644 index 0000000..95373f6 --- /dev/null +++ b/Projektbeschreibung.md @@ -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). diff --git a/backend/00-router.go b/backend/00-router.go new file mode 100644 index 0000000..aa899f4 --- /dev/null +++ b/backend/00-router.go @@ -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", `
Konto links waehlen oder unter „Postfaecher" anlegen.
`) +} + +// 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, ` + +Email-Forwarder + + + +
+
Email-Forwarder
+ +
+
+ +
+
Nachrichten
+
+
+
Lesebereich
+
%s
+
+
+
bereit
+`, 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) {} diff --git a/backend/01-config.go b/backend/01-config.go new file mode 100644 index 0000000..b03f62f --- /dev/null +++ b/backend/01-config.go @@ -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 +} diff --git a/backend/02-database.go b/backend/02-database.go new file mode 100644 index 0000000..4b976d7 --- /dev/null +++ b/backend/02-database.go @@ -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). diff --git a/backend/03-auth.go b/backend/03-auth.go new file mode 100644 index 0000000..807a111 --- /dev/null +++ b/backend/03-auth.go @@ -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. +} diff --git a/backend/04-imap-source.go b/backend/04-imap-source.go new file mode 100644 index 0000000..6698d73 --- /dev/null +++ b/backend/04-imap-source.go @@ -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 +} diff --git a/backend/05-imap-target.go b/backend/05-imap-target.go new file mode 100644 index 0000000..d3b6a39 --- /dev/null +++ b/backend/05-imap-target.go @@ -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 +} diff --git a/backend/06-mbox.go b/backend/06-mbox.go new file mode 100644 index 0000000..11ee0d1 --- /dev/null +++ b/backend/06-mbox.go @@ -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 // 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 .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 diff --git a/backend/07-migrate.go b/backend/07-migrate.go new file mode 100644 index 0000000..4e6b03d --- /dev/null +++ b/backend/07-migrate.go @@ -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 +} diff --git a/backend/08-viewer.go b/backend/08-viewer.go new file mode 100644 index 0000000..4508ccc --- /dev/null +++ b/backend/08-viewer.go @@ -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. +} diff --git a/backend/09-smtp.go b/backend/09-smtp.go new file mode 100644 index 0000000..e2edd53 --- /dev/null +++ b/backend/09-smtp.go @@ -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 +} diff --git a/backend/10-pop3.go b/backend/10-pop3.go new file mode 100644 index 0000000..b4299e4 --- /dev/null +++ b/backend/10-pop3.go @@ -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 +} diff --git a/backend/11-folders.go b/backend/11-folders.go new file mode 100644 index 0000000..b72bb30 --- /dev/null +++ b/backend/11-folders.go @@ -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 "" +} diff --git a/compile.sh b/compile.sh new file mode 100644 index 0000000..3743d43 --- /dev/null +++ b/compile.sh @@ -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" diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..66b1b65 --- /dev/null +++ b/config.json.example @@ -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": "" + } +} diff --git a/frontend-js/dist/style.css b/frontend-js/dist/style.css new file mode 100644 index 0000000..21893b0 --- /dev/null +++ b/frontend-js/dist/style.css @@ -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; } diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..66f304b --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..f91f81d --- /dev/null +++ b/main.go @@ -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) + } +}