Initial Email-Forwarder scaffold
This commit is contained in:
commit
91e52aecd0
22 changed files with 1007 additions and 0 deletions
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 ""
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue