package backend import ( "database/sql" "encoding/json" "errors" "os" "strings" _ "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 `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 `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 `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"` } type AppUser struct { ID int64 Username string Role string Active bool PasswordHash string } // 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 { if Cfg.DBPath == "" { Cfg.DBPath = "mail-graveyard.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' )`, `CREATE TABLE IF NOT EXISTS app_users( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )`, `CREATE TABLE IF NOT EXISTS app_sessions( token TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES app_users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )`, } for _, stmt := range schema { if _, err := db.Exec(stmt); err != nil { _ = db.Close() return err } } DB = db return nil } func CountAppUsers() (int, error) { var count int err := DB.QueryRow(`SELECT count(*) FROM app_users`).Scan(&count) return count, err } func ListAppUsers() ([]AppUser, error) { rows, err := DB.Query(`SELECT id, username, password_hash, role, active FROM app_users ORDER BY username`) if err != nil { return nil, err } defer rows.Close() var out []AppUser for rows.Next() { u, err := scanAppUser(rows) if err != nil { return nil, err } out = append(out, u) } return out, rows.Err() } func GetAppUser(username string) (AppUser, error) { row := DB.QueryRow(`SELECT id, username, password_hash, role, active FROM app_users WHERE username=?`, username) return scanAppUser(row) } func GetAppUserByID(id int64) (AppUser, error) { row := DB.QueryRow(`SELECT id, username, password_hash, role, active FROM app_users WHERE id=?`, id) return scanAppUser(row) } func SaveAppUser(username, passwordHash, role string, active bool) error { role = normalizeRole(role) if passwordHash == "" { _, err := DB.Exec(`UPDATE app_users SET role=?, active=?, updated_at=CURRENT_TIMESTAMP WHERE username=?`, role, boolInt(active), username) return err } _, err := DB.Exec(`INSERT INTO app_users(username, password_hash, role, active) VALUES(?,?,?,?) ON CONFLICT(username) DO UPDATE SET password_hash=excluded.password_hash, role=excluded.role, active=excluded.active, updated_at=CURRENT_TIMESTAMP`, username, passwordHash, role, boolInt(active)) return err } func DeleteAppUser(username string) error { _, err := DB.Exec(`DELETE FROM app_users WHERE username=?`, username) return err } func CreateSession(token string, userID int64, expiresAt string) error { _, err := DB.Exec(`INSERT INTO app_sessions(token, user_id, expires_at) VALUES(?,?,?)`, token, userID, expiresAt) return err } func DeleteSession(token string) error { _, err := DB.Exec(`DELETE FROM app_sessions WHERE token=?`, token) return err } func SessionUser(token string) (AppUser, error) { row := DB.QueryRow(`SELECT u.id, u.username, u.password_hash, u.role, u.active FROM app_sessions s JOIN app_users u ON u.id=s.user_id WHERE s.token=? AND s.expires_at > CURRENT_TIMESTAMP AND u.active=1`, token) return scanAppUser(row) } func CleanupSessions() error { _, err := DB.Exec(`DELETE FROM app_sessions WHERE expires_at <= CURRENT_TIMESTAMP`) return err } 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 } type appUserScanner 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 } } func scanAppUser(s appUserScanner) (AppUser, error) { var u AppUser var active int err := s.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &active) u.Role = normalizeRole(u.Role) u.Active = active != 0 return u, err } func normalizeRole(role string) string { switch strings.ToLower(strings.TrimSpace(role)) { case "admin": return "admin" case "verwalter": return "verwalter" default: return "user" } } func boolInt(v bool) int { if v { return 1 } return 0 }