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 (
"database/sql"
"encoding/json"
"errors"
"os"
_ "modernc.org/sqlite"
)
@ -11,27 +14,27 @@ 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"
ID int64 `json:"id"`
Name string `json:"name"` // 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)
SrcHost string `json:"src_host"`
SrcPort int `json:"src_port"`
SrcSecurity string `json:"src_security"`
SrcInsecure bool `json:"src_insecure"`
SrcUser string `json:"src_user"` // hans-peter@dr-gold.de
SrcPass string `json:"src_pass"`
SrcProto string `json:"src_proto"` // "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
DstHost string `json:"dst_host"`
DstPort int `json:"dst_port"`
DstSecurity string `json:"dst_security"`
DstInsecure bool `json:"dst_insecure"`
DstUser string `json:"dst_user"` // archiv-hans-peter@dr-gold.com
DstPass string `json:"dst_pass"`
MboxDir string `json:"mbox_dir"` // Unterordner unter Cfg.MboxRoot; leer = Cfg.MboxRoot/Name
Active bool `json:"active"`
}
// 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
// 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.
if Cfg.DBPath == "" {
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
}
// TODO Codex: ListAccounts / SaveAccount / DeleteAccount / GetAccount,
// AlreadyCopied(accountID, folder, messageID) bool,
// MarkCopied(accountID, folder, messageID).
func ListAccounts() ([]Account, error) {
rows, err := DB.Query(`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 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
}