662 lines
20 KiB
Go
662 lines
20 KiB
Go
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
|
|
}
|
|
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 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,
|
|
copied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(account_id, folder, message_id)
|
|
)`,
|
|
`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,
|
|
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, 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,
|
|
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 := seedArchiveMailboxesFromFS(); err != nil {
|
|
_ = db.Close()
|
|
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
|
|
}
|
|
defer rows.Close()
|
|
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 {
|
|
return err
|
|
}
|
|
if name == "display_name" {
|
|
hasDisplayName = true
|
|
}
|
|
}
|
|
if err := rows.Err(); 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 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) {
|
|
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
|
|
}
|
|
|
|
type MboxIndexEntry struct {
|
|
AccountID int64
|
|
Folder string
|
|
Seq int
|
|
MessageID 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 == "" {
|
|
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 IGNORE INTO mbox_index(account_id, folder, seq, message_id, subject, from_addr, date, file_offset, frame_len, inner_offset, inner_len)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
|
|
e.AccountID, e.Folder, seq, e.MessageID, e.Subject, e.From, e.Date, e.FileOffset, e.FrameLen, e.InnerOffset, e.InnerLen)
|
|
return 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, 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.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, 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.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
|
|
}
|