Mail-Graveyard/backend/02-database.go
2026-07-13 00:28:01 +02:00

376 lines
11 KiB
Go

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
}