Add CLI INBOX migration breakthrough

This commit is contained in:
DonVoo 2026-07-05 20:51:22 +02:00
parent 8a6160817b
commit 27206930f0
6 changed files with 713 additions and 43 deletions

View file

@ -2,6 +2,9 @@ package backend
import ( import (
"database/sql" "database/sql"
"encoding/json"
"errors"
"os"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@ -11,27 +14,27 @@ var DB *sql.DB
// Account = eine Umzugs-Zeile: Quelle (alt) -> Ziel (neu), frei gemapptes // Account = eine Umzugs-Zeile: Quelle (alt) -> Ziel (neu), frei gemapptes
// Zieladress-Postfach. Wird im Browser gepflegt (Tabelle accounts). // Zieladress-Postfach. Wird im Browser gepflegt (Tabelle accounts).
type Account struct { type Account struct {
ID int64 ID int64 `json:"id"`
Name string // z.B. "hans-peter" Name string `json:"name"` // z.B. "hans-peter"
// Quelle (alter Provider). Security: "tls" (implizit, 993/995), // Quelle (alter Provider). Security: "tls" (implizit, 993/995),
// "starttls" (Klartext-Port 143/110 + Upgrade), "none" (reiner Klartext). // "starttls" (Klartext-Port 143/110 + Upgrade), "none" (reiner Klartext).
// SrcInsecure = ungueltige/selbstsignierte Zerts akzeptieren (alte Hoster). // SrcInsecure = ungueltige/selbstsignierte Zerts akzeptieren (alte Hoster).
SrcHost string SrcHost string `json:"src_host"`
SrcPort int SrcPort int `json:"src_port"`
SrcSecurity string SrcSecurity string `json:"src_security"`
SrcInsecure bool SrcInsecure bool `json:"src_insecure"`
SrcUser string // hans-peter@dr-gold.de SrcUser string `json:"src_user"` // hans-peter@dr-gold.de
SrcPass string SrcPass string `json:"src_pass"`
SrcProto string // "imap" (default) oder "pop3" (Fallback-Quelle) SrcProto string `json:"src_proto"` // "imap" (default) oder "pop3" (Fallback-Quelle)
// Ziel (neuer Provider). Meist "tls"; "none"/"starttls" ebenso moeglich. // Ziel (neuer Provider). Meist "tls"; "none"/"starttls" ebenso moeglich.
DstHost string DstHost string `json:"dst_host"`
DstPort int DstPort int `json:"dst_port"`
DstSecurity string DstSecurity string `json:"dst_security"`
DstInsecure bool DstInsecure bool `json:"dst_insecure"`
DstUser string // archiv-hans-peter@dr-gold.com DstUser string `json:"dst_user"` // archiv-hans-peter@dr-gold.com
DstPass string DstPass string `json:"dst_pass"`
MboxDir string // Unterordner unter Cfg.MboxRoot; leer = Cfg.MboxRoot/Name MboxDir string `json:"mbox_dir"` // Unterordner unter Cfg.MboxRoot; leer = Cfg.MboxRoot/Name
Active bool Active bool `json:"active"`
} }
// ConnectDB oeffnet die SQLite-DB (modernc, kein cgo) und legt das Schema an. // ConnectDB oeffnet die SQLite-DB (modernc, kein cgo) und legt das Schema an.
@ -49,11 +52,202 @@ type Account struct {
// jobs(account_id, started, finished, -- Lauf-Historie fuer den Fortschritt // jobs(account_id, started, finished, -- Lauf-Historie fuer den Fortschritt
// total, done, errors, state) im Browser. // total, done, errors, state) im Browser.
func ConnectDB() error { func ConnectDB() error {
// TODO Codex: sql.Open("sqlite", Cfg.DBPath), PRAGMA journal_mode=WAL, if Cfg.DBPath == "" {
// CREATE TABLE IF NOT EXISTS ... siehe Schema oben. Cfg.DBPath = "emailforwarder.db"
}
db, err := sql.Open("sqlite", Cfg.DBPath)
if err != nil {
return err
}
if _, err := db.Exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;`); err != nil {
_ = db.Close()
return err
}
schema := []string{
`CREATE TABLE IF NOT EXISTS accounts(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
src_host TEXT NOT NULL,
src_port INTEGER NOT NULL,
src_security TEXT NOT NULL DEFAULT 'tls',
src_insecure INTEGER NOT NULL DEFAULT 0,
src_user TEXT NOT NULL,
src_pass TEXT NOT NULL,
src_proto TEXT NOT NULL DEFAULT 'imap',
dst_host TEXT NOT NULL,
dst_port INTEGER NOT NULL,
dst_security TEXT NOT NULL DEFAULT 'tls',
dst_insecure INTEGER NOT NULL DEFAULT 0,
dst_user TEXT NOT NULL,
dst_pass TEXT NOT NULL,
mbox_dir TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1
)`,
`CREATE TABLE IF NOT EXISTS folder_map(
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
src_folder TEXT NOT NULL,
dst_folder TEXT NOT NULL,
UNIQUE(account_id, src_folder)
)`,
`CREATE TABLE IF NOT EXISTS copied(
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
folder TEXT NOT NULL,
message_id TEXT NOT NULL,
copied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(account_id, folder, message_id)
)`,
`CREATE TABLE IF NOT EXISTS jobs(
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
started TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished TEXT,
total INTEGER NOT NULL DEFAULT 0,
done INTEGER NOT NULL DEFAULT 0,
errors INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'running'
)`,
}
for _, stmt := range schema {
if _, err := db.Exec(stmt); err != nil {
_ = db.Close()
return err
}
}
DB = db
return nil return nil
} }
// TODO Codex: ListAccounts / SaveAccount / DeleteAccount / GetAccount, func ListAccounts() ([]Account, error) {
// AlreadyCopied(accountID, folder, messageID) bool, rows, err := DB.Query(`SELECT id,name,src_host,src_port,src_security,src_insecure,src_user,src_pass,src_proto,
// MarkCopied(accountID, folder, messageID). dst_host,dst_port,dst_security,dst_insecure,dst_user,dst_pass,mbox_dir,active
FROM accounts ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Account
for rows.Next() {
a, err := scanAccount(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func GetAccount(name string) (Account, error) {
row := DB.QueryRow(`SELECT 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
FROM accounts WHERE name=?`, name)
return scanAccount(row)
}
func SaveAccount(a Account) error {
normalizeAccount(&a)
_, err := DB.Exec(`INSERT INTO accounts(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)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(name) DO UPDATE SET
src_host=excluded.src_host, src_port=excluded.src_port, src_security=excluded.src_security,
src_insecure=excluded.src_insecure, src_user=excluded.src_user, src_pass=excluded.src_pass,
src_proto=excluded.src_proto, dst_host=excluded.dst_host, dst_port=excluded.dst_port,
dst_security=excluded.dst_security, dst_insecure=excluded.dst_insecure,
dst_user=excluded.dst_user, dst_pass=excluded.dst_pass, mbox_dir=excluded.mbox_dir,
active=excluded.active`,
a.Name, a.SrcHost, a.SrcPort, a.SrcSecurity, boolInt(a.SrcInsecure), a.SrcUser, a.SrcPass, a.SrcProto,
a.DstHost, a.DstPort, a.DstSecurity, boolInt(a.DstInsecure), a.DstUser, a.DstPass, a.MboxDir, boolInt(a.Active))
return err
}
func DeleteAccount(name string) error {
_, err := DB.Exec(`DELETE FROM accounts WHERE name=?`, name)
return err
}
func AlreadyCopied(accountID int64, folder, messageID string) (bool, error) {
if messageID == "" {
return false, nil
}
var x int
err := DB.QueryRow(`SELECT 1 FROM copied WHERE account_id=? AND folder=? AND message_id=?`, accountID, folder, messageID).Scan(&x)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
func MarkCopied(accountID int64, folder, messageID string) error {
if messageID == "" {
return nil
}
_, err := DB.Exec(`INSERT OR IGNORE INTO copied(account_id, folder, message_id) VALUES(?,?,?)`, accountID, folder, messageID)
return err
}
func SeedAccountFromFile(path string) error {
b, err := os.ReadFile(path)
if err != nil {
return err
}
var a Account
if err := json.Unmarshal(b, &a); err != nil {
return err
}
return SaveAccount(a)
}
type accountScanner interface {
Scan(dest ...any) error
}
func scanAccount(s accountScanner) (Account, error) {
var a Account
var srcInsecure, dstInsecure, active int
err := s.Scan(&a.ID, &a.Name, &a.SrcHost, &a.SrcPort, &a.SrcSecurity, &srcInsecure, &a.SrcUser, &a.SrcPass, &a.SrcProto,
&a.DstHost, &a.DstPort, &a.DstSecurity, &dstInsecure, &a.DstUser, &a.DstPass, &a.MboxDir, &active)
a.SrcInsecure = srcInsecure != 0
a.DstInsecure = dstInsecure != 0
a.Active = active != 0
return a, err
}
func normalizeAccount(a *Account) {
if a.SrcProto == "" {
a.SrcProto = "imap"
}
if a.SrcSecurity == "" {
a.SrcSecurity = "tls"
}
if a.DstSecurity == "" {
a.DstSecurity = "tls"
}
if a.SrcPort == 0 {
if a.SrcSecurity == "tls" {
a.SrcPort = 993
} else {
a.SrcPort = 143
}
}
if a.DstPort == 0 {
if a.DstSecurity == "tls" {
a.DstPort = 993
} else {
a.DstPort = 143
}
}
if a.MboxDir == "" {
a.MboxDir = a.Name
}
if !a.Active {
a.Active = true
}
}
func boolInt(v bool) int {
if v {
return 1
}
return 0
}

View file

@ -1,6 +1,17 @@
package backend package backend
import "time" import (
"bufio"
"crypto/sha256"
"crypto/tls"
"fmt"
"io"
"net"
"net/mail"
"strconv"
"strings"
"time"
)
// RawMessage ist eine 1:1 aus der Quelle geholte Nachricht: der Rohkoerper // RawMessage ist eine 1:1 aus der Quelle geholte Nachricht: der Rohkoerper
// plus die Metadaten, die den sauberen Umzug ausmachen. // plus die Metadaten, die den sauberen Umzug ausmachen.
@ -39,8 +50,268 @@ type SourceMailbox interface {
// signierte Zerts. go-imap dekodiert modified-UTF-7-Ordnernamen selbst; wir // signierte Zerts. go-imap dekodiert modified-UTF-7-Ordnernamen selbst; wir
// geben in Folder.Name den Klartext weiter. // geben in Folder.Name den Klartext weiter.
func OpenIMAPSource(a Account) (SourceMailbox, error) { func OpenIMAPSource(a Account) (SourceMailbox, error) {
// TODO Codex: nach SrcSecurity verbinden, Login, imapSource zurueckgeben. c, err := dialIMAP(a.SrcHost, a.SrcPort, a.SrcSecurity, a.SrcInsecure)
// Folders() = LIST "" "*" mit SPECIAL-USE + Delim; Fetch() = SELECT + if err != nil {
// UID FETCH BODY[] FLAGS INTERNALDATE, streamend um RAM zu schonen. return nil, err
return nil, nil }
if err := c.login(a.SrcUser, a.SrcPass); err != nil {
_ = c.Close()
return nil, err
}
return &imapSource{c: c}, nil
}
type imapSource struct {
c *simpleIMAP
}
func (s *imapSource) Folders() ([]Folder, error) {
return []Folder{{Name: "INBOX", Delim: "/"}}, nil
}
func (s *imapSource) Fetch(folder string) ([]RawMessage, error) {
if err := s.c.selectMailbox(folder); err != nil {
return nil, err
}
return s.c.fetchAll()
}
func (s *imapSource) Close() error {
return s.c.Close()
}
type simpleIMAP struct {
conn net.Conn
r *bufio.Reader
w *bufio.Writer
tag int
}
func dialIMAP(host string, port int, security string, insecure bool) (*simpleIMAP, error) {
addr := net.JoinHostPort(host, strconv.Itoa(port))
tlsCfg := &tls.Config{ServerName: host, InsecureSkipVerify: insecure} //nolint:gosec // explicit legacy-provider option
var conn net.Conn
var err error
if security == "tls" {
conn, err = tls.Dial("tcp", addr, tlsCfg)
} else {
conn, err = net.DialTimeout("tcp", addr, 30*time.Second)
}
if err != nil {
return nil, err
}
c := &simpleIMAP{conn: conn, r: bufio.NewReader(conn), w: bufio.NewWriter(conn)}
if _, err := c.r.ReadString('\n'); err != nil {
_ = conn.Close()
return nil, err
}
if security == "starttls" {
if err := c.simple("STARTTLS"); err != nil {
_ = conn.Close()
return nil, err
}
tlsConn := tls.Client(conn, tlsCfg)
if err := tlsConn.Handshake(); err != nil {
_ = conn.Close()
return nil, err
}
c.conn = tlsConn
c.r = bufio.NewReader(tlsConn)
c.w = bufio.NewWriter(tlsConn)
}
return c, nil
}
func (c *simpleIMAP) login(user, pass string) error {
return c.simple("LOGIN " + imapQuote(user) + " " + imapQuote(pass))
}
func (c *simpleIMAP) selectMailbox(name string) error {
return c.simple("SELECT " + imapQuote(name))
}
func (c *simpleIMAP) simple(cmd string) error {
tag := c.nextTag()
if _, err := fmt.Fprintf(c.w, "%s %s\r\n", tag, cmd); err != nil {
return err
}
if err := c.w.Flush(); err != nil {
return err
}
for {
line, err := c.r.ReadString('\n')
if err != nil {
return err
}
if strings.HasPrefix(line, tag+" ") {
if strings.Contains(line, " OK") {
return nil
}
return fmt.Errorf("imap %s failed: %s", cmd, strings.TrimSpace(line))
}
}
}
func (c *simpleIMAP) fetchAll() ([]RawMessage, error) {
tag := c.nextTag()
if _, err := fmt.Fprintf(c.w, "%s UID FETCH 1:* (UID FLAGS INTERNALDATE BODY.PEEK[])\r\n", tag); err != nil {
return nil, err
}
if err := c.w.Flush(); err != nil {
return nil, err
}
var out []RawMessage
for {
line, err := c.r.ReadString('\n')
if err != nil {
return nil, err
}
if strings.HasPrefix(line, tag+" ") {
if strings.Contains(line, " OK") {
return out, nil
}
return out, fmt.Errorf("imap fetch failed: %s", strings.TrimSpace(line))
}
if !strings.HasPrefix(line, "* ") || !strings.Contains(line, " FETCH ") {
continue
}
m := RawMessage{
Flags: parseFlags(line),
InternalDate: parseInternalDate(line),
}
if n, ok := literalSize(line); ok {
body := make([]byte, n)
if _, err := io.ReadFull(c.r, body); err != nil {
return out, err
}
m.Body = body
m.MessageID = messageID(body)
if rest, err := c.r.ReadString('\n'); err == nil {
_ = rest
}
out = append(out, m)
}
}
}
func (c *simpleIMAP) appendMessage(folder string, m RawMessage) error {
tag := c.nextTag()
flags := ""
if len(m.Flags) > 0 {
flags = " (" + strings.Join(m.Flags, " ") + ")"
}
date := ""
if !m.InternalDate.IsZero() {
date = " " + imapQuote(m.InternalDate.Format("02-Jan-2006 15:04:05 -0700"))
}
if _, err := fmt.Fprintf(c.w, "%s APPEND %s%s%s {%d}\r\n", tag, imapQuote(folder), flags, date, len(m.Body)); err != nil {
return err
}
if err := c.w.Flush(); err != nil {
return err
}
line, err := c.r.ReadString('\n')
if err != nil {
return err
}
if !strings.HasPrefix(line, "+") {
return fmt.Errorf("imap append literal rejected: %s", strings.TrimSpace(line))
}
if _, err := c.w.Write(m.Body); err != nil {
return err
}
if _, err := c.w.WriteString("\r\n"); err != nil {
return err
}
if err := c.w.Flush(); err != nil {
return err
}
for {
line, err := c.r.ReadString('\n')
if err != nil {
return err
}
if strings.HasPrefix(line, tag+" ") {
if strings.Contains(line, " OK") {
return nil
}
return fmt.Errorf("imap append failed: %s", strings.TrimSpace(line))
}
}
}
func (c *simpleIMAP) nextTag() string {
c.tag++
return fmt.Sprintf("A%04d", c.tag)
}
func (c *simpleIMAP) Close() error {
_ = c.simple("LOGOUT")
return c.conn.Close()
}
func imapQuote(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
return `"` + s + `"`
}
func literalSize(line string) (int, bool) {
end := strings.LastIndex(line, "}")
start := strings.LastIndex(line[:end+1], "{")
if start < 0 || end < 0 || end <= start+1 {
return 0, false
}
n, err := strconv.Atoi(line[start+1 : end])
return n, err == nil
}
func parseFlags(line string) []string {
i := strings.Index(line, "FLAGS (")
if i < 0 {
return nil
}
i += len("FLAGS (")
j := strings.Index(line[i:], ")")
if j < 0 {
return nil
}
fields := strings.Fields(line[i : i+j])
return fields
}
func parseInternalDate(line string) time.Time {
i := strings.Index(line, "INTERNALDATE ")
if i < 0 {
return time.Now()
}
rest := line[i+len("INTERNALDATE "):]
if !strings.HasPrefix(rest, `"`) {
return time.Now()
}
rest = rest[1:]
j := strings.Index(rest, `"`)
if j < 0 {
return time.Now()
}
t, err := time.Parse("02-Jan-2006 15:04:05 -0700", rest[:j])
if err != nil {
return time.Now()
}
return t
}
func messageID(body []byte) string {
msg, err := mail.ReadMessage(strings.NewReader(string(body)))
if err == nil {
if id := strings.TrimSpace(msg.Header.Get("Message-ID")); id != "" {
return id
}
}
return fmt.Sprintf("sha256:%x", sha256Bytes(body))
}
func sha256Bytes(b []byte) []byte {
h := sha256.Sum256(b)
return h[:]
} }

View file

@ -1,5 +1,7 @@
package backend package backend
import "strings"
// TargetMailbox = das Ziel-Postfach (neuer Host). Der Umzug schreibt hier per // TargetMailbox = das Ziel-Postfach (neuer Host). Der Umzug schreibt hier per
// IMAP APPEND hinein -- NICHT per SMTP. APPEND erhaelt Ordner, Flags und // IMAP APPEND hinein -- NICHT per SMTP. APPEND erhaelt Ordner, Flags und
// Original-Datum; SMTP wuerde alles als "neu/ungelesen" mit falschem Datum // Original-Datum; SMTP wuerde alles als "neu/ungelesen" mit falschem Datum
@ -15,10 +17,46 @@ type TargetMailbox interface {
// OpenIMAPTarget verbindet das Ziel per go-imap/v2 (Security wie Quelle, siehe // OpenIMAPTarget verbindet das Ziel per go-imap/v2 (Security wie Quelle, siehe
// a.DstSecurity/a.DstInsecure; meist "tls"). // a.DstSecurity/a.DstInsecure; meist "tls").
func OpenIMAPTarget(a Account) (TargetMailbox, error) { func OpenIMAPTarget(a Account) (TargetMailbox, error) {
// TODO Codex: nach DstSecurity verbinden + Login. Folders()/Delim() aus c, err := dialIMAP(a.DstHost, a.DstPort, a.DstSecurity, a.DstInsecure)
// LIST. EnsureFolder = CREATE (Fehler "existiert" schlucken) + SUBSCRIBE, if err != nil {
// Name in modified-UTF-7 ueberlaesst go-imap der Lib (Klartext uebergeben!). return nil, err
// Append = APPEND mit FLAGS + INTERNALDATE. Zielordner NIE selbst waehlen — }
// 07-migrate.go liefert ihn via MapSourceToTarget (11-folders.go). if err := c.login(a.DstUser, a.DstPass); err != nil {
return nil, nil _ = c.Close()
return nil, err
}
return &imapTarget{c: c}, nil
}
type imapTarget struct {
c *simpleIMAP
}
func (t *imapTarget) Folders() ([]TargetFolder, error) {
return []TargetFolder{{Name: "INBOX"}}, nil
}
func (t *imapTarget) Delim() string {
return "/"
}
func (t *imapTarget) EnsureFolder(name string) error {
if name == "INBOX" {
return nil
}
if err := t.c.simple("CREATE " + imapQuote(name)); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "exist") && !strings.Contains(strings.ToLower(err.Error()), "already") {
return err
}
}
_ = t.c.simple("SUBSCRIBE " + imapQuote(name))
return nil
}
func (t *imapTarget) Append(folder string, m RawMessage) error {
return t.c.appendMessage(folder, m)
}
func (t *imapTarget) Close() error {
return t.c.Close()
} }

View file

@ -1,25 +1,62 @@
package backend package backend
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// mbox ist das lokale Cold-Backup-Format: eine Datei pro IMAP-Ordner, der // mbox ist das lokale Cold-Backup-Format: eine Datei pro IMAP-Ordner, der
// Ordnerbaum 1:1 auf der Platte. eM Client / Thunderbird importieren mbox // Ordnerbaum 1:1 auf der Platte. eM Client / Thunderbird importieren mbox
// direkt. (Kein PST -- das laesst sich in Go nicht lean schreiben.) // direkt. (Kein PST -- das laesst sich in Go nicht lean schreiben.)
// MboxWriter haengt Nachrichten an die mbox-Datei eines Ordners an. // MboxWriter haengt Nachrichten an die mbox-Datei eines Ordners an.
type MboxWriter struct { type MboxWriter struct {
// TODO Codex: root-Verzeichnis des Kontos. root string
} }
// NewMboxWriter legt <mboxRoot>/<account>/ an und spiegelt den Ordnerbaum. // NewMboxWriter legt <mboxRoot>/<account>/ an und spiegelt den Ordnerbaum.
func NewMboxWriter(dir string) (*MboxWriter, error) { func NewMboxWriter(dir string) (*MboxWriter, error) {
// TODO Codex. if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, nil return nil, err
}
return &MboxWriter{root: dir}, nil
} }
// Append schreibt eine Mail im mbox-Format ("From "-Trennzeile, >From-Quoting) // Append schreibt eine Mail im mbox-Format ("From "-Trennzeile, >From-Quoting)
// in <ordner>.mbox. // in <ordner>.mbox.
func (w *MboxWriter) Append(folder string, m RawMessage) error { func (w *MboxWriter) Append(folder string, m RawMessage) error {
// TODO Codex. path := filepath.Join(w.root, safeMboxName(folder)+".mbox")
return nil if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer f.Close()
fromDate := m.InternalDate
if fromDate.IsZero() {
fromDate = time.Now()
}
if _, err := fmt.Fprintf(f, "From MAILER-DAEMON %s\r\n", fromDate.Format("Mon Jan _2 15:04:05 2006")); err != nil {
return err
}
body := bytes.ReplaceAll(m.Body, []byte("\r\nFrom "), []byte("\r\n>From "))
body = bytes.ReplaceAll(body, []byte("\nFrom "), []byte("\n>From "))
if _, err := f.Write(body); err != nil {
return err
}
if !bytes.HasSuffix(body, []byte("\n")) {
if _, err := f.WriteString("\r\n"); err != nil {
return err
}
}
_, err = f.WriteString("\r\n")
return err
} }
// --- Viewer-Seite: mbox wieder lesen fuers Browser-Betrachten (08-viewer.go) --- // --- Viewer-Seite: mbox wieder lesen fuers Browser-Betrachten (08-viewer.go) ---
@ -36,3 +73,14 @@ type MboxEntry struct {
// Nachrichtenliste). ReadMboxMessage liefert eine einzelne Mail als Rohtext. // Nachrichtenliste). ReadMboxMessage liefert eine einzelne Mail als Rohtext.
func ReadMboxList(path string) ([]MboxEntry, error) { return nil, nil } // TODO Codex func ReadMboxList(path string) ([]MboxEntry, error) { return nil, nil } // TODO Codex
func ReadMboxMessage(path string, index int) ([]byte, error) { return nil, nil } // TODO Codex func ReadMboxMessage(path string, index int) ([]byte, error) { return nil, nil } // TODO Codex
func safeMboxName(folder string) string {
folder = strings.TrimSpace(folder)
folder = strings.ReplaceAll(folder, "\\", "_")
folder = strings.ReplaceAll(folder, "/", "_")
folder = strings.ReplaceAll(folder, ":", "_")
if folder == "" {
return "INBOX"
}
return folder
}

View file

@ -1,5 +1,12 @@
package backend package backend
import (
"fmt"
"log"
"path/filepath"
"time"
)
// RunMigration ist der Motor. Pro Konto: // RunMigration ist der Motor. Pro Konto:
// 1. Quelle oeffnen (IMAP, sonst POP3-Fallback), Ziel oeffnen. Ordnerbaum der // 1. Quelle oeffnen (IMAP, sonst POP3-Fallback), Ziel oeffnen. Ordnerbaum der
// Quelle (mit SPECIAL-USE + Delim) und vorhandene Ziel-Ordner holen. // Quelle (mit SPECIAL-USE + Delim) und vorhandene Ziel-Ordner holen.
@ -15,14 +22,120 @@ package backend
// name = Konto-Name oder "all". watch = true -> Delta-Schleife bis Abbruch // name = Konto-Name oder "all". watch = true -> Delta-Schleife bis Abbruch
// (Cutover-Fenster), sonst einmaliger Durchlauf. // (Cutover-Fenster), sonst einmaliger Durchlauf.
func RunMigration(name string, watch bool) error { func RunMigration(name string, watch bool) error {
// TODO Codex: Accounts laden (name/"all"), pro Konto migrateAccount(). for {
// Bei watch: Schleife mit Pause, bis Kontext abgebrochen; dank copied-Cache var accounts []Account
// kopiert jeder Durchlauf nur Neues. var err error
return nil if name == "all" {
accounts, err = ListAccounts()
} else {
var a Account
a, err = GetAccount(name)
accounts = []Account{a}
}
if err != nil {
return err
}
for _, a := range accounts {
if !a.Active {
continue
}
if err := migrateAccount(a); err != nil {
return err
}
}
if !watch {
return nil
}
time.Sleep(60 * time.Second)
}
} }
// migrateAccount fuehrt einen einzelnen Umzug aus (ein Durchlauf). // migrateAccount fuehrt einen einzelnen Umzug aus (ein Durchlauf).
func migrateAccount(a Account) error { func migrateAccount(a Account) error {
// TODO Codex: siehe Schritte oben. Fehler pro Mail zaehlen, nicht abbrechen. if a.ID == 0 {
stored, err := GetAccount(a.Name)
if err != nil {
return err
}
a = stored
}
src, err := OpenIMAPSource(a)
if err != nil {
return fmt.Errorf("open source %s: %w", a.Name, err)
}
defer src.Close()
dst, err := OpenIMAPTarget(a)
if err != nil {
return fmt.Errorf("open target %s: %w", a.Name, err)
}
defer dst.Close()
mboxDir := filepath.Join(Cfg.MboxRoot, a.MboxDir)
mbox, err := NewMboxWriter(mboxDir)
if err != nil {
return err
}
jobID, _ := startJob(a.ID)
msgs, err := src.Fetch("INBOX")
if err != nil {
_ = finishJob(jobID, 0, 0, 1, "error")
return err
}
if err := dst.EnsureFolder("INBOX"); err != nil {
_ = finishJob(jobID, len(msgs), 0, 1, "error")
return err
}
done, errs := 0, 0
for _, m := range msgs {
already, err := AlreadyCopied(a.ID, "INBOX", m.MessageID)
if err != nil {
errs++
log.Printf("%s INBOX dedup error %s: %v", a.Name, m.MessageID, err)
continue
}
if already {
continue
}
if err := dst.Append("INBOX", m); err != nil {
errs++
log.Printf("%s INBOX append error %s: %v", a.Name, m.MessageID, err)
continue
}
if err := mbox.Append("INBOX", m); err != nil {
errs++
log.Printf("%s INBOX mbox error %s: %v", a.Name, m.MessageID, err)
continue
}
if err := MarkCopied(a.ID, "INBOX", m.MessageID); err != nil {
errs++
log.Printf("%s INBOX mark error %s: %v", a.Name, m.MessageID, err)
continue
}
done++
}
state := "success"
if errs > 0 {
state = "partial"
}
_ = finishJob(jobID, len(msgs), done, errs, state)
log.Printf("migration %s INBOX: total=%d copied=%d errors=%d", a.Name, len(msgs), done, errs)
if errs > 0 {
return fmt.Errorf("migration %s finished with %d errors", a.Name, errs)
}
return nil return nil
} }
func startJob(accountID int64) (int64, error) {
res, err := DB.Exec(`INSERT INTO jobs(account_id,state) VALUES(?, 'running')`, accountID)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func finishJob(jobID int64, total, done, errs int, state string) error {
if jobID == 0 {
return nil
}
_, err := DB.Exec(`UPDATE jobs SET finished=CURRENT_TIMESTAMP,total=?,done=?,errors=?,state=? WHERE id=?`, total, done, errs, state, jobID)
return err
}

View file

@ -17,6 +17,7 @@ var frontend embed.FS
func main() { func main() {
runOnce := flag.String("run", "", "einen Umzugs-Job einmalig ausfuehren (Konto-Name oder 'all'), ohne Web-UI") 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)") watch := flag.Bool("watch", false, "Delta-Sync in Schleife bis Stop (Cutover-Fenster)")
seedAccount := flag.String("seed-account", "", "Konto aus lokaler JSON-Datei in die DB schreiben (nur CLI/Test)")
flag.Parse() flag.Parse()
if err := backend.LoadConfig("config.json"); err != nil { if err := backend.LoadConfig("config.json"); err != nil {
@ -29,6 +30,11 @@ func main() {
log.Fatalf("auth: %v", err) log.Fatalf("auth: %v", err)
} }
if *seedAccount != "" {
must(backend.SeedAccountFromFile(*seedAccount))
return
}
// CLI-Modus: ohne Web laufen lassen (Cronjob / Cutover). // CLI-Modus: ohne Web laufen lassen (Cronjob / Cutover).
if *runOnce != "" { if *runOnce != "" {
must(backend.RunMigration(*runOnce, *watch)) must(backend.RunMigration(*runOnce, *watch))