package backend import ( "database/sql" "encoding/json" "errors" "fmt" "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"` // optionaler Name aus archive_mailboxes unter Cfg.MboxRoot Active bool `json:"active"` } type AppUser struct { ID int64 Username string DisplayName 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. // archive_mailboxes(name) -- lokale/importierte Archiv-mboxen, // unabhaengig von Quell/Ziel-Konten. // 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(initDB bool) error { if Cfg.DBPath == "" { Cfg.DBPath = "mail-graveyard.db" } if err := ensureDBFile(Cfg.DBPath, initDB); err != nil { return err } db, err := sql.Open("sqlite", Cfg.DBPath) if err != nil { return err } // SQLite pragmas are connection-local. Keep a single process-local // connection so every query uses the busy timeout configured here. The web // app and watcher are separate processes; WAL + busy_timeout coordinates // their writes without failing immediately with SQLITE_BUSY. db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) if _, err := db.Exec(`PRAGMA busy_timeout=10000; 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 archive_mailboxes( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )`, `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, body_sha256 TEXT NOT NULL DEFAULT '', mbox_done INTEGER NOT NULL DEFAULT 0, target_done INTEGER NOT NULL DEFAULT 0, copied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(account_id, folder, message_id, body_sha256) )`, `CREATE TABLE IF NOT EXISTS mbox_index( account_id INTEGER NOT NULL, folder TEXT NOT NULL, seq INTEGER NOT NULL, message_id TEXT NOT NULL, body_sha256 TEXT NOT NULL DEFAULT '', subject TEXT NOT NULL DEFAULT '', from_addr TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '', file_offset INTEGER NOT NULL, frame_len INTEGER NOT NULL, inner_offset INTEGER NOT NULL DEFAULT 0, inner_len INTEGER NOT NULL, UNIQUE(account_id, folder, seq) )`, `CREATE TABLE IF NOT EXISTS mbox_index_state( account_id INTEGER NOT NULL, folder TEXT NOT NULL, indexed_bytes INTEGER NOT NULL DEFAULT 0, UNIQUE(account_id, folder) )`, `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, display_name TEXT NOT NULL DEFAULT '', 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 )`, `CREATE TABLE IF NOT EXISTS app_password_resets( token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES app_users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, used_at TEXT, 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 if err := ensureAppUserColumns(); err != nil { _ = db.Close() return err } if err := ensureCopiedStageColumns(); err != nil { _ = db.Close() return err } if err := ensureMessageIdentitySchema(); err != nil { _ = db.Close() return err } if err := seedArchiveMailboxesFromFS(); err != nil { _ = db.Close() return err } return nil } // ensureMessageIdentitySchema upgrades the old Message-ID-only bookkeeping in // one transaction. No logical rows are discarded: old keys remain as rows with // an empty body hash and continue to act as compatibility aliases. The mbox // index is deliberately invalidated so its hashes are rebuilt from the archive // before the next migration pass makes a copy decision. func ensureMessageIdentitySchema() error { copiedColumns, err := tableColumns("copied") if err != nil { return err } indexColumns, err := tableColumns("mbox_index") if err != nil { return err } upgradeCopied := !copiedColumns["body_sha256"] upgradeIndex := !indexColumns["body_sha256"] if !upgradeCopied && !upgradeIndex { _, err := DB.Exec(`CREATE INDEX IF NOT EXISTS idx_copied_identity ON copied(account_id, folder, message_id, body_sha256); CREATE INDEX IF NOT EXISTS idx_mbox_index_identity ON mbox_index(account_id, folder, message_id, body_sha256);`) return err } tx, err := DB.Begin() if err != nil { return err } defer tx.Rollback() if upgradeCopied { statements := []string{ `ALTER TABLE copied RENAME TO copied_pre_identity`, `CREATE TABLE copied( account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, folder TEXT NOT NULL, message_id TEXT NOT NULL, body_sha256 TEXT NOT NULL DEFAULT '', mbox_done INTEGER NOT NULL DEFAULT 0, target_done INTEGER NOT NULL DEFAULT 0, copied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(account_id, folder, message_id, body_sha256) )`, `INSERT INTO copied(account_id, folder, message_id, body_sha256, mbox_done, target_done, copied_at) SELECT account_id, folder, message_id, '', mbox_done, target_done, copied_at FROM copied_pre_identity`, `DROP TABLE copied_pre_identity`, } for _, stmt := range statements { if _, err := tx.Exec(stmt); err != nil { return err } } } if upgradeIndex { statements := []string{ `ALTER TABLE mbox_index RENAME TO mbox_index_pre_identity`, `CREATE TABLE mbox_index( account_id INTEGER NOT NULL, folder TEXT NOT NULL, seq INTEGER NOT NULL, message_id TEXT NOT NULL, body_sha256 TEXT NOT NULL DEFAULT '', subject TEXT NOT NULL DEFAULT '', from_addr TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '', file_offset INTEGER NOT NULL, frame_len INTEGER NOT NULL, inner_offset INTEGER NOT NULL DEFAULT 0, inner_len INTEGER NOT NULL, UNIQUE(account_id, folder, seq) )`, `INSERT INTO mbox_index(account_id, folder, seq, message_id, body_sha256, subject, from_addr, date, file_offset, frame_len, inner_offset, inner_len) SELECT account_id, folder, seq, message_id, '', subject, from_addr, date, file_offset, frame_len, inner_offset, inner_len FROM mbox_index_pre_identity`, `DROP TABLE mbox_index_pre_identity`, `UPDATE mbox_index_state SET indexed_bytes=-1`, } for _, stmt := range statements { if _, err := tx.Exec(stmt); err != nil { return err } } } if _, err := tx.Exec(`CREATE INDEX IF NOT EXISTS idx_copied_identity ON copied(account_id, folder, message_id, body_sha256)`); err != nil { return err } if _, err := tx.Exec(`CREATE INDEX IF NOT EXISTS idx_mbox_index_identity ON mbox_index(account_id, folder, message_id, body_sha256)`); err != nil { return err } return tx.Commit() } func tableColumns(table string) (map[string]bool, error) { rows, err := DB.Query(`PRAGMA table_info(` + table + `)`) if err != nil { return nil, err } defer rows.Close() columns := map[string]bool{} for rows.Next() { var cid, notNull, pk int var name, typ string var defaultValue any if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil { return nil, err } columns[name] = true } return columns, rows.Err() } func ensureCopiedStageColumns() error { columns := map[string]bool{} rows, err := DB.Query(`PRAGMA table_info(copied)`) if err != nil { return err } for rows.Next() { var cid int var name, typ string var notNull int var defaultValue any var pk int if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil { _ = rows.Close() return err } columns[name] = true } if err := rows.Err(); err != nil { _ = rows.Close() return err } if err := rows.Close(); err != nil { return err } // Existing copied rows were created by the old all-or-nothing pipeline and // therefore represent both stages as complete. DEFAULT 1 preserves that // truth while all new writes below set their stage values explicitly. if !columns["mbox_done"] { if _, err := DB.Exec(`ALTER TABLE copied ADD COLUMN mbox_done INTEGER NOT NULL DEFAULT 1`); err != nil { return err } } if !columns["target_done"] { if _, err := DB.Exec(`ALTER TABLE copied ADD COLUMN target_done INTEGER NOT NULL DEFAULT 1`); err != nil { return err } } return nil } func ensureDBFile(path string, initDB bool) error { info, err := os.Stat(path) if err == nil { if info.IsDir() { return fmt.Errorf("DB %q ist ein Verzeichnis", path) } return nil } if !errors.Is(err, os.ErrNotExist) { return err } if !initDB { return fmt.Errorf("DB %q existiert nicht -- mit --init-db neu anlegen oder db_path pruefen", path) } f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600) if err != nil { return err } return f.Close() } func ensureAppUserColumns() error { hasDisplayName := false rows, err := DB.Query(`PRAGMA table_info(app_users)`) if err != nil { return err } for rows.Next() { var cid int var name, typ string var notNull int var defaultValue any var pk int if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil { _ = rows.Close() return err } if name == "display_name" { hasDisplayName = true } } if err := rows.Err(); err != nil { _ = rows.Close() return err } if err := rows.Close(); err != nil { return err } if !hasDisplayName { if _, err := DB.Exec(`ALTER TABLE app_users ADD COLUMN display_name TEXT NOT NULL DEFAULT ''`); err != nil { return err } } 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, display_name, 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, display_name, 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, display_name, password_hash, role, active FROM app_users WHERE id=?`, id) return scanAppUser(row) } func SaveAppUser(username, displayName, passwordHash, role string, active bool) error { displayName = strings.TrimSpace(displayName) role = normalizeRole(role) if passwordHash == "" { _, err := DB.Exec(`UPDATE app_users SET display_name=?, role=?, active=?, updated_at=CURRENT_TIMESTAMP WHERE username=?`, displayName, role, boolInt(active), username) return err } _, err := DB.Exec(`INSERT INTO app_users(username, display_name, password_hash, role, active) VALUES(?,?,?,?,?) ON CONFLICT(username) DO UPDATE SET display_name=excluded.display_name, password_hash=excluded.password_hash, role=excluded.role, active=excluded.active, updated_at=CURRENT_TIMESTAMP`, username, displayName, passwordHash, role, boolInt(active)) return err } func UpdateAppUser(oldUsername, username, displayName, passwordHash, role string, active bool) error { oldUsername = strings.TrimSpace(oldUsername) username = strings.TrimSpace(username) displayName = strings.TrimSpace(displayName) role = normalizeRole(role) if oldUsername == "" || oldUsername == username { return SaveAppUser(username, displayName, passwordHash, role, active) } tx, err := DB.Begin() if err != nil { return err } defer tx.Rollback() var existing int err = tx.QueryRow(`SELECT 1 FROM app_users WHERE username=?`, username).Scan(&existing) if err == nil { return fmt.Errorf("Login/E-Mail existiert bereits.") } if !errors.Is(err, sql.ErrNoRows) { return err } if passwordHash == "" { _, err = tx.Exec(`UPDATE app_users SET username=?, display_name=?, role=?, active=?, updated_at=CURRENT_TIMESTAMP WHERE username=?`, username, displayName, role, boolInt(active), oldUsername) } else { _, err = tx.Exec(`UPDATE app_users SET username=?, display_name=?, password_hash=?, role=?, active=?, updated_at=CURRENT_TIMESTAMP WHERE username=?`, username, displayName, passwordHash, role, boolInt(active), oldUsername) } if err != nil { return err } _, err = tx.Exec(`DELETE FROM app_sessions WHERE user_id IN (SELECT id FROM app_users WHERE username=?)`, username) if err != nil { return err } return tx.Commit() } 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.display_name, 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 CreatePasswordReset(tokenHash string, userID int64, expiresAt string) error { _, err := DB.Exec(`INSERT INTO app_password_resets(token_hash, user_id, expires_at) VALUES(?,?,?)`, tokenHash, userID, expiresAt) return err } func PasswordResetUser(tokenHash string) (AppUser, error) { row := DB.QueryRow(`SELECT u.id, u.username, u.display_name, u.password_hash, u.role, u.active FROM app_password_resets r JOIN app_users u ON u.id=r.user_id WHERE r.token_hash=? AND r.used_at IS NULL AND r.expires_at > CURRENT_TIMESTAMP AND u.active=1`, tokenHash) return scanAppUser(row) } func UsePasswordReset(tokenHash, passwordHash string) error { tx, err := DB.Begin() if err != nil { return err } defer tx.Rollback() var userID int64 err = tx.QueryRow(`SELECT user_id FROM app_password_resets WHERE token_hash=? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP`, tokenHash).Scan(&userID) if err != nil { return err } if _, err := tx.Exec(`UPDATE app_users SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`, passwordHash, userID); err != nil { return err } if _, err := tx.Exec(`UPDATE app_password_resets SET used_at=CURRENT_TIMESTAMP WHERE token_hash=?`, tokenHash); err != nil { return err } if _, err := tx.Exec(`DELETE FROM app_sessions WHERE user_id=?`, userID); err != nil { return err } return tx.Commit() } func CleanupPasswordResets() error { _, err := DB.Exec(`DELETE FROM app_password_resets WHERE used_at IS NOT NULL OR 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 FolderMap(accountID int64) (map[string]string, error) { rows, err := DB.Query(`SELECT src_folder, dst_folder FROM folder_map WHERE account_id=?`, accountID) if err != nil { return nil, err } defer rows.Close() out := map[string]string{} for rows.Next() { var src, dst string if err := rows.Scan(&src, &dst); err != nil { return nil, err } out[src] = dst } return out, rows.Err() } func ListArchiveMailboxes() ([]string, error) { rows, err := DB.Query(`SELECT name FROM archive_mailboxes ORDER BY lower(name), name`) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var name string if err := rows.Scan(&name); err != nil { return nil, err } out = append(out, name) } return out, rows.Err() } func SaveArchiveMailbox(name string) error { name = strings.TrimSpace(name) if name == "" { return fmt.Errorf("Name der Archiv-Mailbox fehlt.") } _, err := DB.Exec(`INSERT OR IGNORE INTO archive_mailboxes(name) VALUES(?)`, name) return err } func DeleteArchiveMailbox(name string) error { _, err := DB.Exec(`DELETE FROM archive_mailboxes WHERE name=?`, strings.TrimSpace(name)) return err } func seedArchiveMailboxesFromFS() error { if Cfg.MboxRoot == "" { return nil } entries, err := os.ReadDir(Cfg.MboxRoot) if os.IsNotExist(err) { return nil } if err != nil { return err } for _, entry := range entries { if entry.IsDir() { if err := SaveArchiveMailbox(entry.Name()); err != nil { return err } } } return nil } func AlreadyCopied(accountID int64, folder, messageID string) (bool, error) { state, err := GetCopyState(accountID, folder, messageID) return state.MboxDone && state.TargetDone, err } func MarkCopied(accountID int64, folder, messageID string) error { return MarkIdentityCopied(accountID, folder, MessageIdentity{MessageID: messageID}) } type CopyState struct { MboxDone bool TargetDone bool } func GetCopyState(accountID int64, folder, messageID string) (CopyState, error) { return GetCopyIdentityState(accountID, folder, MessageIdentity{MessageID: messageID}) } func MarkMboxCopied(accountID int64, folder, messageID string) error { return MarkIdentityMboxCopied(accountID, folder, MessageIdentity{MessageID: messageID}) } func MarkTargetCopied(accountID int64, folder, messageID string) error { return MarkIdentityTargetCopied(accountID, folder, MessageIdentity{MessageID: messageID}) } func GetCopyIdentityState(accountID int64, folder string, identity MessageIdentity) (CopyState, error) { return GetCopyIdentityStateWithFolderAlias(accountID, folder, folder, identity) } func GetCopyIdentityStateWithFolderAlias(accountID int64, folder, legacyFolder string, identity MessageIdentity) (CopyState, error) { if identity.MessageID == "" && identity.BodySHA256 == "" { return CopyState{}, nil } folders := []string{folder} if legacyFolder != "" && legacyFolder != folder { folders = append(folders, legacyFolder) } // Precise rows always win. For Message-ID-less mail the hash is the whole // identity, irrespective of which legacy key happened to be stored beside it. var mboxDone, targetDone int for _, candidateFolder := range folders { var mbox, target int var err error if identity.MessageID == "" { err = DB.QueryRow(`SELECT COALESCE(MAX(mbox_done),0), COALESCE(MAX(target_done),0) FROM copied WHERE account_id=? AND folder=? AND body_sha256=?`, accountID, candidateFolder, identity.BodySHA256).Scan(&mbox, &target) } else { err = DB.QueryRow(`SELECT COALESCE(MAX(mbox_done),0), COALESCE(MAX(target_done),0) FROM copied WHERE account_id=? AND folder=? AND message_id=? AND body_sha256=?`, accountID, candidateFolder, identity.MessageID, identity.BodySHA256).Scan(&mbox, &target) } if err != nil { return CopyState{}, err } mboxDone |= mbox targetDone |= target } if mboxDone != 0 || targetDone != 0 { return CopyState{MboxDone: mboxDone != 0, TargetDone: targetDone != 0}, nil } // Once a Message-ID has precise archive identities, a different body with // that same ID is new and must not be hidden by the old wildcard row. if identity.MessageID != "" && identity.BodySHA256 != "" { var precise int for _, candidateFolder := range folders { var count int if err := DB.QueryRow(`SELECT count(*) FROM copied WHERE account_id=? AND folder=? AND message_id=? AND body_sha256<>''`, accountID, candidateFolder, identity.MessageID).Scan(&count); err != nil { return CopyState{}, err } precise += count } if precise > 0 { return CopyState{}, nil } } aliases := []string{identity.MessageID, identity.LegacyMessageID} if identity.MessageID == "" && identity.BodySHA256 != "" { aliases = append(aliases, "sha256:"+identity.BodySHA256) } seen := map[string]bool{} for _, alias := range aliases { alias = strings.TrimSpace(alias) if alias == "" || seen[alias] { continue } seen[alias] = true for _, candidateFolder := range folders { var mbox, target int err := DB.QueryRow(`SELECT COALESCE(MAX(mbox_done),0), COALESCE(MAX(target_done),0) FROM copied WHERE account_id=? AND folder=? AND message_id=? AND body_sha256=''`, accountID, candidateFolder, alias).Scan(&mbox, &target) if err != nil { return CopyState{}, err } mboxDone |= mbox targetDone |= target } } return CopyState{MboxDone: mboxDone != 0, TargetDone: targetDone != 0}, nil } func MarkIdentityCopied(accountID int64, folder string, identity MessageIdentity) error { return markCopyIdentity(accountID, folder, identity, true, true) } func MarkIdentityMboxCopied(accountID int64, folder string, identity MessageIdentity) error { return markCopyIdentity(accountID, folder, identity, true, false) } func MarkIdentityTargetCopied(accountID int64, folder string, identity MessageIdentity) error { return markCopyIdentity(accountID, folder, identity, false, true) } func markCopyIdentity(accountID int64, folder string, identity MessageIdentity, mboxDone, targetDone bool) error { if identity.MessageID == "" && identity.BodySHA256 == "" { return nil } _, err := DB.Exec(`INSERT INTO copied(account_id, folder, message_id, body_sha256, mbox_done, target_done) VALUES(?,?,?,?,?,?) ON CONFLICT(account_id, folder, message_id, body_sha256) DO UPDATE SET mbox_done=MAX(copied.mbox_done, excluded.mbox_done), target_done=MAX(copied.target_done, excluded.target_done), copied_at=CURRENT_TIMESTAMP`, accountID, folder, identity.MessageID, identity.BodySHA256, boolInt(mboxDone), boolInt(targetDone)) return err } type MboxIndexEntry struct { AccountID int64 Folder string Seq int MessageID string BodySHA256 string Subject string From string Date string FileOffset int64 FrameLen int64 InnerOffset int64 InnerLen int64 } func SaveMboxIndex(e MboxIndexEntry) error { if DB == nil || (e.MessageID == "" && e.BodySHA256 == "") { return nil } var seq int if e.Seq > 0 { seq = e.Seq } else { _ = DB.QueryRow(`SELECT COALESCE(MAX(seq), -1) + 1 FROM mbox_index WHERE account_id=? AND folder=?`, e.AccountID, e.Folder).Scan(&seq) } _, err := DB.Exec(`INSERT OR REPLACE INTO mbox_index(account_id, folder, seq, message_id, body_sha256, subject, from_addr, date, file_offset, frame_len, inner_offset, inner_len) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, e.AccountID, e.Folder, seq, e.MessageID, e.BodySHA256, e.Subject, e.From, e.Date, e.FileOffset, e.FrameLen, e.InnerOffset, e.InnerLen) return err } func ReplaceMboxIndex(accountID int64, folder string, entries []MboxIndexEntry, indexedBytes int64) error { if DB == nil || accountID == 0 { return nil } tx, err := DB.Begin() if err != nil { return err } defer tx.Rollback() folderAliases := []string{folder} aliasRows, err := tx.Query(`SELECT folder FROM copied WHERE account_id=? UNION SELECT folder FROM mbox_index WHERE account_id=?`, accountID, accountID) if err != nil { return err } for aliasRows.Next() { var candidate string if err := aliasRows.Scan(&candidate); err != nil { _ = aliasRows.Close() return err } if candidate != folder && safeMboxName(candidate) == folder { folderAliases = append(folderAliases, candidate) } } if err := aliasRows.Err(); err != nil { _ = aliasRows.Close() return err } if err := aliasRows.Close(); err != nil { return err } legacyIDsBySeq := map[int]string{} for _, candidateFolder := range folderAliases { rows, err := tx.Query(`SELECT seq, message_id FROM mbox_index WHERE account_id=? AND folder=?`, accountID, candidateFolder) if err != nil { return err } for rows.Next() { var seq int var messageID string if err := rows.Scan(&seq, &messageID); err != nil { _ = rows.Close() return err } if _, exists := legacyIDsBySeq[seq]; !exists || candidateFolder == folder { legacyIDsBySeq[seq] = messageID } } if err := rows.Err(); err != nil { _ = rows.Close() return err } if err := rows.Close(); err != nil { return err } } if _, err := tx.Exec(`DELETE FROM mbox_index WHERE account_id=? AND folder=?`, accountID, folder); err != nil { return err } for i, entry := range entries { entry.AccountID = accountID entry.Folder = folder entry.Seq = i if entry.MessageID == "" && entry.BodySHA256 == "" { continue } if _, err := tx.Exec(`INSERT INTO mbox_index(account_id, folder, seq, message_id, body_sha256, subject, from_addr, date, file_offset, frame_len, inner_offset, inner_len) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, entry.AccountID, entry.Folder, entry.Seq, entry.MessageID, entry.BodySHA256, entry.Subject, entry.From, entry.Date, entry.FileOffset, entry.FrameLen, entry.InnerOffset, entry.InnerLen); err != nil { return err } // A full rebuild recovers records which reached the old append-first // pipeline but whose index/copied writes lost a SQLITE_BUSY race. In that // pipeline the target append happened before the mbox append, so recovered // records are complete in both stages. Marking them prevents a later watch // run from appending duplicates to either destination. state, found, err := copyStateForReindex(tx, entry.AccountID, folderAliases, entry, legacyIDsBySeq[i]) if err != nil { return err } if !found { state = CopyState{MboxDone: true, TargetDone: true} } if _, err := tx.Exec(`INSERT INTO copied(account_id, folder, message_id, body_sha256, mbox_done, target_done) VALUES(?,?,?,?,?,?) ON CONFLICT(account_id, folder, message_id, body_sha256) DO UPDATE SET mbox_done=1`, entry.AccountID, entry.Folder, entry.MessageID, entry.BodySHA256, 1, boolInt(state.TargetDone)); err != nil { return err } } if _, err := tx.Exec(`INSERT INTO mbox_index_state(account_id, folder, indexed_bytes) VALUES(?,?,?) ON CONFLICT(account_id, folder) DO UPDATE SET indexed_bytes=excluded.indexed_bytes`, accountID, folder, indexedBytes); err != nil { return err } return tx.Commit() } // RemoveMboxIndexFolderAliases removes only superseded index metadata such as // "INBOX.Newbies " after the actual archive file has been rebuilt under its // canonical filesystem name "INBOX.Newbies". copied compatibility aliases and // all mbox files remain untouched. func RemoveMboxIndexFolderAliases(accountID int64, canonicalFolders map[string]bool) error { rows, err := DB.Query(`SELECT folder FROM mbox_index WHERE account_id=? UNION SELECT folder FROM mbox_index_state WHERE account_id=?`, accountID, accountID) if err != nil { return err } var stale []string for rows.Next() { var folder string if err := rows.Scan(&folder); err != nil { _ = rows.Close() return err } if !canonicalFolders[folder] && canonicalFolders[safeMboxName(folder)] { stale = append(stale, folder) } } if err := rows.Err(); err != nil { _ = rows.Close() return err } if err := rows.Close(); err != nil { return err } if len(stale) == 0 { return nil } tx, err := DB.Begin() if err != nil { return err } defer tx.Rollback() for _, folder := range stale { if _, err := tx.Exec(`DELETE FROM mbox_index WHERE account_id=? AND folder=?`, accountID, folder); err != nil { return err } if _, err := tx.Exec(`DELETE FROM mbox_index_state WHERE account_id=? AND folder=?`, accountID, folder); err != nil { return err } } return tx.Commit() } func copyStateForReindex(tx *sql.Tx, accountID int64, folders []string, entry MboxIndexEntry, legacyMessageID string) (CopyState, bool, error) { type candidate struct{ messageID, bodyHash string } candidates := []candidate{{entry.MessageID, entry.BodySHA256}} if legacyMessageID != "" { candidates = append(candidates, candidate{legacyMessageID, ""}) } if entry.MessageID != "" { candidates = append(candidates, candidate{entry.MessageID, ""}) } else if entry.BodySHA256 != "" { candidates = append(candidates, candidate{"sha256:" + entry.BodySHA256, ""}) } seen := map[candidate]bool{} var state CopyState found := false for _, c := range candidates { if seen[c] { continue } seen[c] = true for _, folder := range folders { var count, mboxDone, targetDone int if err := tx.QueryRow(`SELECT count(*), COALESCE(MAX(mbox_done),0), COALESCE(MAX(target_done),0) FROM copied WHERE account_id=? AND folder=? AND message_id=? AND body_sha256=?`, accountID, folder, c.messageID, c.bodyHash).Scan(&count, &mboxDone, &targetDone); err != nil { return CopyState{}, false, err } if count > 0 { found = true state.MboxDone = state.MboxDone || mboxDone != 0 state.TargetDone = state.TargetDone || targetDone != 0 } } } return state, found, nil } func UpdateMboxIndexState(accountID int64, folder string, indexedBytes int64) error { if DB == nil || accountID == 0 { return nil } _, err := DB.Exec(`INSERT INTO mbox_index_state(account_id, folder, indexed_bytes) VALUES(?,?,?) ON CONFLICT(account_id, folder) DO UPDATE SET indexed_bytes=excluded.indexed_bytes`, accountID, folder, indexedBytes) return err } func MboxIndexedBytes(accountID int64, folder string) (int64, error) { if DB == nil || accountID == 0 { return 0, sql.ErrNoRows } var n int64 err := DB.QueryRow(`SELECT indexed_bytes FROM mbox_index_state WHERE account_id=? AND folder=?`, accountID, folder).Scan(&n) if errors.Is(err, sql.ErrNoRows) { return 0, nil } return n, err } func ListMboxIndex(accountID int64, folder string) ([]MboxIndexEntry, error) { if DB == nil || accountID == 0 { return nil, sql.ErrNoRows } rows, err := DB.Query(`SELECT account_id, folder, seq, message_id, body_sha256, subject, from_addr, date, file_offset, frame_len, inner_offset, inner_len FROM mbox_index WHERE account_id=? AND folder=? ORDER BY seq`, accountID, folder) if err != nil { return nil, err } defer rows.Close() var out []MboxIndexEntry for rows.Next() { var e MboxIndexEntry if err := rows.Scan(&e.AccountID, &e.Folder, &e.Seq, &e.MessageID, &e.BodySHA256, &e.Subject, &e.From, &e.Date, &e.FileOffset, &e.FrameLen, &e.InnerOffset, &e.InnerLen); err != nil { return nil, err } out = append(out, e) } return out, rows.Err() } func GetMboxIndex(accountID int64, folder string, seq int) (MboxIndexEntry, error) { if DB == nil || accountID == 0 { return MboxIndexEntry{}, sql.ErrNoRows } row := DB.QueryRow(`SELECT account_id, folder, seq, message_id, body_sha256, subject, from_addr, date, file_offset, frame_len, inner_offset, inner_len FROM mbox_index WHERE account_id=? AND folder=? AND seq=?`, accountID, folder, seq) var e MboxIndexEntry err := row.Scan(&e.AccountID, &e.Folder, &e.Seq, &e.MessageID, &e.BodySHA256, &e.Subject, &e.From, &e.Date, &e.FileOffset, &e.FrameLen, &e.InnerOffset, &e.InnerLen) return e, 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 } } a.MboxDir = strings.TrimSpace(a.MboxDir) } func scanAppUser(s appUserScanner) (AppUser, error) { var u AppUser var active int err := s.Scan(&u.ID, &u.Username, &u.DisplayName, &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 }