Files
gowebmail/internal/db/db.go
T

3719 lines
126 KiB
Go

// Package db provides encrypted SQLite storage for GoWebMail.
package db
import (
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"strings"
"time"
"github.com/ghostersk/gowebmail/internal/crypto"
"github.com/ghostersk/gowebmail/internal/models"
_ "github.com/mattn/go-sqlite3"
)
// DB wraps a SQLite database with field-level AES-256 encryption.
type DB struct {
sql *sql.DB
enc *crypto.Encryptor
}
// New opens (or creates) a SQLite database at path, using encKey for field encryption.
func New(path string, encKey []byte) (*DB, error) {
enc, err := crypto.New(encKey)
if err != nil {
return nil, fmt.Errorf("encryptor init: %w", err)
}
// Enable WAL mode and foreign keys for performance and integrity
// sqlite file path must start with `file:` for package mattn/go-sqlite3
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000", path)
sqlDB, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
sqlDB.SetMaxOpenConns(1) // SQLite is single-writer
return &DB{sql: sqlDB, enc: enc}, nil
}
// Close closes the underlying database.
func (d *DB) Close() error {
return d.sql.Close()
}
// ---- Migrations ----
// Migrate creates all required tables and bootstraps the admin account.
func (d *DB) Migrate() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
username TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
is_active INTEGER NOT NULL DEFAULT 1,
mfa_enabled INTEGER NOT NULL DEFAULT 0,
mfa_secret TEXT NOT NULL DEFAULT '',
mfa_pending TEXT NOT NULL DEFAULT '',
sync_interval INTEGER NOT NULL DEFAULT 15,
last_login_at DATETIME,
created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS email_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
email_address TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
access_token TEXT NOT NULL DEFAULT '',
refresh_token TEXT NOT NULL DEFAULT '',
token_expiry DATETIME,
imap_host TEXT NOT NULL DEFAULT '',
imap_port INTEGER NOT NULL DEFAULT 0,
smtp_host TEXT NOT NULL DEFAULT '',
smtp_port INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '#4A90D9',
is_active INTEGER NOT NULL DEFAULT 1,
last_sync DATETIME,
created_at DATETIME DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_accounts_user ON email_accounts(user_id)`,
`CREATE TABLE IF NOT EXISTS folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
name TEXT NOT NULL,
full_path TEXT NOT NULL,
folder_type TEXT NOT NULL DEFAULT 'custom',
unread_count INTEGER NOT NULL DEFAULT 0,
total_count INTEGER NOT NULL DEFAULT 0
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_folders_account_path ON folders(account_id, full_path)`,
`CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
remote_uid TEXT NOT NULL,
thread_id TEXT NOT NULL DEFAULT '',
message_id TEXT NOT NULL DEFAULT '',
subject TEXT NOT NULL DEFAULT '',
from_name TEXT NOT NULL DEFAULT '',
from_email TEXT NOT NULL DEFAULT '',
to_list TEXT NOT NULL DEFAULT '',
cc_list TEXT NOT NULL DEFAULT '',
bcc_list TEXT NOT NULL DEFAULT '',
reply_to TEXT NOT NULL DEFAULT '',
body_text TEXT NOT NULL DEFAULT '',
body_html TEXT NOT NULL DEFAULT '',
date DATETIME NOT NULL,
is_read INTEGER NOT NULL DEFAULT 0,
is_starred INTEGER NOT NULL DEFAULT 0,
is_draft INTEGER NOT NULL DEFAULT 0,
has_attachment INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT (datetime('now'))
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_uid ON messages(account_id, folder_id, remote_uid)`,
`CREATE INDEX IF NOT EXISTS idx_messages_date ON messages(date DESC)`,
`CREATE INDEX IF NOT EXISTS idx_messages_account ON messages(account_id)`,
`CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
filename TEXT NOT NULL DEFAULT '',
content_type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
content_id TEXT NOT NULL DEFAULT '',
data BLOB
)`,
`CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mfa_verified INTEGER NOT NULL DEFAULT 0,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`,
`CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
event TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
ip_address TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_audit_event ON audit_log(event)`,
`CREATE TABLE IF NOT EXISTS remote_content_whitelist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
sender TEXT NOT NULL,
created_at DATETIME DEFAULT (datetime('now')),
UNIQUE(user_id, sender)
)`,
`CREATE TABLE IF NOT EXISTS spam_blocklist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
sender TEXT NOT NULL,
created_at DATETIME DEFAULT (datetime('now')),
UNIQUE(user_id, sender)
)`,
}
for _, stmt := range stmts {
if _, err := d.sql.Exec(stmt); err != nil {
return fmt.Errorf("migration error (%s...): %w", stmt[:40], err)
}
}
// Additive ALTER TABLE migrations — safe to re-run (SQLite ignores duplicate column errors)
alterStmts := []string{
`ALTER TABLE email_accounts ADD COLUMN sync_days INTEGER NOT NULL DEFAULT 30`,
`ALTER TABLE email_accounts ADD COLUMN sync_mode TEXT NOT NULL DEFAULT 'days'`,
`ALTER TABLE email_accounts ADD COLUMN sync_all_folders INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE users ADD COLUMN compose_popup INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE messages ADD COLUMN folder_path TEXT NOT NULL DEFAULT ''`,
// Folder visibility: is_hidden hides from sidebar; sync_enabled controls auto-sync.
`ALTER TABLE folders ADD COLUMN is_hidden INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE folders ADD COLUMN sync_enabled INTEGER NOT NULL DEFAULT 1`,
// Plaintext search index column — stores decrypted subject+from+preview for LIKE search.
`ALTER TABLE messages ADD COLUMN search_text TEXT NOT NULL DEFAULT ''`,
// Per-folder IMAP sync state for incremental/delta sync.
`ALTER TABLE folders ADD COLUMN uid_validity INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE folders ADD COLUMN last_seen_uid INTEGER NOT NULL DEFAULT 0`,
// Account display order for sidebar drag-and-drop reordering.
`ALTER TABLE email_accounts ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`,
// UI preferences (JSON): collapsed accounts/folders, etc. Synced across devices.
`ALTER TABLE users ADD COLUMN ui_prefs TEXT NOT NULL DEFAULT '{}'`,
// Optional CalDAV/CardDAV sync, works alongside any mail provider (encrypted like imap_host).
`ALTER TABLE email_accounts ADD COLUMN caldav_url TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE email_accounts ADD COLUMN carddav_url TEXT NOT NULL DEFAULT ''`,
// account_id/uid let CardDAV-synced contacts be upserted and pruned like calendar_events already are.
`ALTER TABLE contacts ADD COLUMN account_id INTEGER REFERENCES email_accounts(id) ON DELETE SET NULL`,
`ALTER TABLE contacts ADD COLUMN uid TEXT NOT NULL DEFAULT ''`,
// Scoped plaintext search indexes — let search filter to subject-only or body-only
// instead of always matching the combined search_text blob.
`ALTER TABLE messages ADD COLUMN search_subject TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE messages ADD COLUMN search_body TEXT NOT NULL DEFAULT ''`,
// Snooze: NULL = not snoozed; a future timestamp hides the message from normal
// folder views until it passes, at which point the background sweep clears it.
`ALTER TABLE messages ADD COLUMN snoozed_until DATETIME`,
}
for _, stmt := range alterStmts {
d.sql.Exec(stmt) // ignore "duplicate column" errors intentionally
}
// Pending IMAP operations queue — survives server restarts.
// op_type: "delete" | "move" | "flag_read" | "flag_star"
_, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS pending_imap_ops (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
op_type TEXT NOT NULL,
remote_uid INTEGER NOT NULL,
folder_path TEXT NOT NULL DEFAULT '',
extra TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT (datetime('now'))
)`)
if err != nil {
return fmt.Errorf("create pending_imap_ops: %w", err)
}
// Login attempt tracking for brute-force protection.
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
username TEXT NOT NULL DEFAULT '',
success INTEGER NOT NULL DEFAULT 0,
country TEXT NOT NULL DEFAULT '',
country_code TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now'))
)`); err != nil {
return fmt.Errorf("create login_attempts: %w", err)
}
if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_login_attempts_ip_time ON login_attempts(ip, created_at)`); err != nil {
return fmt.Errorf("create login_attempts index: %w", err)
}
// IP block list — manually added or auto-created by brute force protection.
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS ip_blocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL UNIQUE,
reason TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
country_code TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
blocked_at DATETIME DEFAULT (datetime('now')),
expires_at DATETIME,
is_permanent INTEGER NOT NULL DEFAULT 0
)`); err != nil {
return fmt.Errorf("create ip_blocks: %w", err)
}
// Per-user IP access rules.
// mode: "brute_skip" = skip brute force check for this user from listed IPs
// "allow_only" = only allow login from listed IPs (all others get 403)
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS user_ip_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mode TEXT NOT NULL DEFAULT 'brute_skip',
ip_list TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now')),
UNIQUE(user_id)
)`); err != nil {
return fmt.Errorf("create user_ip_rules: %w", err)
}
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id INTEGER REFERENCES email_accounts(id) ON DELETE SET NULL,
uid TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
company TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
avatar_color TEXT NOT NULL DEFAULT '#6b7280',
created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now'))
)`); err != nil {
return fmt.Errorf("create contacts: %w", err)
}
if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_contacts_user ON contacts(user_id)`); err != nil {
return fmt.Errorf("index contacts_user: %w", err)
}
if _, err := d.sql.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_user_uid ON contacts(user_id, uid)`); err != nil {
return fmt.Errorf("index contacts_user_uid: %w", err)
}
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS calendar_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id INTEGER REFERENCES email_accounts(id) ON DELETE SET NULL,
uid TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
location TEXT NOT NULL DEFAULT '',
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
all_day INTEGER NOT NULL DEFAULT 0,
recurrence_rule TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'confirmed',
organizer_email TEXT NOT NULL DEFAULT '',
attendees TEXT NOT NULL DEFAULT '',
ical_source TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now')),
UNIQUE(user_id, uid)
)`); err != nil {
return fmt.Errorf("create calendar_events: %w", err)
}
if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_calendar_user_time ON calendar_events(user_id, start_time)`); err != nil {
return fmt.Errorf("index calendar_user_time: %w", err)
}
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS caldav_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
label TEXT NOT NULL DEFAULT 'CalDAV token',
created_at DATETIME DEFAULT (datetime('now')),
last_used DATETIME
)`); err != nil {
return fmt.Errorf("create caldav_tokens: %w", err)
}
// Mail rules (filters) — evaluated against newly-synced messages per account.
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
name TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 0,
conditions TEXT NOT NULL, -- JSON [{field,op,value}]
match_type TEXT NOT NULL DEFAULT 'all', -- all|any
action TEXT NOT NULL, -- move_to_folder|delete|mark_read|mark_as_spam|forward|auto_reply
action_value TEXT NOT NULL DEFAULT '',
action_options TEXT NOT NULL DEFAULT '{}',
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT (datetime('now'))
)`); err != nil {
return fmt.Errorf("create rules: %w", err)
}
if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_rules_account ON rules(account_id, priority)`); err != nil {
return fmt.Errorf("index rules_account: %w", err)
}
// Loop-prevention log for the auto_reply rule action.
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS auto_reply_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
rule_id INTEGER NOT NULL,
recipient_email TEXT NOT NULL,
sent_at DATETIME DEFAULT (datetime('now'))
)`); err != nil {
return fmt.Errorf("create auto_reply_log: %w", err)
}
if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_auto_reply_log ON auto_reply_log(account_id, rule_id, recipient_email, sent_at)`); err != nil {
return fmt.Errorf("index auto_reply_log: %w", err)
}
// Signatures — belong to the user (span accounts); default assignment is per-account.
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS signatures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
content_html TEXT NOT NULL,
created_at DATETIME DEFAULT (datetime('now'))
)`); err != nil {
return fmt.Errorf("create signatures: %w", err)
}
if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_signatures_user ON signatures(user_id)`); err != nil {
return fmt.Errorf("index signatures_user: %w", err)
}
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS signature_defaults (
account_id INTEGER PRIMARY KEY REFERENCES email_accounts(id) ON DELETE CASCADE,
default_new_id INTEGER REFERENCES signatures(id) ON DELETE SET NULL,
default_reply_id INTEGER REFERENCES signatures(id) ON DELETE SET NULL
)`); err != nil {
return fmt.Errorf("create signature_defaults: %w", err)
}
// S/MIME identities (per-account, sign) and contact certs (per-user, encrypt-to).
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS smime_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
cert_pem TEXT NOT NULL,
key_pem TEXT NOT NULL, -- encrypted at rest via internal/crypto.Encryptor
not_after DATETIME NOT NULL,
created_at DATETIME DEFAULT (datetime('now'))
)`); err != nil {
return fmt.Errorf("create smime_identities: %w", err)
}
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS smime_contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
cert_pem TEXT NOT NULL,
created_at DATETIME DEFAULT (datetime('now')),
UNIQUE(user_id, email)
)`); err != nil {
return fmt.Errorf("create smime_contacts: %w", err)
}
// PGP identities (per-account, encrypt/decrypt) and contact public keys (per-user, encrypt-to).
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS pgp_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
label TEXT NOT NULL,
email TEXT NOT NULL,
fingerprint TEXT NOT NULL,
public_key_armor TEXT NOT NULL,
private_key_armor TEXT NOT NULL, -- native OpenPGP S2K passphrase protection
created_at DATETIME DEFAULT (datetime('now'))
)`); err != nil {
return fmt.Errorf("create pgp_identities: %w", err)
}
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS pgp_contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
fingerprint TEXT NOT NULL,
public_key_armor TEXT NOT NULL,
created_at DATETIME DEFAULT (datetime('now')),
UNIQUE(user_id, email)
)`); err != nil {
return fmt.Errorf("create pgp_contacts: %w", err)
}
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT NOT NULL,
created_at DATETIME DEFAULT (datetime('now')),
UNIQUE(user_id, name COLLATE NOCASE)
)`); err != nil {
return fmt.Errorf("create labels: %w", err)
}
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS message_labels (
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
PRIMARY KEY (message_id, label_id)
)`); err != nil {
return fmt.Errorf("create message_labels: %w", err)
}
// Send-later: a fully-composed message held until send_at, delivered by the background
// sweep in main.go via the same send path as an immediate send. to/cc/bcc/forward_from_ids
// are JSON arrays in plain TEXT (matching messages.to_list's existing convention);
// subject/body are AES-encrypted like messages.subject already is. No raw file attachments
// in v1 — only forwarded-message .eml attachments (forward_from_ids), since those need no
// blob storage between scheduling and send time.
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS scheduled_sends (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
to_list TEXT NOT NULL DEFAULT '',
cc_list TEXT NOT NULL DEFAULT '',
bcc_list TEXT NOT NULL DEFAULT '',
subject TEXT NOT NULL DEFAULT '',
body_html TEXT NOT NULL DEFAULT '',
body_text TEXT NOT NULL DEFAULT '',
forward_from_ids TEXT NOT NULL DEFAULT '',
send_at DATETIME NOT NULL,
created_at DATETIME DEFAULT (datetime('now'))
)`); err != nil {
return fmt.Errorf("create scheduled_sends: %w", err)
}
if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_scheduled_sends_due ON scheduled_sends(send_at)`); err != nil {
return fmt.Errorf("create idx_scheduled_sends_due: %w", err)
}
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS trusted_certs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
cert_fingerprint TEXT NOT NULL,
cert_pem TEXT NOT NULL,
hostname TEXT NOT NULL,
created_at DATETIME DEFAULT (datetime('now')),
UNIQUE(account_id, cert_fingerprint)
)`); err != nil {
return fmt.Errorf("create trusted_certs: %w", err)
}
// One-row-per-migration marker table so a one-time data backfill (as opposed to a
// schema ALTER, which is naturally idempotent) runs exactly once, ever — never
// re-applying and silently overwriting a choice the user made after that first run.
// Web Push subscriptions — a browser/WebView calls PushManager.subscribe() once per
// device and posts the result here; the syncer looks these up by user_id to deliver
// background new-mail notifications via VAPID. Outlives login sessions on purpose.
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at DATETIME DEFAULT (datetime('now'))
)`); err != nil {
return fmt.Errorf("create push_subscriptions: %w", err)
}
if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_push_subs_user ON push_subscriptions(user_id)`); err != nil {
return fmt.Errorf("create idx_push_subs_user: %w", err)
}
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS data_migrations (
name TEXT PRIMARY KEY,
applied_at DATETIME DEFAULT (datetime('now'))
)`); err != nil {
return fmt.Errorf("create data_migrations: %w", err)
}
d.backfillSearchIndex()
d.runOnce("sync_all_folders_by_default", d.backfillSyncAllFoldersDefault)
d.runOnce("seed_default_labels_existing_users", d.seedDefaultLabelsForExistingUsers)
// Bootstrap admin account if no users exist
return d.bootstrapAdmin()
}
// runOnce executes fn the first time this DB ever sees the given migration name, then
// never again — used for one-time data backfills where re-running on every startup would
// stomp a choice the user made afterward (unlike a schema ALTER, which is self-limiting).
func (d *DB) runOnce(name string, fn func()) {
var exists int
d.sql.QueryRow(`SELECT 1 FROM data_migrations WHERE name=?`, name).Scan(&exists)
if exists == 1 {
return
}
fn()
d.sql.Exec(`INSERT OR IGNORE INTO data_migrations(name) VALUES (?)`, name)
}
// backfillSyncAllFoldersDefault enables sync for every folder that predates the "sync all
// folders by default" change (previously only inbox/sent/drafts/trash/spam synced by
// default; custom folders sat disabled until manually enabled). Runs once, via runOnce.
func (d *DB) backfillSyncAllFoldersDefault() {
r, err := d.sql.Exec(`UPDATE folders SET sync_enabled=1 WHERE sync_enabled=0`)
if err != nil {
return
}
if n, _ := r.RowsAffected(); n > 0 {
log.Printf("[migrate] enabled sync for %d folder(s) that predated the sync-all-by-default change", n)
}
d.sql.Exec(`UPDATE email_accounts SET sync_all_folders=1`)
}
// backfillSearchIndex populates search_subject/search_body for messages synced
// before those columns existed (they default to '' from the ALTER TABLE above).
// One-time, best-effort: skipped entirely once no rows need it.
func (d *DB) backfillSearchIndex() {
var pending int
d.sql.QueryRow(`SELECT COUNT(*) FROM messages WHERE search_subject='' AND search_text!=''`).Scan(&pending)
if pending == 0 {
return
}
log.Printf("[migrate] backfilling search index for %d message(s)...", pending)
rows, err := d.sql.Query(`SELECT id, subject, body_text FROM messages WHERE search_subject='' AND search_text!=''`)
if err != nil {
return
}
type row struct{ id int64; subject, body string }
var pendingRows []row
for rows.Next() {
var r row
if rows.Scan(&r.id, &r.subject, &r.body) == nil {
pendingRows = append(pendingRows, r)
}
}
rows.Close()
for _, r := range pendingRows {
subject, _ := d.enc.Decrypt(r.subject)
body, _ := d.enc.Decrypt(r.body)
if len(body) > 2000 {
body = body[:2000]
}
d.sql.Exec(`UPDATE messages SET search_subject=?, search_body=? WHERE id=?`,
strings.ToLower(subject), strings.ToLower(body), r.id)
}
log.Printf("[migrate] search index backfill complete")
}
// bootstrapAdmin creates the default admin/admin account on first run.
func (d *DB) bootstrapAdmin() error {
var count int
d.sql.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count)
if count > 0 {
return nil
}
hash, err := crypto.HashPassword("admin")
if err != nil {
return err
}
_, err = d.sql.Exec(
`INSERT INTO users (email, username, password_hash, role, is_active)
VALUES ('admin', 'admin', ?, 'admin', 1)`, hash,
)
if err != nil {
return fmt.Errorf("bootstrap admin: %w", err)
}
var adminID int64
d.sql.QueryRow(`SELECT id FROM users WHERE username='admin'`).Scan(&adminID)
d.seedDefaultLabels(adminID)
fmt.Println("WARNING: Default admin account created: username=admin password=admin — CHANGE THIS IMMEDIATELY")
return nil
}
// seedDefaultLabels gives a newly created user the starter label set, each a preset color.
func (d *DB) seedDefaultLabels(userID int64) {
defaults := []struct{ name, color string }{
{"Important", "#e5484d"},
{"Personal", "#5b8def"},
{"Work", "#f5a623"},
{"ToDo", "#7c5cfc"},
}
for _, l := range defaults {
d.sql.Exec(`INSERT OR IGNORE INTO labels (user_id, name, color) VALUES (?,?,?)`, userID, l.name, l.color)
}
}
// seedDefaultLabelsForExistingUsers backfills the starter label set for users created
// before the labels feature existed (bootstrapAdmin/CreateUser only seed new users).
func (d *DB) seedDefaultLabelsForExistingUsers() {
rows, err := d.sql.Query(`SELECT id FROM users`)
if err != nil {
return
}
var ids []int64
for rows.Next() {
var id int64
if rows.Scan(&id) == nil {
ids = append(ids, id)
}
}
rows.Close()
for _, id := range ids {
d.seedDefaultLabels(id)
}
}
// ---- Users ----
func (d *DB) CreateUser(username, email, password string, role models.UserRole) (*models.User, error) {
hash, err := crypto.HashPassword(password)
if err != nil {
return nil, err
}
res, err := d.sql.Exec(
`INSERT INTO users (email, username, password_hash, role) VALUES (?, ?, ?, ?)`,
email, username, hash, role,
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return nil, fmt.Errorf("email already registered")
}
return nil, err
}
id, _ := res.LastInsertId()
d.seedDefaultLabels(id)
return d.GetUserByID(id)
}
func (d *DB) scanUser(row *sql.Row) (*models.User, error) {
u := &models.User{}
var mfaSecretEnc, mfaPendingEnc string
var lastLogin sql.NullTime
var composePopup int
err := row.Scan(
&u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.Role, &u.IsActive,
&u.MFAEnabled, &mfaSecretEnc, &mfaPendingEnc, &lastLogin,
&u.CreatedAt, &u.UpdatedAt, &u.SyncInterval, &composePopup,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
u.MFASecret, _ = d.enc.Decrypt(mfaSecretEnc)
u.MFAPending, _ = d.enc.Decrypt(mfaPendingEnc)
if lastLogin.Valid {
u.LastLoginAt = &lastLogin.Time
}
u.ComposePopup = composePopup == 1
return u, nil
}
const userSelectCols = `SELECT id, email, username, password_hash, role, is_active,
mfa_enabled, mfa_secret, mfa_pending, last_login_at, created_at, updated_at,
COALESCE(sync_interval,15), COALESCE(compose_popup,0) FROM users`
func (d *DB) GetUserByEmail(email string) (*models.User, error) {
return d.scanUser(d.sql.QueryRow(userSelectCols+` WHERE email=?`, email))
}
func (d *DB) GetUserByUsername(username string) (*models.User, error) {
return d.scanUser(d.sql.QueryRow(userSelectCols+` WHERE username=?`, username))
}
func (d *DB) GetUserByID(id int64) (*models.User, error) {
return d.scanUser(d.sql.QueryRow(userSelectCols+` WHERE id=?`, id))
}
func (d *DB) ListUsers() ([]*models.User, error) {
rows, err := d.sql.Query(userSelectCols + ` ORDER BY created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
var users []*models.User
for rows.Next() {
u := &models.User{}
var mfaSecretEnc, mfaPendingEnc string
var lastLogin sql.NullTime
var composePopup int
if err := rows.Scan(
&u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.Role, &u.IsActive,
&u.MFAEnabled, &mfaSecretEnc, &mfaPendingEnc, &lastLogin,
&u.CreatedAt, &u.UpdatedAt, &u.SyncInterval, &composePopup,
); err != nil {
return nil, err
}
u.ComposePopup = composePopup == 1
u.MFASecret, _ = d.enc.Decrypt(mfaSecretEnc)
u.MFAPending, _ = d.enc.Decrypt(mfaPendingEnc)
if lastLogin.Valid {
u.LastLoginAt = &lastLogin.Time
}
users = append(users, u)
}
return users, rows.Err()
}
func (d *DB) UpdateUserPassword(userID int64, newPassword string) error {
hash, err := crypto.HashPassword(newPassword)
if err != nil {
return err
}
_, err = d.sql.Exec(
`UPDATE users SET password_hash=?, updated_at=datetime('now') WHERE id=?`, hash, userID,
)
return err
}
// AdminListAdmins returns (username, email, mfa_enabled) for all admin-role users.
func (d *DB) AdminListAdmins() ([]struct {
Username string
Email string
MFAEnabled bool
}, error) {
rows, err := d.sql.Query(`SELECT username, email, mfa_enabled FROM users WHERE role='admin' ORDER BY username`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []struct {
Username string
Email string
MFAEnabled bool
}
for rows.Next() {
var r struct {
Username string
Email string
MFAEnabled bool
}
rows.Scan(&r.Username, &r.Email, &r.MFAEnabled)
out = append(out, r)
}
return out, rows.Err()
}
// AdminResetPassword sets a new password for an admin user by username (admin-only check).
func (d *DB) AdminResetPassword(username, newPassword string) error {
// Verify user exists and is admin
var id int64
var role string
err := d.sql.QueryRow(`SELECT id, role FROM users WHERE username=?`, username).Scan(&id, &role)
if err != nil || id == 0 {
return fmt.Errorf("user %q not found", username)
}
if role != "admin" {
return fmt.Errorf("user %q is not an admin (use the web UI for regular users)", username)
}
return d.UpdateUserPassword(id, newPassword)
}
// AdminDisableMFA disables MFA for an admin user by username (admin-only check).
func (d *DB) AdminDisableMFA(username string) error {
var id int64
var role string
err := d.sql.QueryRow(`SELECT id, role FROM users WHERE username=?`, username).Scan(&id, &role)
if err != nil || id == 0 {
return fmt.Errorf("user %q not found", username)
}
if role != "admin" {
return fmt.Errorf("user %q is not an admin (use the web UI for regular users)", username)
}
return d.DisableMFA(id)
}
func (d *DB) SetUserActive(userID int64, active bool) error {
v := 0
if active {
v = 1
}
_, err := d.sql.Exec(`UPDATE users SET is_active=?, updated_at=datetime('now') WHERE id=?`, v, userID)
return err
}
func (d *DB) DeleteUser(userID int64) error {
_, err := d.sql.Exec(`DELETE FROM users WHERE id=? AND role != 'admin'`, userID)
return err
}
func (d *DB) TouchLastLogin(userID int64) {
d.sql.Exec(`UPDATE users SET last_login_at=datetime('now') WHERE id=?`, userID)
}
// ---- MFA ----
func (d *DB) SetMFAPending(userID int64, secret string) error {
enc, err := d.enc.Encrypt(secret)
if err != nil {
return err
}
_, err = d.sql.Exec(`UPDATE users SET mfa_pending=?, updated_at=datetime('now') WHERE id=?`, enc, userID)
return err
}
func (d *DB) EnableMFA(userID int64, secret string) error {
enc, err := d.enc.Encrypt(secret)
if err != nil {
return err
}
_, err = d.sql.Exec(
`UPDATE users SET mfa_enabled=1, mfa_secret=?, mfa_pending='', updated_at=datetime('now') WHERE id=?`,
enc, userID,
)
return err
}
func (d *DB) DisableMFA(userID int64) error {
_, err := d.sql.Exec(
`UPDATE users SET mfa_enabled=0, mfa_secret='', mfa_pending='', updated_at=datetime('now') WHERE id=?`,
userID,
)
return err
}
// ---- Sessions ----
func (d *DB) CreateSession(userID int64, ttl time.Duration) (string, error) {
token, err := crypto.GenerateToken(32)
if err != nil {
return "", err
}
expiry := time.Now().Add(ttl)
_, err = d.sql.Exec(
`INSERT INTO sessions (token, user_id, mfa_verified, expires_at) VALUES (?, ?, 0, ?)`,
token, userID, expiry,
)
return token, err
}
func (d *DB) SetSessionMFAVerified(token string) error {
_, err := d.sql.Exec(`UPDATE sessions SET mfa_verified=1 WHERE token=?`, token)
return err
}
func (d *DB) GetSession(token string) (userID int64, mfaVerified bool, err error) {
var expiresAt time.Time
err = d.sql.QueryRow(
`SELECT user_id, mfa_verified, expires_at FROM sessions WHERE token=?`, token,
).Scan(&userID, &mfaVerified, &expiresAt)
if err == sql.ErrNoRows {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
if time.Now().After(expiresAt) {
d.sql.Exec(`DELETE FROM sessions WHERE token=?`, token)
return 0, false, nil
}
return userID, mfaVerified, nil
}
func (d *DB) DeleteSession(token string) error {
_, err := d.sql.Exec(`DELETE FROM sessions WHERE token=?`, token)
return err
}
// ---- Audit Log ----
func (d *DB) WriteAudit(userID *int64, event models.AuditEventType, detail, ip, ua string) {
d.sql.Exec(
`INSERT INTO audit_log (user_id, event, detail, ip_address, user_agent) VALUES (?,?,?,?,?)`,
userID, string(event), detail, ip, ua,
)
}
func (d *DB) ListAuditLogs(page, pageSize int, eventFilter string) (*models.AuditPage, error) {
offset := (page - 1) * pageSize
where := ""
args := []interface{}{}
if eventFilter != "" {
where = " WHERE a.event=?"
args = append(args, eventFilter)
}
var total int
d.sql.QueryRow(`SELECT COUNT(*) FROM audit_log a`+where, args...).Scan(&total)
args = append(args, pageSize, offset)
rows, err := d.sql.Query(`
SELECT a.id, a.user_id, COALESCE(u.email,''), a.event, a.detail, a.ip_address, a.user_agent, a.created_at
FROM audit_log a LEFT JOIN users u ON u.id=a.user_id`+where+`
ORDER BY a.created_at DESC LIMIT ? OFFSET ?`, args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
var logs []models.AuditLog
for rows.Next() {
l := models.AuditLog{}
var uid sql.NullInt64
if err := rows.Scan(&l.ID, &uid, &l.UserEmail, &l.Event, &l.Detail, &l.IPAddress, &l.UserAgent, &l.CreatedAt); err != nil {
return nil, err
}
if uid.Valid {
l.UserID = &uid.Int64
}
logs = append(logs, l)
}
return &models.AuditPage{
Logs: logs, Total: total, Page: page, PageSize: pageSize,
HasMore: offset+len(logs) < total,
}, rows.Err()
}
// ListLoginHistory returns a user's own login attempts (success + failure) — used by the
// Settings > Security "Login History" viewer. Always scoped to userID so a user can only ever
// see their own attempts, unlike the admin-only ListAuditLogs above. success nil means both;
// true/false filters to just successful/failed attempts. ip is a substring match. dateFrom/
// dateTo are inclusive "YYYY-MM-DD HH:MM:SS" bounds (caller pads a plain date to a full day).
func (d *DB) ListLoginHistory(userID int64, page, pageSize int, dateFrom, dateTo string, success *bool, ip string, sortAsc bool) (*models.AuditPage, error) {
offset := (page - 1) * pageSize
where := " WHERE a.user_id=? AND a.event IN ('login','login_fail')"
args := []interface{}{userID}
if success != nil {
if *success {
where += " AND a.event='login'"
} else {
where += " AND a.event='login_fail'"
}
}
if dateFrom != "" {
where += " AND a.created_at>=?"
args = append(args, dateFrom)
}
if dateTo != "" {
where += " AND a.created_at<=?"
args = append(args, dateTo)
}
if ip != "" {
where += " AND a.ip_address LIKE ?"
args = append(args, "%"+ip+"%")
}
var total int
d.sql.QueryRow(`SELECT COUNT(*) FROM audit_log a`+where, args...).Scan(&total)
order := "DESC"
if sortAsc {
order = "ASC"
}
args = append(args, pageSize, offset)
rows, err := d.sql.Query(`
SELECT a.id, a.event, a.detail, a.ip_address, a.user_agent, a.created_at
FROM audit_log a`+where+`
ORDER BY a.created_at `+order+` LIMIT ? OFFSET ?`, args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
var logs []models.AuditLog
for rows.Next() {
l := models.AuditLog{UserID: &userID}
if err := rows.Scan(&l.ID, &l.Event, &l.Detail, &l.IPAddress, &l.UserAgent, &l.CreatedAt); err != nil {
return nil, err
}
logs = append(logs, l)
}
return &models.AuditPage{
Logs: logs, Total: total, Page: page, PageSize: pageSize,
HasMore: offset+len(logs) < total,
}, rows.Err()
}
// ---- Email Accounts ----
func (d *DB) CreateAccount(a *models.EmailAccount) error {
accessEnc, _ := d.enc.Encrypt(a.AccessToken)
refreshEnc, _ := d.enc.Encrypt(a.RefreshToken)
imapHostEnc, _ := d.enc.Encrypt(a.IMAPHost)
smtpHostEnc, _ := d.enc.Encrypt(a.SMTPHost)
caldavEnc, _ := d.enc.Encrypt(a.CalDAVURL)
carddavEnc, _ := d.enc.Encrypt(a.CardDAVURL)
res, err := d.sql.Exec(`
INSERT INTO email_accounts
(user_id, provider, email_address, display_name, access_token, refresh_token,
token_expiry, imap_host, imap_port, smtp_host, smtp_port, color, caldav_url, carddav_url)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
a.UserID, a.Provider, a.EmailAddress, a.DisplayName,
accessEnc, refreshEnc, a.TokenExpiry,
imapHostEnc, a.IMAPPort, smtpHostEnc, a.SMTPPort,
a.Color, caldavEnc, carddavEnc,
)
if err != nil {
return err
}
id, _ := res.LastInsertId()
a.ID = id
return nil
}
func (d *DB) UpdateAccountTokens(accountID int64, accessToken, refreshToken string, expiry time.Time) error {
accessEnc, _ := d.enc.Encrypt(accessToken)
refreshEnc, _ := d.enc.Encrypt(refreshToken)
_, err := d.sql.Exec(
`UPDATE email_accounts SET access_token=?, refresh_token=?, token_expiry=? WHERE id=?`,
accessEnc, refreshEnc, expiry, accountID,
)
return err
}
func (d *DB) UpdateAccountLastSync(accountID int64) error {
_, err := d.sql.Exec(`UPDATE email_accounts SET last_sync=? WHERE id=?`, time.Now(), accountID)
return err
}
func (d *DB) GetAccount(accountID int64) (*models.EmailAccount, error) {
a := &models.EmailAccount{}
var accessEnc, refreshEnc, imapHostEnc, smtpHostEnc, caldavEnc, carddavEnc string
var lastSync sql.NullTime
err := d.sql.QueryRow(`
SELECT id, user_id, provider, email_address, display_name,
access_token, refresh_token, token_expiry,
imap_host, imap_port, smtp_host, smtp_port,
last_error, color, is_active, last_sync, created_at,
COALESCE(sync_days,30), COALESCE(sync_mode,'days'), COALESCE(sort_order,0),
COALESCE(caldav_url,''), COALESCE(carddav_url,'')
FROM email_accounts WHERE id=?`, accountID,
).Scan(
&a.ID, &a.UserID, &a.Provider, &a.EmailAddress, &a.DisplayName,
&accessEnc, &refreshEnc, &a.TokenExpiry,
&imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort,
&a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt,
&a.SyncDays, &a.SyncMode, &a.SortOrder,
&caldavEnc, &carddavEnc,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
a.AccessToken, _ = d.enc.Decrypt(accessEnc)
a.RefreshToken, _ = d.enc.Decrypt(refreshEnc)
a.IMAPHost, _ = d.enc.Decrypt(imapHostEnc)
a.SMTPHost, _ = d.enc.Decrypt(smtpHostEnc)
a.CalDAVURL, _ = d.enc.Decrypt(caldavEnc)
a.CardDAVURL, _ = d.enc.Decrypt(carddavEnc)
if lastSync.Valid {
a.LastSync = lastSync.Time
}
if a.SyncDays == 0 {
a.SyncDays = 30
}
return a, nil
}
func (d *DB) GetUserSyncInterval(userID int64) (int, error) {
var interval int
err := d.sql.QueryRow(`SELECT sync_interval FROM users WHERE id=?`, userID).Scan(&interval)
if err != nil {
return 15, err
}
return interval, nil
}
func (d *DB) SetUserSyncInterval(userID int64, minutes int) error {
_, err := d.sql.Exec(`UPDATE users SET sync_interval=? WHERE id=?`, minutes, userID)
return err
}
func (d *DB) SetComposePopup(userID int64, popup bool) error {
v := 0
if popup {
v = 1
}
_, err := d.sql.Exec(`UPDATE users SET compose_popup=? WHERE id=?`, v, userID)
return err
}
func (d *DB) SetAccountSyncSettings(accountID, userID int64, syncDays int, syncMode string) error {
if syncMode == "" {
syncMode = "days"
}
_, err := d.sql.Exec(`UPDATE email_accounts SET sync_days=?, sync_mode=? WHERE id=? AND user_id=?`,
syncDays, syncMode, accountID, userID)
return err
}
func (d *DB) UpdateAccount(a *models.EmailAccount) error {
accessEnc, _ := d.enc.Encrypt(a.AccessToken)
imapHostEnc, _ := d.enc.Encrypt(a.IMAPHost)
smtpHostEnc, _ := d.enc.Encrypt(a.SMTPHost)
caldavEnc, _ := d.enc.Encrypt(a.CalDAVURL)
carddavEnc, _ := d.enc.Encrypt(a.CardDAVURL)
syncMode := a.SyncMode
if syncMode == "" {
syncMode = "days"
}
syncDays := a.SyncDays
if syncDays == 0 {
syncDays = 30
}
_, err := d.sql.Exec(`
UPDATE email_accounts SET
display_name=?, access_token=?,
imap_host=?, imap_port=?, smtp_host=?, smtp_port=?,
caldav_url=?, carddav_url=?,
color=?, sync_days=?, sync_mode=?
WHERE id=? AND user_id=?`,
a.DisplayName, accessEnc,
imapHostEnc, a.IMAPPort, smtpHostEnc, a.SMTPPort,
caldavEnc, carddavEnc,
a.Color, syncDays, syncMode, a.ID, a.UserID,
)
return err
}
func (d *DB) SetAccountError(accountID int64, errMsg string) {
d.sql.Exec(`UPDATE email_accounts SET last_error=? WHERE id=?`, errMsg, accountID)
}
func (d *DB) ClearAccountError(accountID int64) {
d.sql.Exec(`UPDATE email_accounts SET last_error='' WHERE id=?`, accountID)
}
// ListAllActiveAccounts returns all active accounts joined with their user's sync_interval.
func (d *DB) ListAllActiveAccounts() ([]*models.EmailAccount, error) {
rows, err := d.sql.Query(`
SELECT a.id, a.user_id, a.provider, a.email_address, a.display_name,
a.access_token, a.refresh_token, a.token_expiry,
a.imap_host, a.imap_port, a.smtp_host, a.smtp_port,
a.last_error, a.color, a.is_active, a.last_sync, a.created_at,
u.sync_interval, COALESCE(a.caldav_url,''), COALESCE(a.carddav_url,'')
FROM email_accounts a
JOIN users u ON u.id = a.user_id
WHERE a.is_active=1 AND u.is_active=1`)
if err != nil {
return nil, err
}
defer rows.Close()
var accounts []*models.EmailAccount
for rows.Next() {
a := &models.EmailAccount{}
var accessEnc, refreshEnc, imapHostEnc, smtpHostEnc, caldavEnc, carddavEnc string
var lastSync sql.NullTime
if err := rows.Scan(
&a.ID, &a.UserID, &a.Provider, &a.EmailAddress, &a.DisplayName,
&accessEnc, &refreshEnc, &a.TokenExpiry,
&imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort,
&a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt,
&a.SyncInterval, &caldavEnc, &carddavEnc,
); err != nil {
return nil, err
}
a.AccessToken, _ = d.enc.Decrypt(accessEnc)
a.RefreshToken, _ = d.enc.Decrypt(refreshEnc)
a.IMAPHost, _ = d.enc.Decrypt(imapHostEnc)
a.SMTPHost, _ = d.enc.Decrypt(smtpHostEnc)
a.CalDAVURL, _ = d.enc.Decrypt(caldavEnc)
a.CardDAVURL, _ = d.enc.Decrypt(carddavEnc)
if lastSync.Valid {
a.LastSync = lastSync.Time
}
accounts = append(accounts, a)
}
return accounts, rows.Err()
}
func (d *DB) ListAccountsByUser(userID int64) ([]*models.EmailAccount, error) {
rows, err := d.sql.Query(`
SELECT id, user_id, provider, email_address, display_name,
access_token, refresh_token, token_expiry,
imap_host, imap_port, smtp_host, smtp_port,
last_error, color, is_active, last_sync, created_at,
COALESCE(sort_order,0), COALESCE(caldav_url,''), COALESCE(carddav_url,'')
FROM email_accounts WHERE user_id=? AND is_active=1
ORDER BY COALESCE(sort_order,0), created_at`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
return d.scanAccounts(rows)
}
func (d *DB) scanAccounts(rows *sql.Rows) ([]*models.EmailAccount, error) {
var accounts []*models.EmailAccount
for rows.Next() {
a := &models.EmailAccount{}
var accessEnc, refreshEnc, imapHostEnc, smtpHostEnc, caldavEnc, carddavEnc string
var lastSync sql.NullTime
if err := rows.Scan(
&a.ID, &a.UserID, &a.Provider, &a.EmailAddress, &a.DisplayName,
&accessEnc, &refreshEnc, &a.TokenExpiry,
&imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort,
&a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt,
&a.SortOrder, &caldavEnc, &carddavEnc,
); err != nil {
return nil, err
}
a.AccessToken, _ = d.enc.Decrypt(accessEnc)
a.RefreshToken, _ = d.enc.Decrypt(refreshEnc)
a.IMAPHost, _ = d.enc.Decrypt(imapHostEnc)
a.SMTPHost, _ = d.enc.Decrypt(smtpHostEnc)
a.CalDAVURL, _ = d.enc.Decrypt(caldavEnc)
a.CardDAVURL, _ = d.enc.Decrypt(carddavEnc)
if lastSync.Valid {
a.LastSync = lastSync.Time
}
accounts = append(accounts, a)
}
return accounts, rows.Err()
}
func (d *DB) DeleteAccount(accountID, userID int64) error {
_, err := d.sql.Exec(
`DELETE FROM email_accounts WHERE id=? AND user_id=?`, accountID, userID,
)
return err
}
// UpsertOAuthAccount inserts a new OAuth account or updates tokens/display name
// if an account with the same (user_id, provider, email_address) already exists.
// Used by OAuth callbacks so that re-connecting updates rather than duplicates.
func (d *DB) UpsertOAuthAccount(a *models.EmailAccount) (created bool, err error) {
accessEnc, _ := d.enc.Encrypt(a.AccessToken)
refreshEnc, _ := d.enc.Encrypt(a.RefreshToken)
// Check for existing account with same user + provider + email
var existingID int64
row := d.sql.QueryRow(
`SELECT id FROM email_accounts WHERE user_id=? AND provider=? AND email_address=?`,
a.UserID, a.Provider, a.EmailAddress,
)
scanErr := row.Scan(&existingID)
if scanErr == sql.ErrNoRows {
// New account — insert with next sort_order
var maxOrder int
d.sql.QueryRow(`SELECT COALESCE(MAX(sort_order),0) FROM email_accounts WHERE user_id=?`, a.UserID).Scan(&maxOrder)
res, insertErr := d.sql.Exec(`
INSERT INTO email_accounts
(user_id, provider, email_address, display_name, access_token, refresh_token,
token_expiry, imap_host, imap_port, smtp_host, smtp_port, color, sort_order)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
a.UserID, a.Provider, a.EmailAddress, a.DisplayName,
accessEnc, refreshEnc, a.TokenExpiry,
"", a.IMAPPort, "", a.SMTPPort,
a.Color, maxOrder+1,
)
if insertErr != nil {
return false, insertErr
}
id, _ := res.LastInsertId()
a.ID = id
return true, nil
}
if scanErr != nil {
return false, scanErr
}
// Existing account — update tokens and display name only.
// If refresh token is empty (Microsoft omits it after first auth),
// keep the existing one to avoid losing the ability to auto-refresh.
if a.RefreshToken != "" {
_, err = d.sql.Exec(`
UPDATE email_accounts SET
display_name=?, access_token=?, refresh_token=?, token_expiry=?, last_error=''
WHERE id=?`,
a.DisplayName, accessEnc, refreshEnc, a.TokenExpiry, existingID,
)
} else {
_, err = d.sql.Exec(`
UPDATE email_accounts SET
display_name=?, access_token=?, token_expiry=?, last_error=''
WHERE id=?`,
a.DisplayName, accessEnc, a.TokenExpiry, existingID,
)
}
a.ID = existingID
return false, err
}
// UpdateAccountSortOrder sets sort_order for a batch of accounts for a user.
// accountIDs is ordered from first to last in the desired display order.
func (d *DB) UpdateAccountSortOrder(userID int64, accountIDs []int64) error {
tx, err := d.sql.Begin()
if err != nil {
return err
}
for i, id := range accountIDs {
if _, err := tx.Exec(
`UPDATE email_accounts SET sort_order=? WHERE id=? AND user_id=?`,
i, id, userID,
); err != nil {
tx.Rollback()
return err
}
}
return tx.Commit()
}
// GetUIPrefs returns the JSON ui_prefs string for a user.
func (d *DB) GetUIPrefs(userID int64) (string, error) {
var prefs string
err := d.sql.QueryRow(`SELECT COALESCE(ui_prefs,'{}') FROM users WHERE id=?`, userID).Scan(&prefs)
if err != nil {
return "{}", err
}
return prefs, nil
}
// SetUIPrefs stores the JSON ui_prefs string for a user.
func (d *DB) SetUIPrefs(userID int64, prefs string) error {
_, err := d.sql.Exec(`UPDATE users SET ui_prefs=? WHERE id=?`, prefs, userID)
return err
}
// UpdateFolderCountsDirect sets folder counts directly (used by Graph sync where
// the server provides accurate counts without needing a local recount).
func (d *DB) UpdateFolderCountsDirect(folderID int64, total, unread int) {
d.sql.Exec(`UPDATE folders SET total_count=?, unread_count=? WHERE id=?`,
total, unread, folderID)
}
// UpdateFolderCounts refreshes the unread/total counts for a folder.
func (d *DB) UpdateFolderCounts(folderID int64) {
d.sql.Exec(`
UPDATE folders SET
total_count = (SELECT COUNT(*) FROM messages WHERE folder_id=?),
unread_count = (SELECT COUNT(*) FROM messages WHERE folder_id=? AND is_read=0)
WHERE id=?`, folderID, folderID, folderID)
}
// ---- Folders ----
func (d *DB) UpsertFolder(f *models.Folder) error {
// All folders sync by default (the user can disable sync per folder afterward via
// SetFolderVisibility). ON CONFLICT deliberately never touches sync_enabled, so this
// default only applies the first time a folder is discovered.
_, err := d.sql.Exec(`
INSERT INTO folders (account_id, name, full_path, folder_type, unread_count, total_count, sync_enabled)
VALUES (?,?,?,?,?,?,1)
ON CONFLICT(account_id, full_path) DO UPDATE SET
name=excluded.name,
folder_type=excluded.folder_type,
unread_count=excluded.unread_count,
total_count=excluded.total_count`,
f.AccountID, f.Name, f.FullPath, f.FolderType, f.UnreadCount, f.TotalCount,
)
return err
}
func (d *DB) GetFolderByPath(accountID int64, fullPath string) (*models.Folder, error) {
f := &models.Folder{}
var isHidden, syncEnabled int
err := d.sql.QueryRow(
`SELECT id, account_id, name, full_path, folder_type, unread_count, total_count,
COALESCE(is_hidden,0), COALESCE(sync_enabled,1)
FROM folders WHERE account_id=? AND full_path=?`, accountID, fullPath,
).Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount, &isHidden, &syncEnabled)
f.IsHidden = isHidden == 1
f.SyncEnabled = syncEnabled == 1
if err == sql.ErrNoRows {
return nil, nil
}
return f, err
}
// GetFolderByName returns a folder matched by its display name (case-insensitive), used to
// resolve a rule's move_to_folder ActionValue (a human-typed folder name) to a real folder.
func (d *DB) GetFolderByName(accountID int64, name string) (*models.Folder, error) {
f := &models.Folder{}
var isHidden, syncEnabled int
err := d.sql.QueryRow(
`SELECT id, account_id, name, full_path, folder_type, unread_count, total_count,
COALESCE(is_hidden,0), COALESCE(sync_enabled,1)
FROM folders WHERE account_id=? AND name=? COLLATE NOCASE LIMIT 1`, accountID, name,
).Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount, &isHidden, &syncEnabled)
f.IsHidden = isHidden == 1
f.SyncEnabled = syncEnabled == 1
if err == sql.ErrNoRows {
return nil, nil
}
return f, err
}
// GetFolderByType returns the first folder of the given folder_type for an account
// (used to resolve e.g. the Junk folder for a mark_as_spam rule action).
func (d *DB) GetFolderByType(accountID int64, folderType string) (*models.Folder, error) {
f := &models.Folder{}
var isHidden, syncEnabled int
err := d.sql.QueryRow(
`SELECT id, account_id, name, full_path, folder_type, unread_count, total_count,
COALESCE(is_hidden,0), COALESCE(sync_enabled,1)
FROM folders WHERE account_id=? AND folder_type=? LIMIT 1`, accountID, folderType,
).Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount, &isHidden, &syncEnabled)
f.IsHidden = isHidden == 1
f.SyncEnabled = syncEnabled == 1
if err == sql.ErrNoRows {
return nil, nil
}
return f, err
}
func (d *DB) ListFoldersByAccount(accountID int64) ([]*models.Folder, error) {
rows, err := d.sql.Query(
`SELECT id, account_id, name, full_path, folder_type, unread_count, total_count,
COALESCE(is_hidden,0), COALESCE(sync_enabled,1)
FROM folders WHERE account_id=? ORDER BY folder_type, name`, accountID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var folders []*models.Folder
for rows.Next() {
f := &models.Folder{}
var isHidden, syncEnabled int
if err := rows.Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount, &isHidden, &syncEnabled); err != nil {
return nil, err
}
f.IsHidden = isHidden == 1
f.SyncEnabled = syncEnabled == 1
folders = append(folders, f)
}
return folders, rows.Err()
}
// ---- Labels ----
func (d *DB) ListLabels(userID int64) ([]models.Label, error) {
rows, err := d.sql.Query(`SELECT id, user_id, name, color FROM labels WHERE user_id=? ORDER BY name COLLATE NOCASE`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var labels []models.Label
for rows.Next() {
var l models.Label
if err := rows.Scan(&l.ID, &l.UserID, &l.Name, &l.Color); err != nil {
return nil, err
}
labels = append(labels, l)
}
return labels, rows.Err()
}
func (d *DB) CreateLabel(userID int64, name, color string) (*models.Label, error) {
res, err := d.sql.Exec(`INSERT INTO labels (user_id, name, color) VALUES (?,?,?)`, userID, name, color)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return nil, fmt.Errorf("a label named %q already exists", name)
}
return nil, err
}
id, _ := res.LastInsertId()
return &models.Label{ID: id, UserID: userID, Name: name, Color: color}, nil
}
func (d *DB) UpdateLabel(labelID, userID int64, name, color string) error {
_, err := d.sql.Exec(`UPDATE labels SET name=?, color=? WHERE id=? AND user_id=?`, name, color, labelID, userID)
if err != nil && strings.Contains(err.Error(), "UNIQUE") {
return fmt.Errorf("a label named %q already exists", name)
}
return err
}
func (d *DB) DeleteLabel(labelID, userID int64) error {
_, err := d.sql.Exec(`DELETE FROM labels WHERE id=? AND user_id=?`, labelID, userID)
return err
}
// AssignLabel attaches a label to a message. Both are scoped to userID so a user can't
// label another user's message or use another user's label.
func (d *DB) AssignLabel(messageID, labelID, userID int64) error {
_, err := d.sql.Exec(`
INSERT OR IGNORE INTO message_labels (message_id, label_id)
SELECT m.id, l.id FROM messages m, labels l
WHERE m.id=? AND l.id=?
AND m.account_id IN (SELECT id FROM email_accounts WHERE user_id=?)
AND l.user_id=?`,
messageID, labelID, userID, userID,
)
return err
}
func (d *DB) UnassignLabel(messageID, labelID, userID int64) error {
_, err := d.sql.Exec(`
DELETE FROM message_labels WHERE message_id=? AND label_id=?
AND message_id IN (SELECT m.id FROM messages m WHERE m.account_id IN (SELECT id FROM email_accounts WHERE user_id=?))`,
messageID, labelID, userID,
)
return err
}
// attachLabels batch-loads labels for a page of message summaries (one query instead of
// one per row) and fills in each summary's Labels field in place.
func (d *DB) attachLabels(msgs []models.MessageSummary) {
if len(msgs) == 0 {
return
}
idIdx := make(map[int64]int, len(msgs))
args := make([]interface{}, len(msgs))
for i, m := range msgs {
idIdx[m.ID] = i
args[i] = m.ID
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(args)), ",")
rows, err := d.sql.Query(`
SELECT ml.message_id, l.id, l.user_id, l.name, l.color
FROM message_labels ml JOIN labels l ON l.id = ml.label_id
WHERE ml.message_id IN (`+placeholders+`)`, args...)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var mid int64
var l models.Label
if rows.Scan(&mid, &l.ID, &l.UserID, &l.Name, &l.Color) == nil {
if i, ok := idIdx[mid]; ok {
msgs[i].Labels = append(msgs[i].Labels, l)
}
}
}
}
// ListMessagesByLabel returns all messages (across every account) tagged with labelID,
// newest first — a virtual folder, same pattern as ListStarredMessages.
func (d *DB) ListMessagesByLabel(labelID, userID int64, page, pageSize int) (*models.PagedMessages, error) {
offset := (page - 1) * pageSize
var total int
d.sql.QueryRow(`
SELECT COUNT(*) FROM message_labels ml
JOIN messages m ON m.id = ml.message_id
JOIN email_accounts a ON a.id = m.account_id
WHERE ml.label_id=? AND a.user_id=?`, labelID, userID).Scan(&total)
rows, err := d.sql.Query(`
SELECT m.id, m.account_id, a.email_address, a.color, m.folder_id, f.name,
m.subject, m.from_name, m.from_email, m.body_text,
m.date, m.is_read, m.is_starred, m.has_attachment
FROM message_labels ml
JOIN messages m ON m.id = ml.message_id
JOIN email_accounts a ON a.id = m.account_id
JOIN folders f ON f.id = m.folder_id
WHERE ml.label_id=? AND a.user_id=?
ORDER BY m.date DESC
LIMIT ? OFFSET ?`, labelID, userID, pageSize, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var summaries []models.MessageSummary
for rows.Next() {
s := models.MessageSummary{}
var subjectEnc, fromNameEnc, fromEmailEnc, bodyTextEnc string
if err := rows.Scan(
&s.ID, &s.AccountID, &s.AccountEmail, &s.AccountColor, &s.FolderID, &s.FolderName,
&subjectEnc, &fromNameEnc, &fromEmailEnc, &bodyTextEnc,
&s.Date, &s.IsRead, &s.IsStarred, &s.HasAttachment,
); err != nil {
return nil, err
}
s.Subject, _ = d.enc.Decrypt(subjectEnc)
s.FromName, _ = d.enc.Decrypt(fromNameEnc)
s.FromEmail, _ = d.enc.Decrypt(fromEmailEnc)
bodyText, _ := d.enc.Decrypt(bodyTextEnc)
if len(bodyText) > 120 {
bodyText = bodyText[:120] + "…"
}
s.Preview = bodyText
summaries = append(summaries, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
d.attachLabels(summaries)
return &models.PagedMessages{
Messages: summaries,
Total: total,
Page: page,
PageSize: pageSize,
HasMore: offset+len(summaries) < total,
}, nil
}
// ---- Messages ----
func (d *DB) UpsertMessage(m *models.Message) error {
subjectEnc, _ := d.enc.Encrypt(m.Subject)
fromNameEnc, _ := d.enc.Encrypt(m.FromName)
fromEmailEnc, _ := d.enc.Encrypt(m.FromEmail)
toEnc, _ := d.enc.Encrypt(m.ToList)
ccEnc, _ := d.enc.Encrypt(m.CCList)
bccEnc, _ := d.enc.Encrypt(m.BCCList)
replyToEnc, _ := d.enc.Encrypt(m.ReplyTo)
bodyTextEnc, _ := d.enc.Encrypt(m.BodyText)
bodyHTMLEnc, _ := d.enc.Encrypt(m.BodyHTML)
// Build plaintext search indexes: combined (subject+from+preview, for the default/all
// scope), plus subject-only and body-only so search can be scoped to just one of them.
preview := m.BodyText
if len(preview) > 200 {
preview = preview[:200]
}
searchText := strings.ToLower(m.Subject + " " + m.FromName + " " + m.FromEmail + " " + preview)
searchSubject := strings.ToLower(m.Subject)
bodyForSearch := m.BodyText
if len(bodyForSearch) > 2000 {
bodyForSearch = bodyForSearch[:2000]
}
searchBody := strings.ToLower(bodyForSearch)
res, err := d.sql.Exec(`
INSERT INTO messages
(account_id, folder_id, remote_uid, thread_id, message_id,
subject, from_name, from_email, to_list, cc_list, bcc_list, reply_to,
body_text, body_html, date, is_read, is_starred, is_draft, has_attachment,
search_text, search_subject, search_body)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(account_id, folder_id, remote_uid) DO UPDATE SET
is_read=excluded.is_read,
is_starred=excluded.is_starred,
has_attachment=excluded.has_attachment,
search_text=excluded.search_text,
search_subject=excluded.search_subject,
search_body=excluded.search_body`,
m.AccountID, m.FolderID, m.RemoteUID, m.ThreadID, m.MessageID,
subjectEnc, fromNameEnc, fromEmailEnc, toEnc, ccEnc, bccEnc, replyToEnc,
bodyTextEnc, bodyHTMLEnc, m.Date,
m.IsRead, m.IsStarred, m.IsDraft, m.HasAttachment,
searchText, searchSubject, searchBody,
)
if err != nil {
return err
}
// LastInsertId returns 0 on conflict in SQLite — always look up the real ID.
id, _ := res.LastInsertId()
if id == 0 {
d.sql.QueryRow(
`SELECT id FROM messages WHERE account_id=? AND folder_id=? AND remote_uid=?`,
m.AccountID, m.FolderID, m.RemoteUID,
).Scan(&id)
}
if m.ID == 0 {
m.ID = id
}
return nil
}
func (d *DB) GetMessage(messageID, userID int64) (*models.Message, error) {
m := &models.Message{}
var subjectEnc, fromNameEnc, fromEmailEnc, toEnc, ccEnc, bccEnc, replyToEnc, bodyTextEnc, bodyHTMLEnc string
err := d.sql.QueryRow(`
SELECT m.id, m.account_id, m.folder_id, m.remote_uid, m.thread_id, m.message_id,
m.subject, m.from_name, m.from_email, m.to_list, m.cc_list, m.bcc_list,
m.reply_to, m.body_text, m.body_html,
m.date, m.is_read, m.is_starred, m.is_draft, m.has_attachment, m.created_at
FROM messages m
JOIN email_accounts a ON a.id = m.account_id
WHERE m.id=? AND a.user_id=?`, messageID, userID,
).Scan(
&m.ID, &m.AccountID, &m.FolderID, &m.RemoteUID, &m.ThreadID, &m.MessageID,
&subjectEnc, &fromNameEnc, &fromEmailEnc, &toEnc, &ccEnc, &bccEnc,
&replyToEnc, &bodyTextEnc, &bodyHTMLEnc,
&m.Date, &m.IsRead, &m.IsStarred, &m.IsDraft, &m.HasAttachment, &m.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
m.Subject, _ = d.enc.Decrypt(subjectEnc)
m.FromName, _ = d.enc.Decrypt(fromNameEnc)
m.FromEmail, _ = d.enc.Decrypt(fromEmailEnc)
m.ToList, _ = d.enc.Decrypt(toEnc)
m.CCList, _ = d.enc.Decrypt(ccEnc)
m.BCCList, _ = d.enc.Decrypt(bccEnc)
m.ReplyTo, _ = d.enc.Decrypt(replyToEnc)
m.BodyText, _ = d.enc.Decrypt(bodyTextEnc)
m.BodyHTML, _ = d.enc.Decrypt(bodyHTMLEnc)
// Load attachment metadata
if m.HasAttachment {
atts, _ := d.GetAttachmentsByMessage(m.ID, userID)
m.Attachments = atts
}
summary := []models.MessageSummary{{ID: m.ID}}
d.attachLabels(summary)
m.Labels = summary[0].Labels
return m, nil
}
func (d *DB) ListMessages(userID int64, folderIDs []int64, accountID int64, page, pageSize int) (*models.PagedMessages, error) {
offset := (page - 1) * pageSize
args := []interface{}{userID}
where := "a.user_id=? AND (m.snoozed_until IS NULL OR m.snoozed_until <= datetime('now'))"
if accountID > 0 {
where += " AND m.account_id=?"
args = append(args, accountID)
}
if len(folderIDs) > 0 {
placeholders := make([]string, len(folderIDs))
for i, fid := range folderIDs {
placeholders[i] = "?"
args = append(args, fid)
}
where += " AND m.folder_id IN (" + strings.Join(placeholders, ",") + ")"
}
countArgs := make([]interface{}, len(args))
copy(countArgs, args)
var total int
d.sql.QueryRow("SELECT COUNT(*) FROM messages m JOIN email_accounts a ON a.id=m.account_id WHERE "+where, countArgs...).Scan(&total)
args = append(args, pageSize, offset)
rows, err := d.sql.Query(`
SELECT m.id, m.account_id, a.email_address, a.display_name, a.color, m.folder_id, f.name,
m.subject, m.from_name, m.from_email, m.to_list, m.body_text,
m.date, m.is_read, m.is_starred, m.has_attachment
FROM messages m
JOIN email_accounts a ON a.id = m.account_id
JOIN folders f ON f.id = m.folder_id
WHERE `+where+`
ORDER BY m.date DESC
LIMIT ? OFFSET ?`, args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
var summaries []models.MessageSummary
for rows.Next() {
s := models.MessageSummary{}
var subjectEnc, fromNameEnc, fromEmailEnc, toListEnc, bodyTextEnc string
if err := rows.Scan(
&s.ID, &s.AccountID, &s.AccountEmail, &s.AccountName, &s.AccountColor, &s.FolderID, &s.FolderName,
&subjectEnc, &fromNameEnc, &fromEmailEnc, &toListEnc, &bodyTextEnc,
&s.Date, &s.IsRead, &s.IsStarred, &s.HasAttachment,
); err != nil {
return nil, err
}
s.Subject, _ = d.enc.Decrypt(subjectEnc)
s.FromName, _ = d.enc.Decrypt(fromNameEnc)
s.FromEmail, _ = d.enc.Decrypt(fromEmailEnc)
s.ToList, _ = d.enc.Decrypt(toListEnc)
bodyText, _ := d.enc.Decrypt(bodyTextEnc)
if len(bodyText) > 120 {
bodyText = bodyText[:120] + "…"
}
s.Preview = bodyText
summaries = append(summaries, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
d.attachLabels(summaries)
return &models.PagedMessages{
Messages: summaries,
Total: total,
Page: page,
PageSize: pageSize,
HasMore: offset+len(summaries) < total,
}, nil
}
// SearchFilters narrows a SearchMessages query beyond the plain text match.
// All fields are optional (zero value = "no filter").
type SearchFilters struct {
Scope string // "" / "all" | "subject" | "body" | "subject_body"
HasAttachment *bool
DateFrom string // "YYYY-MM-DD", inclusive
DateTo string // "YYYY-MM-DD", inclusive
MinSizeKB *int
MaxSizeKB *int
AccountID *int64 // narrow to one connected mailbox instead of all of them
FolderID *int64 // narrow to one folder within a mailbox
}
// approxSizeExpr estimates a message's size from ciphertext length (a stand-in for
// plaintext length — AES output is close enough in size for filtering purposes)
// plus its attachments' real sizes. There's no stored message size, so this avoids
// a schema/sync change just to support a size filter.
const approxSizeExpr = `(LENGTH(m.body_text)+LENGTH(m.body_html)+COALESCE((SELECT SUM(a.size) FROM attachments a WHERE a.message_id=m.id),0))`
func (d *DB) SearchMessages(userID int64, q string, filters SearchFilters, page, pageSize int) (*models.PagedMessages, error) {
offset := (page - 1) * pageSize
like := "%" + strings.ToLower(q) + "%"
where := []string{"a.user_id=?"}
args := []interface{}{userID}
switch filters.Scope {
case "subject":
where = append(where, "m.search_subject LIKE ?")
args = append(args, like)
case "body":
where = append(where, "m.search_body LIKE ?")
args = append(args, like)
case "subject_body":
where = append(where, "(m.search_subject LIKE ? OR m.search_body LIKE ?)")
args = append(args, like, like)
default:
where = append(where, "m.search_text LIKE ?")
args = append(args, like)
}
if filters.HasAttachment != nil {
v := 0
if *filters.HasAttachment {
v = 1
}
where = append(where, "m.has_attachment=?")
args = append(args, v)
}
if filters.DateFrom != "" {
where = append(where, "m.date >= ?")
args = append(args, filters.DateFrom)
}
if filters.DateTo != "" {
where = append(where, "m.date <= ?")
args = append(args, filters.DateTo+" 23:59:59")
}
if filters.MinSizeKB != nil {
where = append(where, approxSizeExpr+" >= ?")
args = append(args, *filters.MinSizeKB*1024)
}
if filters.MaxSizeKB != nil {
where = append(where, approxSizeExpr+" <= ?")
args = append(args, *filters.MaxSizeKB*1024)
}
if filters.AccountID != nil {
where = append(where, "m.account_id=?")
args = append(args, *filters.AccountID)
}
if filters.FolderID != nil {
where = append(where, "m.folder_id=?")
args = append(args, *filters.FolderID)
}
whereClause := strings.Join(where, " AND ")
var total int
d.sql.QueryRow(`
SELECT COUNT(*) FROM messages m
JOIN email_accounts a ON a.id=m.account_id
WHERE `+whereClause, args...,
).Scan(&total)
qArgs := append(append([]interface{}{}, args...), pageSize, offset)
rows, err := d.sql.Query(`
SELECT m.id, m.account_id, a.email_address, a.display_name, a.color, m.folder_id, f.name,
m.subject, m.from_name, m.from_email, m.to_list, m.body_text,
m.date, m.is_read, m.is_starred, m.has_attachment, `+approxSizeExpr+`
FROM messages m
JOIN email_accounts a ON a.id=m.account_id
JOIN folders f ON f.id=m.folder_id
WHERE `+whereClause+`
ORDER BY m.date DESC LIMIT ? OFFSET ?`, qArgs...,
)
if err != nil {
return nil, err
}
defer rows.Close()
var summaries []models.MessageSummary
for rows.Next() {
s := models.MessageSummary{}
var subjectEnc, fromNameEnc, fromEmailEnc, toListEnc, bodyTextEnc string
if err := rows.Scan(
&s.ID, &s.AccountID, &s.AccountEmail, &s.AccountName, &s.AccountColor, &s.FolderID, &s.FolderName,
&subjectEnc, &fromNameEnc, &fromEmailEnc, &toListEnc, &bodyTextEnc,
&s.Date, &s.IsRead, &s.IsStarred, &s.HasAttachment, &s.Size,
); err != nil {
return nil, err
}
s.Subject, _ = d.enc.Decrypt(subjectEnc)
s.FromName, _ = d.enc.Decrypt(fromNameEnc)
s.FromEmail, _ = d.enc.Decrypt(fromEmailEnc)
s.ToList, _ = d.enc.Decrypt(toListEnc)
bodyText, _ := d.enc.Decrypt(bodyTextEnc)
if len(bodyText) > 120 {
bodyText = bodyText[:120] + "…"
}
s.Preview = bodyText
summaries = append(summaries, s)
}
d.attachLabels(summaries)
return &models.PagedMessages{
Messages: summaries, Total: total, Page: page, PageSize: pageSize,
HasMore: offset+len(summaries) < total,
}, rows.Err()
}
func (d *DB) MarkMessageRead(messageID, userID int64, read bool) error {
val := 0
if read {
val = 1
}
_, err := d.sql.Exec(`
UPDATE messages SET is_read=?
WHERE id=? AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`,
val, messageID, userID,
)
return err
}
// UpdateMessageBody persists body text/html for a message (used by Graph lazy fetch).
func (d *DB) UpdateMessageBody(messageID int64, bodyText, bodyHTML string) {
bodyTextEnc, _ := d.enc.Encrypt(bodyText)
bodyHTMLEnc, _ := d.enc.Encrypt(bodyHTML)
d.sql.Exec(`UPDATE messages SET body_text=?, body_html=? WHERE id=?`,
bodyTextEnc, bodyHTMLEnc, messageID)
}
// GetNewestMessageDate returns the date of the most recent message in a folder.
// Returns zero time if the folder is empty.
func (d *DB) GetNewestMessageDate(folderID int64) time.Time {
var t time.Time
d.sql.QueryRow(`SELECT MAX(date) FROM messages WHERE folder_id=?`, folderID).Scan(&t)
return t
}
func (d *DB) ToggleMessageStar(messageID, userID int64) (bool, error) {
var current bool
err := d.sql.QueryRow(`
SELECT is_starred FROM messages
WHERE id=? AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`,
messageID, userID,
).Scan(&current)
if err != nil {
return false, err
}
newVal := !current
intVal := 0
if newVal {
intVal = 1
}
_, err = d.sql.Exec(`UPDATE messages SET is_starred=? WHERE id=?`, intVal, messageID)
return newVal, err
}
func (d *DB) MoveMessage(messageID, userID, folderID int64) error {
_, err := d.sql.Exec(`
UPDATE messages SET folder_id=?
WHERE id=? AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`,
folderID, messageID, userID,
)
return err
}
func (d *DB) DeleteMessage(messageID, userID int64) error {
_, err := d.sql.Exec(`
DELETE FROM messages WHERE id=?
AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`,
messageID, userID,
)
return err
}
func (d *DB) GetFoldersByUser(userID int64) ([]*models.Folder, error) {
rows, err := d.sql.Query(`
SELECT f.id, f.account_id, f.name, f.full_path, f.folder_type, f.unread_count, f.total_count,
COALESCE(f.is_hidden,0), COALESCE(f.sync_enabled,1)
FROM folders f
JOIN email_accounts a ON a.id=f.account_id
WHERE a.user_id=?
ORDER BY a.created_at, f.folder_type, f.name`, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var folders []*models.Folder
for rows.Next() {
f := &models.Folder{}
var isHidden, syncEnabled int
if err := rows.Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount, &isHidden, &syncEnabled); err != nil {
return nil, err
}
f.IsHidden = isHidden == 1
f.SyncEnabled = syncEnabled == 1
folders = append(folders, f)
}
return folders, rows.Err()
}
// ---- Remote Content Whitelist ----
func (d *DB) GetRemoteContentWhitelist(userID int64) ([]string, error) {
rows, err := d.sql.Query(
`SELECT sender FROM remote_content_whitelist WHERE user_id=? ORDER BY sender`,
userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var list []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err == nil {
list = append(list, s)
}
}
return list, rows.Err()
}
func (d *DB) AddRemoteContentWhitelist(userID int64, sender string) error {
_, err := d.sql.Exec(
`INSERT OR IGNORE INTO remote_content_whitelist (user_id, sender) VALUES (?, ?)`,
userID, sender,
)
return err
}
func (d *DB) DeleteRemoteContentWhitelist(userID int64, sender string) error {
_, err := d.sql.Exec(
`DELETE FROM remote_content_whitelist WHERE user_id=? AND sender=?`,
userID, sender,
)
return err
}
func (d *DB) IsRemoteContentAllowed(userID int64, sender string) (bool, error) {
var count int
err := d.sql.QueryRow(
`SELECT COUNT(*) FROM remote_content_whitelist WHERE user_id=? AND sender=?`,
userID, sender,
).Scan(&count)
return count > 0, err
}
// ---- Spam Blocklist (Settings > Security > Spam Block) ----
// A blocked sender is enforced at sync time (see syncer.IsSpamBlocked call sites): any new
// message from a blocked address gets moved to the account's Spam folder automatically,
// the same way the Rules engine's mark_as_spam action does — this is a separate, purpose-
// built list rather than a generic Rule so it gets its own simple add/remove UI.
func (d *DB) ListSpamBlock(userID int64) ([]models.SpamBlockEntry, error) {
rows, err := d.sql.Query(
`SELECT sender, created_at FROM spam_blocklist WHERE user_id=? ORDER BY created_at DESC`,
userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var list []models.SpamBlockEntry
for rows.Next() {
var e models.SpamBlockEntry
if err := rows.Scan(&e.Sender, &e.CreatedAt); err == nil {
list = append(list, e)
}
}
return list, rows.Err()
}
func (d *DB) AddSpamBlock(userID int64, sender string) error {
_, err := d.sql.Exec(
`INSERT OR IGNORE INTO spam_blocklist (user_id, sender) VALUES (?, ?)`,
userID, sender,
)
return err
}
func (d *DB) DeleteSpamBlock(userID int64, sender string) error {
_, err := d.sql.Exec(
`DELETE FROM spam_blocklist WHERE user_id=? AND sender=?`,
userID, sender,
)
return err
}
// IsSpamBlocked reports whether sender is on userID's spam blocklist. Errors are treated as
// "not blocked" (fail open) since this gates an automatic mail-moving side effect during
// sync — a transient DB hiccup shouldn't misfile someone's legitimate mail.
// IsSpamBlocked reports whether sender matches userID's spam blocklist — either an exact
// blocked email address, or (for a blocklist entry with no "@", i.e. a bare domain like
// "example.com") the sender's address being @ that domain or any subdomain of it.
// Errors are treated as "not blocked" (fail open) since this gates an automatic mail-moving
// side effect during sync — a transient DB hiccup shouldn't misfile someone's legitimate mail.
func (d *DB) IsSpamBlocked(userID int64, sender string) bool {
if sender == "" {
return false
}
sender = strings.ToLower(strings.TrimSpace(sender))
at := strings.LastIndex(sender, "@")
if at < 0 {
return false
}
senderDomain := sender[at+1:]
rows, err := d.sql.Query(`SELECT sender FROM spam_blocklist WHERE user_id=?`, userID)
if err != nil {
return false
}
defer rows.Close()
for rows.Next() {
var pattern string
if err := rows.Scan(&pattern); err != nil {
continue
}
pattern = strings.ToLower(pattern)
if strings.Contains(pattern, "@") {
if pattern == sender {
return true
}
continue
}
if senderDomain == pattern || strings.HasSuffix(senderDomain, "."+pattern) {
return true
}
}
return false
}
// SetFolderVisibility sets is_hidden and sync_enabled for a folder owned by the user.
func (d *DB) SetFolderVisibility(folderID, userID int64, isHidden, syncEnabled bool) error {
ih, se := 0, 0
if isHidden {
ih = 1
}
if syncEnabled {
se = 1
}
_, err := d.sql.Exec(`
UPDATE folders SET is_hidden=?, sync_enabled=?
WHERE id=? AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`,
ih, se, folderID, userID)
return err
}
// CountFolderMessages returns how many messages are in a folder (owned by user).
func (d *DB) CountFolderMessages(folderID, userID int64) (int, error) {
var count int
err := d.sql.QueryRow(`
SELECT COUNT(*) FROM messages m
JOIN folders f ON f.id=m.folder_id
JOIN email_accounts a ON a.id=f.account_id
WHERE m.folder_id=? AND a.user_id=?`, folderID, userID).Scan(&count)
return count, err
}
// DeleteFolder removes a folder and all its messages (cascade).
func (d *DB) DeleteFolder(folderID, userID int64) error {
_, err := d.sql.Exec(`
DELETE FROM folders WHERE id=?
AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`,
folderID, userID)
return err
}
// MoveFolderContents moves all messages from one folder to another (both must belong to user).
func (d *DB) MoveFolderContents(fromID, toID, userID int64) (int64, error) {
res, err := d.sql.Exec(`
UPDATE messages SET folder_id=?
WHERE folder_id=?
AND folder_id IN (SELECT f.id FROM folders f JOIN email_accounts a ON a.id=f.account_id WHERE a.user_id=?)`,
toID, fromID, userID)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return n, nil
}
func (d *DB) GetFolderByID(folderID int64) (*models.Folder, error) {
f := &models.Folder{}
var isHidden, syncEnabled int
err := d.sql.QueryRow(
`SELECT id, account_id, name, full_path, folder_type, unread_count, total_count,
COALESCE(is_hidden,0), COALESCE(sync_enabled,1)
FROM folders WHERE id=?`, folderID,
).Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount, &isHidden, &syncEnabled)
f.IsHidden = isHidden == 1
f.SyncEnabled = syncEnabled == 1
if err == sql.ErrNoRows {
return nil, nil
}
return f, err
}
// GetMessageIMAPInfo returns the remote_uid, folder full_path, account info needed for IMAP ops.
func (d *DB) GetMessageIMAPInfo(messageID, userID int64) (remoteUID uint32, folderPath string, account *models.EmailAccount, err error) {
var uidStr string
var accountID int64
var folderID int64
err = d.sql.QueryRow(`
SELECT m.remote_uid, m.account_id, m.folder_id
FROM messages m
JOIN email_accounts a ON a.id = m.account_id
WHERE m.id=? AND a.user_id=?`, messageID, userID,
).Scan(&uidStr, &accountID, &folderID)
if err != nil {
return 0, "", nil, err
}
// Parse uid
var uid uint64
fmt.Sscanf(uidStr, "%d", &uid)
remoteUID = uint32(uid)
folder, err := d.GetFolderByID(folderID)
if err != nil || folder == nil {
return remoteUID, "", nil, fmt.Errorf("folder not found")
}
account, err = d.GetAccount(accountID)
return remoteUID, folder.FullPath, account, err
}
// GetMessageFolderID returns the local folder id a message currently belongs to — used to
// recompute that folder's sidebar count immediately after deleting the message, instead of
// leaving it stale until the next background sync happens to run.
func (d *DB) GetMessageFolderID(messageID, userID int64) (int64, error) {
var folderID int64
err := d.sql.QueryRow(`
SELECT m.folder_id FROM messages m
JOIN email_accounts a ON a.id = m.account_id
WHERE m.id=? AND a.user_id=?`, messageID, userID,
).Scan(&folderID)
return folderID, err
}
// GetMessageGraphInfo returns the Graph message ID (remote_uid as string), folder ID string,
// and account for a Graph-backed message. Used by handlers for outlook_personal accounts.
func (d *DB) GetMessageGraphInfo(messageID, userID int64) (graphMsgID string, folderGraphID string, account *models.EmailAccount, err error) {
var accountID int64
var folderID int64
err = d.sql.QueryRow(`
SELECT m.remote_uid, m.account_id, m.folder_id
FROM messages m
JOIN email_accounts a ON a.id = m.account_id
WHERE m.id=? AND a.user_id=?`, messageID, userID,
).Scan(&graphMsgID, &accountID, &folderID)
if err != nil {
return "", "", nil, err
}
folder, err := d.GetFolderByID(folderID)
if err != nil || folder == nil {
return graphMsgID, "", nil, fmt.Errorf("folder not found")
}
account, err = d.GetAccount(accountID)
return graphMsgID, folder.FullPath, account, err
}
// ListStarredMessages returns all starred messages for a user, newest first.
func (d *DB) ListStarredMessages(userID int64, page, pageSize int) (*models.PagedMessages, error) {
offset := (page - 1) * pageSize
var total int
d.sql.QueryRow(`SELECT COUNT(*) FROM messages m JOIN email_accounts a ON a.id=m.account_id WHERE a.user_id=? AND m.is_starred=1`, userID).Scan(&total)
rows, err := d.sql.Query(`
SELECT m.id, m.account_id, a.email_address, a.color, m.folder_id, f.name,
m.subject, m.from_name, m.from_email, m.body_text,
m.date, m.is_read, m.is_starred, m.has_attachment
FROM messages m
JOIN email_accounts a ON a.id = m.account_id
JOIN folders f ON f.id = m.folder_id
WHERE a.user_id=? AND m.is_starred=1
ORDER BY m.date DESC
LIMIT ? OFFSET ?`, userID, pageSize, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var summaries []models.MessageSummary
for rows.Next() {
s := models.MessageSummary{}
var subjectEnc, fromNameEnc, fromEmailEnc, bodyTextEnc string
if err := rows.Scan(
&s.ID, &s.AccountID, &s.AccountEmail, &s.AccountColor, &s.FolderID, &s.FolderName,
&subjectEnc, &fromNameEnc, &fromEmailEnc, &bodyTextEnc,
&s.Date, &s.IsRead, &s.IsStarred, &s.HasAttachment,
); err != nil {
return nil, err
}
s.Subject, _ = d.enc.Decrypt(subjectEnc)
s.FromName, _ = d.enc.Decrypt(fromNameEnc)
s.FromEmail, _ = d.enc.Decrypt(fromEmailEnc)
bodyText, _ := d.enc.Decrypt(bodyTextEnc)
if len(bodyText) > 120 {
bodyText = bodyText[:120] + "…"
}
s.Preview = bodyText
summaries = append(summaries, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
d.attachLabels(summaries)
return &models.PagedMessages{
Messages: summaries,
Total: total,
Page: page,
PageSize: pageSize,
HasMore: offset+len(summaries) < total,
}, nil
}
// ---- Snooze ----
// SnoozeMessage hides a message from normal folder views until `until`, scoped to accounts
// owned by userID.
func (d *DB) SnoozeMessage(id, userID int64, until time.Time) error {
_, err := d.sql.Exec(`
UPDATE messages SET snoozed_until=?
WHERE id=? AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`,
until.UTC().Format("2006-01-02 15:04:05"), id, userID)
return err
}
// UnsnoozeMessage clears a message's snooze early, scoped to accounts owned by userID.
func (d *DB) UnsnoozeMessage(id, userID int64) error {
_, err := d.sql.Exec(`
UPDATE messages SET snoozed_until=NULL
WHERE id=? AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`,
id, userID)
return err
}
// ListSnoozedMessages returns messages currently snoozed (snoozed_until in the future),
// soonest-to-wake first.
func (d *DB) ListSnoozedMessages(userID int64, page, pageSize int) (*models.PagedMessages, error) {
offset := (page - 1) * pageSize
const where = "a.user_id=? AND m.snoozed_until IS NOT NULL AND m.snoozed_until > datetime('now')"
var total int
d.sql.QueryRow(`SELECT COUNT(*) FROM messages m JOIN email_accounts a ON a.id=m.account_id WHERE `+where, userID).Scan(&total)
rows, err := d.sql.Query(`
SELECT m.id, m.account_id, a.email_address, a.color, m.folder_id, f.name,
m.subject, m.from_name, m.from_email, m.body_text,
m.date, m.is_read, m.is_starred, m.has_attachment, m.snoozed_until
FROM messages m
JOIN email_accounts a ON a.id = m.account_id
JOIN folders f ON f.id = m.folder_id
WHERE `+where+`
ORDER BY m.snoozed_until ASC
LIMIT ? OFFSET ?`, userID, pageSize, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var summaries []models.MessageSummary
for rows.Next() {
s := models.MessageSummary{}
var subjectEnc, fromNameEnc, fromEmailEnc, bodyTextEnc string
var snoozedUntil sql.NullTime
if err := rows.Scan(
&s.ID, &s.AccountID, &s.AccountEmail, &s.AccountColor, &s.FolderID, &s.FolderName,
&subjectEnc, &fromNameEnc, &fromEmailEnc, &bodyTextEnc,
&s.Date, &s.IsRead, &s.IsStarred, &s.HasAttachment, &snoozedUntil,
); err != nil {
return nil, err
}
s.Subject, _ = d.enc.Decrypt(subjectEnc)
s.FromName, _ = d.enc.Decrypt(fromNameEnc)
s.FromEmail, _ = d.enc.Decrypt(fromEmailEnc)
bodyText, _ := d.enc.Decrypt(bodyTextEnc)
if len(bodyText) > 120 {
bodyText = bodyText[:120] + "…"
}
s.Preview = bodyText
if snoozedUntil.Valid {
s.SnoozedUntil = &snoozedUntil.Time
}
summaries = append(summaries, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
d.attachLabels(summaries)
return &models.PagedMessages{
Messages: summaries,
Total: total,
Page: page,
PageSize: pageSize,
HasMore: offset+len(summaries) < total,
}, nil
}
// WakeExpiredSnoozes clears snoozed_until on every message whose snooze has passed, marks
// them unread (the conventional "snooze brought it back" signal), and returns the set of
// folder IDs that need their unread counts recomputed. Called by the background sweep.
func (d *DB) WakeExpiredSnoozes() ([]int64, error) {
rows, err := d.sql.Query(`SELECT DISTINCT folder_id FROM messages WHERE snoozed_until IS NOT NULL AND snoozed_until <= datetime('now')`)
if err != nil {
return nil, err
}
var folderIDs []int64
for rows.Next() {
var fid int64
if err := rows.Scan(&fid); err == nil {
folderIDs = append(folderIDs, fid)
}
}
rows.Close()
if len(folderIDs) == 0 {
return nil, nil
}
if _, err := d.sql.Exec(`UPDATE messages SET snoozed_until=NULL, is_read=0 WHERE snoozed_until IS NOT NULL AND snoozed_until <= datetime('now')`); err != nil {
return nil, err
}
return folderIDs, nil
}
// ---- Send-later (scheduled sends) ----
// CreateScheduledSend stores a fully-composed message to be sent at s.SendAt by the
// background sweep. Returns the new row's ID.
func (d *DB) CreateScheduledSend(s *models.ScheduledSend) (int64, error) {
toJSON, _ := json.Marshal(s.To)
ccJSON, _ := json.Marshal(s.CC)
bccJSON, _ := json.Marshal(s.BCC)
fwdJSON, _ := json.Marshal(s.ForwardFromIDs)
subjectEnc, err := d.enc.Encrypt(s.Subject)
if err != nil {
return 0, err
}
bodyHTMLEnc, err := d.enc.Encrypt(s.BodyHTML)
if err != nil {
return 0, err
}
bodyTextEnc, err := d.enc.Encrypt(s.BodyText)
if err != nil {
return 0, err
}
res, err := d.sql.Exec(`
INSERT INTO scheduled_sends (user_id, account_id, to_list, cc_list, bcc_list, subject, body_html, body_text, forward_from_ids, send_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
s.UserID, s.AccountID, string(toJSON), string(ccJSON), string(bccJSON),
subjectEnc, bodyHTMLEnc, bodyTextEnc, string(fwdJSON),
s.SendAt.UTC().Format("2006-01-02 15:04:05"),
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// ListScheduledSends returns userID's pending scheduled sends, soonest first.
func (d *DB) ListScheduledSends(userID int64) ([]*models.ScheduledSend, error) {
rows, err := d.sql.Query(`
SELECT id, user_id, account_id, to_list, cc_list, bcc_list, subject, body_html, body_text, forward_from_ids, send_at, created_at
FROM scheduled_sends WHERE user_id=? ORDER BY send_at ASC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
return d.scanScheduledSends(rows)
}
// ListDueScheduledSends returns every scheduled send (across all users) whose send_at has
// passed. Called by the background sweep.
func (d *DB) ListDueScheduledSends() ([]*models.ScheduledSend, error) {
rows, err := d.sql.Query(`
SELECT id, user_id, account_id, to_list, cc_list, bcc_list, subject, body_html, body_text, forward_from_ids, send_at, created_at
FROM scheduled_sends WHERE send_at <= datetime('now') ORDER BY send_at ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
return d.scanScheduledSends(rows)
}
func (d *DB) scanScheduledSends(rows *sql.Rows) ([]*models.ScheduledSend, error) {
var out []*models.ScheduledSend
for rows.Next() {
s := &models.ScheduledSend{}
var toJSON, ccJSON, bccJSON, fwdJSON, subjectEnc, bodyHTMLEnc, bodyTextEnc string
if err := rows.Scan(&s.ID, &s.UserID, &s.AccountID, &toJSON, &ccJSON, &bccJSON,
&subjectEnc, &bodyHTMLEnc, &bodyTextEnc, &fwdJSON, &s.SendAt, &s.CreatedAt); err != nil {
return nil, err
}
json.Unmarshal([]byte(toJSON), &s.To)
json.Unmarshal([]byte(ccJSON), &s.CC)
json.Unmarshal([]byte(bccJSON), &s.BCC)
json.Unmarshal([]byte(fwdJSON), &s.ForwardFromIDs)
s.Subject, _ = d.enc.Decrypt(subjectEnc)
s.BodyHTML, _ = d.enc.Decrypt(bodyHTMLEnc)
s.BodyText, _ = d.enc.Decrypt(bodyTextEnc)
out = append(out, s)
}
return out, rows.Err()
}
// DeleteScheduledSend cancels a pending scheduled send, scoped to userID. Also used by the
// background sweep (without a user check) to remove a row once it's been sent.
func (d *DB) DeleteScheduledSend(id, userID int64) error {
var err error
if userID > 0 {
_, err = d.sql.Exec(`DELETE FROM scheduled_sends WHERE id=? AND user_id=?`, id, userID)
} else {
_, err = d.sql.Exec(`DELETE FROM scheduled_sends WHERE id=?`, id)
}
return err
}
// ---- Pending IMAP ops queue ----
// PendingIMAPOp represents an IMAP write operation that needs to be applied to the server.
type PendingIMAPOp struct {
ID int64
AccountID int64
OpType string // "delete" | "move" | "flag_read" | "flag_star"
RemoteUID uint32
FolderPath string
Extra string // for move: dest folder path; for flag_*: "1" or "0"
Attempts int
}
// EnqueueIMAPOp adds an operation to the pending queue atomically.
func (d *DB) EnqueueIMAPOp(op *PendingIMAPOp) error {
_, err := d.sql.Exec(
`INSERT INTO pending_imap_ops (account_id, op_type, remote_uid, folder_path, extra) VALUES (?,?,?,?,?)`,
op.AccountID, op.OpType, op.RemoteUID, op.FolderPath, op.Extra,
)
return err
}
// DequeuePendingOps returns up to `limit` pending ops for a given account.
func (d *DB) DequeuePendingOps(accountID int64, limit int) ([]*PendingIMAPOp, error) {
rows, err := d.sql.Query(
`SELECT id, account_id, op_type, remote_uid, folder_path, extra, attempts
FROM pending_imap_ops WHERE account_id=? ORDER BY id ASC LIMIT ?`,
accountID, limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var ops []*PendingIMAPOp
for rows.Next() {
op := &PendingIMAPOp{}
rows.Scan(&op.ID, &op.AccountID, &op.OpType, &op.RemoteUID, &op.FolderPath, &op.Extra, &op.Attempts)
ops = append(ops, op)
}
return ops, rows.Err()
}
// DeletePendingOp removes a successfully applied op.
func (d *DB) DeletePendingOp(id int64) error {
_, err := d.sql.Exec(`DELETE FROM pending_imap_ops WHERE id=?`, id)
return err
}
// IncrementPendingOpAttempts bumps attempt count; ops with >5 attempts are abandoned (dropped
// from the queue entirely). Returns true when this call was the one that abandoned it, so the
// caller can surface that as a visible account error instead of silently losing the operation
// (e.g. a delete/move that never actually reaches the server, with no sign anything went wrong).
func (d *DB) IncrementPendingOpAttempts(id int64) (abandoned bool) {
d.sql.Exec(`UPDATE pending_imap_ops SET attempts=attempts+1 WHERE id=?`, id)
res, _ := d.sql.Exec(`DELETE FROM pending_imap_ops WHERE id=? AND attempts>5`, id)
if res == nil {
return false
}
n, _ := res.RowsAffected()
return n > 0
}
// CountPendingOps returns number of queued ops for an account (for logging).
func (d *DB) CountPendingOps(accountID int64) int {
var n int
d.sql.QueryRow(`SELECT COUNT(*) FROM pending_imap_ops WHERE account_id=?`, accountID).Scan(&n)
return n
}
// ---- Folder delta-sync state ----
// GetFolderSyncState returns uid_validity and last_seen_uid for incremental sync.
func (d *DB) GetFolderSyncState(folderID int64) (uidValidity, lastSeenUID uint32) {
d.sql.QueryRow(`SELECT COALESCE(uid_validity,0), COALESCE(last_seen_uid,0) FROM folders WHERE id=?`, folderID).
Scan(&uidValidity, &lastSeenUID)
return
}
// SetFolderSyncState persists uid_validity and last_seen_uid after a successful sync.
func (d *DB) SetFolderSyncState(folderID int64, uidValidity, lastSeenUID uint32) {
d.sql.Exec(`UPDATE folders SET uid_validity=?, last_seen_uid=? WHERE id=?`, uidValidity, lastSeenUID, folderID)
}
// GetLocalUIDSet returns the set of remote_uid values already stored locally for a folder —
// used alongside PurgeDeletedMessages to reconcile the other direction: UIDs the server has
// that the local cache is missing (from any past cause of local data loss), so the sync can
// re-fetch exactly those instead of relying solely on the last_seen_uid incremental cursor.
func (d *DB) GetLocalUIDSet(folderID int64) (map[string]bool, error) {
rows, err := d.sql.Query(`SELECT remote_uid FROM messages WHERE folder_id=?`, folderID)
if err != nil {
return nil, err
}
defer rows.Close()
set := map[string]bool{}
for rows.Next() {
var uid string
if err := rows.Scan(&uid); err == nil {
set[uid] = true
}
}
return set, rows.Err()
}
// PurgeDeletedMessages removes local messages whose remote_uid is no longer
// in the server's UID list for a folder. Returns count purged.
func (d *DB) PurgeDeletedMessages(folderID int64, serverUIDs []uint32) (int, error) {
if len(serverUIDs) == 0 {
// Don't purge everything if server returned empty (connection issue)
return 0, nil
}
// Build placeholder list
args := make([]interface{}, len(serverUIDs)+1)
args[0] = folderID
placeholders := make([]string, len(serverUIDs))
for i, uid := range serverUIDs {
args[i+1] = fmt.Sprintf("%d", uid)
placeholders[i] = "?"
}
q := fmt.Sprintf(
`DELETE FROM messages WHERE folder_id=? AND remote_uid NOT IN (%s)`,
strings.Join(placeholders, ","),
)
res, err := d.sql.Exec(q, args...)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}
// DeleteMessageByRemoteUID removes a single locally-cached message by its provider id —
// used when a draft autosave replaces the server-side copy under a new id/UID (IMAP/JMAP
// delete-then-recreate) so the stale local row doesn't linger until the next full sync's
// purge step runs.
func (d *DB) DeleteMessageByRemoteUID(folderID int64, remoteUID string) error {
_, err := d.sql.Exec(`DELETE FROM messages WHERE folder_id=? AND remote_uid=?`, folderID, remoteUID)
return err
}
// DeleteAllFolderMessages removes all messages from a folder (used on UIDVALIDITY change).
func (d *DB) DeleteAllFolderMessages(folderID int64) {
d.sql.Exec(`DELETE FROM messages WHERE folder_id=?`, folderID)
}
// GetFolderMessageCount returns the local message count for a folder by account and path.
func (d *DB) GetFolderMessageCount(accountID int64, folderPath string) int {
var n int
d.sql.QueryRow(`
SELECT COUNT(*) FROM messages m
JOIN folders f ON f.id=m.folder_id
WHERE f.account_id=? AND f.full_path=?`, accountID, folderPath,
).Scan(&n)
return n
}
// ReconcileFlags updates is_read and is_starred from server flags, but ONLY for
// messages that do NOT have a pending local write op (to avoid overwriting in-flight changes).
func (d *DB) ReconcileFlags(folderID int64, serverFlags map[uint32][]string) {
// Get set of UIDs with pending ops so we don't overwrite them
rows, _ := d.sql.Query(
`SELECT DISTINCT remote_uid FROM pending_imap_ops po
JOIN folders f ON f.account_id=po.account_id
WHERE f.id=? AND (po.op_type='flag_read' OR po.op_type='flag_star')`, folderID,
)
pendingUIDs := make(map[uint32]bool)
if rows != nil {
for rows.Next() {
var uid uint32
rows.Scan(&uid)
pendingUIDs[uid] = true
}
rows.Close()
}
for uid, flags := range serverFlags {
if pendingUIDs[uid] {
continue // don't reconcile — we have a pending write for this message
}
isRead := false
isStarred := false
for _, f := range flags {
switch f {
case `\Seen`:
isRead = true
case `\Flagged`:
isStarred = true
}
}
d.sql.Exec(
`UPDATE messages SET is_read=?, is_starred=?
WHERE folder_id=? AND remote_uid=?`,
boolToInt(isRead), boolToInt(isStarred),
folderID, fmt.Sprintf("%d", uid),
)
}
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// ListMessageIDsByFolder returns every message ID in a folder, newest first, scoped to
// folders owned by userID. Used by folder export (bulk mbox/zip download).
func (d *DB) ListMessageIDsByFolder(folderID, userID int64) ([]int64, error) {
rows, err := d.sql.Query(`
SELECT m.id FROM messages m
JOIN folders f ON f.id = m.folder_id
JOIN email_accounts a ON a.id = f.account_id
WHERE m.folder_id=? AND a.user_id=?
ORDER BY m.date DESC`, folderID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// EmptyFolder deletes all messages in a folder (Trash/Spam).
// Returns count deleted.
// ListMessageIDsInFolder returns the ids of every message in folderID owned by userID — used
// by EmptyFolder to delete each one through the same per-message path (deleteMessageEverywhere
// in api.go) that a regular single delete uses, so "Empty Trash/Spam" actually removes mail
// from the provider instead of only clearing the local cache.
func (d *DB) ListMessageIDsInFolder(folderID, userID int64) ([]int64, error) {
rows, err := d.sql.Query(`
SELECT m.id FROM messages m
JOIN folders f ON f.id = m.folder_id
JOIN email_accounts a ON a.id = f.account_id
WHERE m.folder_id=? AND a.user_id=?`,
folderID, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err == nil {
ids = append(ids, id)
}
}
return ids, rows.Err()
}
// EnableAllFolderSync enables sync for all currently-disabled folders belonging
// to accounts owned by userID. Returns count updated.
func (d *DB) EnableAllFolderSync(accountID, userID int64) (int, error) {
res, err := d.sql.Exec(`
UPDATE folders SET sync_enabled=1
WHERE account_id=? AND sync_enabled=0
AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`,
accountID, userID,
)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}
// PollUnread returns inbox unread count + total unread, and whether there are
// new messages since `sinceID`. Used by the client-side poller.
func (d *DB) PollUnread(userID int64, sinceID int64) (inboxUnread int, totalUnread int, newestID int64, err error) {
// Inbox unread count
d.sql.QueryRow(`
SELECT COALESCE(SUM(f.unread_count),0) FROM folders f
JOIN email_accounts a ON a.id=f.account_id
WHERE a.user_id=? AND f.folder_type='inbox'`, userID,
).Scan(&inboxUnread)
// Total unread (all folders except trash/spam)
d.sql.QueryRow(`
SELECT COALESCE(SUM(f.unread_count),0) FROM folders f
JOIN email_accounts a ON a.id=f.account_id
WHERE a.user_id=? AND f.folder_type NOT IN ('trash','spam')`, userID,
).Scan(&totalUnread)
// Newest message ID in inbox
d.sql.QueryRow(`
SELECT COALESCE(MAX(m.id),0) FROM messages m
JOIN folders f ON f.id=m.folder_id
JOIN email_accounts a ON a.id=f.account_id
WHERE a.user_id=? AND f.folder_type='inbox'`, userID,
).Scan(&newestID)
return
}
// GetNewMessagesSince returns inbox message summaries with id > sinceID for notifications.
func (d *DB) GetNewMessagesSince(userID int64, sinceID int64) ([]map[string]interface{}, error) {
rows, err := d.sql.Query(`
SELECT m.id, m.subject, m.from_name, m.from_email
FROM messages m
JOIN folders f ON f.id=m.folder_id
JOIN email_accounts a ON a.id=f.account_id
WHERE a.user_id=? AND f.folder_type='inbox' AND m.id>?
ORDER BY m.id DESC LIMIT 5`, userID, sinceID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var result []map[string]interface{}
for rows.Next() {
var id int64
var subject, fromName, fromEmail string
rows.Scan(&id, &subject, &fromName, &fromEmail)
// Decrypt
subject, _ = d.enc.Decrypt(subject)
fromName, _ = d.enc.Decrypt(fromName)
fromEmail, _ = d.enc.Decrypt(fromEmail)
result = append(result, map[string]interface{}{
"id": id, "subject": subject, "from_name": fromName, "from_email": fromEmail,
})
}
if result == nil {
result = []map[string]interface{}{}
}
return result, rows.Err()
}
// ---- Attachment metadata ----
// SaveAttachmentMeta saves attachment metadata for a message (no binary data).
// Uses INSERT OR REPLACE so a re-sync always refreshes the part path (ContentID).
func (d *DB) SaveAttachmentMeta(messageID int64, atts []models.Attachment) error {
// Delete stale rows first so re-syncs don't leave orphans
d.sql.Exec(`DELETE FROM attachments WHERE message_id=?`, messageID)
for _, a := range atts {
_, err := d.sql.Exec(`
INSERT INTO attachments (message_id, filename, content_type, size, content_id)
VALUES (?,?,?,?,?)`,
messageID, a.Filename, a.ContentType, a.Size, a.ContentID,
)
if err != nil {
return err
}
}
return nil
}
// GetAttachmentsByMessage returns attachment metadata for a message.
func (d *DB) GetAttachmentsByMessage(messageID, userID int64) ([]models.Attachment, error) {
rows, err := d.sql.Query(`
SELECT a.id, a.message_id, a.filename, a.content_type, a.size, a.content_id
FROM attachments a
JOIN messages m ON m.id=a.message_id
JOIN email_accounts ac ON ac.id=m.account_id
WHERE a.message_id=? AND ac.user_id=?`, messageID, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Attachment
for rows.Next() {
var a models.Attachment
rows.Scan(&a.ID, &a.MessageID, &a.Filename, &a.ContentType, &a.Size, &a.ContentID)
result = append(result, a)
}
return result, rows.Err()
}
// GetAttachment returns a single attachment record (ownership via userID check).
func (d *DB) GetAttachment(attachmentID, userID int64) (*models.Attachment, error) {
var a models.Attachment
err := d.sql.QueryRow(`
SELECT a.id, a.message_id, a.filename, a.content_type, a.size, a.content_id
FROM attachments a
JOIN messages m ON m.id=a.message_id
JOIN email_accounts ac ON ac.id=m.account_id
WHERE a.id=? AND ac.user_id=?`, attachmentID, userID,
).Scan(&a.ID, &a.MessageID, &a.Filename, &a.ContentType, &a.Size, &a.ContentID)
if err == sql.ErrNoRows {
return nil, nil
}
return &a, err
}
// ---- Mark all read ----
// MarkFolderAllRead marks every message in a folder as read and enqueues IMAP flag ops.
// Returns the list of (remoteUID, folderPath, accountID) for IMAP ops.
func (d *DB) MarkFolderAllRead(folderID, userID int64) ([]PendingIMAPOp, error) {
// Verify folder ownership
var accountID int64
var fullPath string
err := d.sql.QueryRow(`
SELECT f.account_id, f.full_path FROM folders f
JOIN email_accounts a ON a.id=f.account_id
WHERE f.id=? AND a.user_id=?`, folderID, userID,
).Scan(&accountID, &fullPath)
if err != nil {
return nil, fmt.Errorf("folder not found or not owned: %w", err)
}
// Get all unread messages in folder for IMAP ops
rows, err := d.sql.Query(`
SELECT remote_uid FROM messages WHERE folder_id=? AND is_read=0`, folderID)
if err != nil {
return nil, err
}
defer rows.Close()
var ops []PendingIMAPOp
for rows.Next() {
var uid string
rows.Scan(&uid)
var uidNum uint32
fmt.Sscanf(uid, "%d", &uidNum)
if uidNum > 0 {
ops = append(ops, PendingIMAPOp{
AccountID: accountID, OpType: "flag_read",
RemoteUID: uidNum, FolderPath: fullPath, Extra: "1",
})
}
}
rows.Close()
// Bulk mark read in DB
_, err = d.sql.Exec(`UPDATE messages SET is_read=1 WHERE folder_id=?`, folderID)
if err != nil {
return nil, err
}
d.UpdateFolderCounts(folderID)
return ops, nil
}
// ---- Admin MFA disable ----
// AdminDisableMFAByID disables MFA for a user by ID (admin action).
func (d *DB) AdminDisableMFAByID(targetUserID int64) error {
_, err := d.sql.Exec(`
UPDATE users SET mfa_enabled=0, mfa_secret='', mfa_pending=''
WHERE id=?`, targetUserID)
return err
}
// ---- Brute Force / IP Block ----
// IPBlock represents a blocked IP entry.
type IPBlock struct {
ID int64 `json:"id"`
IP string `json:"ip"`
Reason string `json:"reason"`
Country string `json:"country"`
CountryCode string `json:"country_code"`
Attempts int `json:"attempts"`
BlockedAt time.Time `json:"blocked_at"`
ExpiresAt *time.Time `json:"expires_at"`
IsPermanent bool `json:"is_permanent"`
}
// LoginAttemptStat is used for summary display.
type LoginAttemptStat struct {
IP string `json:"ip"`
Country string `json:"country"`
CountryCode string `json:"country_code"`
Total int `json:"total"`
Failures int `json:"failures"`
LastSeen string `json:"last_seen"`
}
// RecordLoginAttempt saves a login attempt for an IP.
func (d *DB) RecordLoginAttempt(ip, username, country, countryCode string, success bool) {
suc := 0
if success {
suc = 1
}
d.sql.Exec(`INSERT INTO login_attempts (ip, username, success, country, country_code) VALUES (?,?,?,?,?)`,
ip, username, suc, country, countryCode)
}
// CountRecentFailures returns the number of failed logins from an IP in the last windowMinutes.
func (d *DB) CountRecentFailures(ip string, windowMinutes int) int {
var count int
d.sql.QueryRow(`
SELECT COUNT(*) FROM login_attempts
WHERE ip=? AND success=0 AND created_at >= datetime('now', ? || ' minutes')`,
ip, fmt.Sprintf("-%d", windowMinutes),
).Scan(&count)
return count
}
// IsIPBlocked returns true if the IP is currently blocked (non-expired entry).
func (d *DB) IsIPBlocked(ip string) bool {
var count int
d.sql.QueryRow(`
SELECT COUNT(*) FROM ip_blocks
WHERE ip=? AND (is_permanent=1 OR expires_at IS NULL OR expires_at > datetime('now'))`,
ip,
).Scan(&count)
return count > 0
}
// BlockIP adds or updates a block entry for an IP.
// banHours=0 means permanent block (admin must remove manually).
func (d *DB) BlockIP(ip, reason, country, countryCode string, attempts int, banHours int) {
isPermanent := 0
var expiresExpr string
if banHours == 0 {
isPermanent = 1
expiresExpr = "NULL"
} else {
expiresExpr = fmt.Sprintf("datetime('now', '+%d hours')", banHours)
}
d.sql.Exec(fmt.Sprintf(`
INSERT INTO ip_blocks (ip, reason, country, country_code, attempts, is_permanent, expires_at)
VALUES (?,?,?,?,?,%d,%s)
ON CONFLICT(ip) DO UPDATE SET
reason=excluded.reason, attempts=excluded.attempts,
blocked_at=datetime('now'), is_permanent=%d, expires_at=%s`,
isPermanent, expiresExpr, isPermanent, expiresExpr,
), ip, reason, country, countryCode, attempts)
}
// UnblockIP removes a block entry.
func (d *DB) UnblockIP(ip string) error {
_, err := d.sql.Exec(`DELETE FROM ip_blocks WHERE ip=?`, ip)
return err
}
// ListIPBlocks returns all current (non-expired or permanent) blocked IPs.
func (d *DB) ListIPBlocks() ([]IPBlock, error) {
rows, err := d.sql.Query(`
SELECT id, ip, reason, country, country_code, attempts, blocked_at, expires_at, is_permanent
FROM ip_blocks
WHERE is_permanent=1 OR expires_at IS NULL OR expires_at > datetime('now')
ORDER BY blocked_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var result []IPBlock
for rows.Next() {
var b IPBlock
var expiresAt sql.NullTime
rows.Scan(&b.ID, &b.IP, &b.Reason, &b.Country, &b.CountryCode,
&b.Attempts, &b.BlockedAt, &expiresAt, &b.IsPermanent)
if expiresAt.Valid {
t := expiresAt.Time
b.ExpiresAt = &t
}
result = append(result, b)
}
return result, rows.Err()
}
// ListLoginAttemptStats returns per-IP attempt summaries for display.
func (d *DB) ListLoginAttemptStats(limitHours int) ([]LoginAttemptStat, error) {
rows, err := d.sql.Query(`
SELECT ip, country, country_code,
COUNT(*) as total,
SUM(CASE WHEN success=0 THEN 1 ELSE 0 END) as failures,
MAX(created_at) as last_seen
FROM login_attempts
WHERE created_at >= datetime('now', ? || ' hours')
GROUP BY ip ORDER BY failures DESC LIMIT 100`,
fmt.Sprintf("-%d", limitHours),
)
if err != nil {
return nil, err
}
defer rows.Close()
var result []LoginAttemptStat
for rows.Next() {
var s LoginAttemptStat
rows.Scan(&s.IP, &s.Country, &s.CountryCode, &s.Total, &s.Failures, &s.LastSeen)
result = append(result, s)
}
return result, rows.Err()
}
// PurgeExpiredBlocks removes expired (non-permanent) blocks from the table.
func (d *DB) PurgeExpiredBlocks() {
d.sql.Exec(`DELETE FROM ip_blocks WHERE is_permanent=0 AND expires_at IS NOT NULL AND expires_at <= datetime('now')`)
}
// LookupIPCountry returns cached country info for an IP from recent login_attempts.
func (d *DB) LookupCachedCountry(ip string) (country, countryCode string) {
d.sql.QueryRow(`
SELECT country, country_code FROM login_attempts
WHERE ip=? AND country != '' ORDER BY created_at DESC LIMIT 1`, ip,
).Scan(&country, &countryCode)
return
}
// ---- Profile Updates ----
// UpdateUserEmail changes a user's email address. Returns error if already taken.
func (d *DB) UpdateUserEmail(userID int64, newEmail string) error {
_, err := d.sql.Exec(
`UPDATE users SET email=?, updated_at=datetime('now') WHERE id=?`,
newEmail, userID)
return err
}
// UpdateUserUsername changes a user's display username. Returns error if already taken.
func (d *DB) UpdateUserUsername(userID int64, newUsername string) error {
_, err := d.sql.Exec(
`UPDATE users SET username=?, updated_at=datetime('now') WHERE id=?`,
newUsername, userID)
return err
}
// ---- Per-User IP Rules ----
// UserIPRule holds per-user IP access settings.
type UserIPRule struct {
UserID int64 `json:"user_id"`
Mode string `json:"mode"` // "brute_skip" | "allow_only" | "disabled"
IPList string `json:"ip_list"` // comma-separated IPs
}
// GetUserIPRule returns the IP rule for a user, or nil if none set.
func (d *DB) GetUserIPRule(userID int64) (*UserIPRule, error) {
row := d.sql.QueryRow(`SELECT user_id, mode, ip_list FROM user_ip_rules WHERE user_id=?`, userID)
r := &UserIPRule{}
if err := row.Scan(&r.UserID, &r.Mode, &r.IPList); err == sql.ErrNoRows {
return nil, nil
} else if err != nil {
return nil, err
}
return r, nil
}
// SetUserIPRule upserts the IP rule for a user.
func (d *DB) SetUserIPRule(userID int64, mode, ipList string) error {
_, err := d.sql.Exec(`
INSERT INTO user_ip_rules (user_id, mode, ip_list, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
mode=excluded.mode,
ip_list=excluded.ip_list,
updated_at=datetime('now')`,
userID, mode, ipList)
return err
}
// DeleteUserIPRule removes IP rules for a user (disables the feature).
func (d *DB) DeleteUserIPRule(userID int64) error {
_, err := d.sql.Exec(`DELETE FROM user_ip_rules WHERE user_id=?`, userID)
return err
}
// CheckUserIPAccess evaluates per-user IP rules against a connecting IP.
// Returns:
// "allow" — rule says allow (brute_skip match or allow_only match)
// "deny" — allow_only mode and IP is not in list
// "skip_brute" — brute_skip mode and IP is in list (skip brute force check)
// "default" — no rule exists, fall through to global rules
func (d *DB) CheckUserIPAccess(userID int64, ip string) string {
rule, err := d.GetUserIPRule(userID)
if err != nil || rule == nil || rule.Mode == "disabled" || rule.IPList == "" {
return "default"
}
for _, listed := range splitIPs(rule.IPList) {
if listed == ip {
if rule.Mode == "allow_only" {
return "allow"
}
return "skip_brute"
}
}
// IP not in list
if rule.Mode == "allow_only" {
return "deny"
}
return "default"
}
// SplitIPList splits a comma-separated IP string into trimmed, non-empty entries.
func SplitIPList(s string) []string {
return splitIPs(s)
}
func splitIPs(s string) []string {
var result []string
for _, p := range strings.Split(s, ",") {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
return result
}
// IPBlockWithUsername extends IPBlock with the last username attempted from that IP.
type IPBlockWithUsername struct {
IPBlock
LastUsername string
}
// ListIPBlocksWithUsername returns active blocks enriched with the most recent
// username that was attempted from each IP (from login_attempts history).
func (d *DB) ListIPBlocksWithUsername() ([]IPBlockWithUsername, error) {
rows, err := d.sql.Query(`
SELECT
b.id, b.ip, b.reason, b.country, b.country_code,
b.attempts, b.blocked_at, b.expires_at, b.is_permanent,
COALESCE(
(SELECT username FROM login_attempts
WHERE ip=b.ip AND username != ''
ORDER BY created_at DESC LIMIT 1),
''
) AS last_username
FROM ip_blocks b
WHERE b.is_permanent=1 OR b.expires_at IS NULL OR b.expires_at > datetime('now')
ORDER BY b.blocked_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var result []IPBlockWithUsername
for rows.Next() {
var b IPBlockWithUsername
var expiresAt sql.NullTime
err := rows.Scan(
&b.ID, &b.IP, &b.Reason, &b.Country, &b.CountryCode,
&b.Attempts, &b.BlockedAt, &expiresAt, &b.IsPermanent,
&b.LastUsername,
)
if err != nil {
return nil, err
}
if expiresAt.Valid {
t := expiresAt.Time
b.ExpiresAt = &t
}
result = append(result, b)
}
return result, rows.Err()
}
// ======== Contacts ========
func (d *DB) ListContacts(userID int64) ([]*models.Contact, error) {
rows, err := d.sql.Query(`
SELECT id, user_id, account_id, uid, display_name, email, phone, company, notes, avatar_color, created_at, updated_at
FROM contacts WHERE user_id=? ORDER BY display_name COLLATE NOCASE`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*models.Contact
for rows.Next() {
var c models.Contact
var accountID *int64
var dn, em, ph, co, no, av []byte
rows.Scan(&c.ID, &c.UserID, &accountID, &c.UID, &dn, &em, &ph, &co, &no, &av, &c.CreatedAt, &c.UpdatedAt)
c.AccountID = accountID
c.DisplayName, _ = d.enc.Decrypt(string(dn))
c.Email, _ = d.enc.Decrypt(string(em))
c.Phone, _ = d.enc.Decrypt(string(ph))
c.Company, _ = d.enc.Decrypt(string(co))
c.Notes, _ = d.enc.Decrypt(string(no))
c.AvatarColor, _ = d.enc.Decrypt(string(av))
out = append(out, &c)
}
return out, nil
}
func (d *DB) GetContact(id, userID int64) (*models.Contact, error) {
var c models.Contact
var accountID *int64
var dn, em, ph, co, no, av []byte
err := d.sql.QueryRow(`
SELECT id, user_id, account_id, uid, display_name, email, phone, company, notes, avatar_color, created_at, updated_at
FROM contacts WHERE id=? AND user_id=?`, id, userID).
Scan(&c.ID, &c.UserID, &accountID, &c.UID, &dn, &em, &ph, &co, &no, &av, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return nil, err
}
c.AccountID = accountID
c.DisplayName, _ = d.enc.Decrypt(string(dn))
c.Email, _ = d.enc.Decrypt(string(em))
c.Phone, _ = d.enc.Decrypt(string(ph))
c.Company, _ = d.enc.Decrypt(string(co))
c.Notes, _ = d.enc.Decrypt(string(no))
c.AvatarColor, _ = d.enc.Decrypt(string(av))
return &c, nil
}
func (d *DB) CreateContact(c *models.Contact) error {
dn, _ := d.enc.Encrypt(c.DisplayName)
em, _ := d.enc.Encrypt(c.Email)
ph, _ := d.enc.Encrypt(c.Phone)
co, _ := d.enc.Encrypt(c.Company)
no, _ := d.enc.Encrypt(c.Notes)
av, _ := d.enc.Encrypt(c.AvatarColor)
if c.UID == "" {
c.UID = fmt.Sprintf("gwm-%d-%d", c.UserID, time.Now().UnixNano())
}
res, err := d.sql.Exec(`
INSERT INTO contacts (user_id, uid, display_name, email, phone, company, notes, avatar_color)
VALUES (?,?,?,?,?,?,?,?)`, c.UserID, c.UID, dn, em, ph, co, no, av)
if err != nil {
return err
}
c.ID, _ = res.LastInsertId()
return nil
}
// UpsertContact inserts or updates a contact synced from a CardDAV server,
// keyed on (user_id, uid) like UpsertCalendarEvent.
func (d *DB) UpsertContact(c *models.Contact) error {
dn, _ := d.enc.Encrypt(c.DisplayName)
em, _ := d.enc.Encrypt(c.Email)
ph, _ := d.enc.Encrypt(c.Phone)
co, _ := d.enc.Encrypt(c.Company)
no, _ := d.enc.Encrypt(c.Notes)
av, _ := d.enc.Encrypt(c.AvatarColor)
res, err := d.sql.Exec(`
INSERT INTO contacts (user_id, account_id, uid, display_name, email, phone, company, notes, avatar_color)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(user_id, uid) DO UPDATE SET
account_id=excluded.account_id, display_name=excluded.display_name, email=excluded.email,
phone=excluded.phone, company=excluded.company, notes=excluded.notes,
updated_at=datetime('now')`,
c.UserID, c.AccountID, c.UID, dn, em, ph, co, no, av)
if err != nil {
return err
}
if c.ID == 0 {
c.ID, _ = res.LastInsertId()
}
return nil
}
// DeleteContactsNotIn removes previously-synced contacts for accountID whose
// uid is no longer present on the CardDAV server (i.e. deleted remotely).
func (d *DB) DeleteContactsNotIn(accountID int64, keepUIDs []string) error {
if len(keepUIDs) == 0 {
_, err := d.sql.Exec(`DELETE FROM contacts WHERE account_id=?`, accountID)
return err
}
placeholders := make([]string, len(keepUIDs))
args := make([]interface{}, 0, len(keepUIDs)+1)
args = append(args, accountID)
for i, u := range keepUIDs {
placeholders[i] = "?"
args = append(args, u)
}
q := fmt.Sprintf(`DELETE FROM contacts WHERE account_id=? AND uid NOT IN (%s)`, strings.Join(placeholders, ","))
_, err := d.sql.Exec(q, args...)
return err
}
func (d *DB) UpdateContact(c *models.Contact, userID int64) error {
dn, _ := d.enc.Encrypt(c.DisplayName)
em, _ := d.enc.Encrypt(c.Email)
ph, _ := d.enc.Encrypt(c.Phone)
co, _ := d.enc.Encrypt(c.Company)
no, _ := d.enc.Encrypt(c.Notes)
av, _ := d.enc.Encrypt(c.AvatarColor)
_, err := d.sql.Exec(`
UPDATE contacts SET display_name=?, email=?, phone=?, company=?, notes=?, avatar_color=?,
updated_at=datetime('now') WHERE id=? AND user_id=?`,
dn, em, ph, co, no, av, c.ID, userID)
return err
}
func (d *DB) DeleteContact(id, userID int64) error {
_, err := d.sql.Exec(`DELETE FROM contacts WHERE id=? AND user_id=?`, id, userID)
return err
}
func (d *DB) SearchContacts(userID int64, q string) ([]*models.Contact, error) {
all, err := d.ListContacts(userID)
if err != nil {
return nil, err
}
q = strings.ToLower(q)
var out []*models.Contact
for _, c := range all {
if strings.Contains(strings.ToLower(c.DisplayName), q) ||
strings.Contains(strings.ToLower(c.Email), q) ||
strings.Contains(strings.ToLower(c.Company), q) {
out = append(out, c)
}
}
return out, nil
}
// ======== Calendar Events ========
func (d *DB) ListCalendarEvents(userID int64, from, to string) ([]*models.CalendarEvent, error) {
rows, err := d.sql.Query(`
SELECT e.id, e.user_id, e.account_id, e.uid, e.title, e.description, e.location,
e.start_time, e.end_time, e.all_day, e.recurrence_rule, e.color,
e.status, e.organizer_email, e.attendees,
COALESCE(a.color,''), COALESCE(a.email_address,'')
FROM calendar_events e
LEFT JOIN email_accounts a ON a.id = e.account_id
WHERE e.user_id=? AND e.start_time >= ? AND e.start_time <= ?
ORDER BY e.start_time`, userID, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
return scanCalendarEvents(d, rows)
}
func (d *DB) GetCalendarEvent(id, userID int64) (*models.CalendarEvent, error) {
rows, err := d.sql.Query(`
SELECT e.id, e.user_id, e.account_id, e.uid, e.title, e.description, e.location,
e.start_time, e.end_time, e.all_day, e.recurrence_rule, e.color,
e.status, e.organizer_email, e.attendees,
COALESCE(a.color,''), COALESCE(a.email_address,'')
FROM calendar_events e
LEFT JOIN email_accounts a ON a.id = e.account_id
WHERE e.id=? AND e.user_id=?`, id, userID)
if err != nil {
return nil, err
}
defer rows.Close()
evs, err := scanCalendarEvents(d, rows)
if err != nil || len(evs) == 0 {
return nil, err
}
return evs[0], nil
}
func scanCalendarEvents(d *DB, rows interface{ Next() bool; Scan(...interface{}) error }) ([]*models.CalendarEvent, error) {
var out []*models.CalendarEvent
for rows.Next() {
var e models.CalendarEvent
var accountID *int64
var ti, de, lo, rc, co, st, oe, at []byte
err := rows.Scan(
&e.ID, &e.UserID, &accountID, &e.UID,
&ti, &de, &lo,
&e.StartTime, &e.EndTime, &e.AllDay, &rc, &co,
&st, &oe, &at,
&e.AccountColor, &e.AccountEmail,
)
if err != nil {
return nil, err
}
e.AccountID = accountID
e.Title, _ = d.enc.Decrypt(string(ti))
e.Description, _ = d.enc.Decrypt(string(de))
e.Location, _ = d.enc.Decrypt(string(lo))
e.RecurrenceRule, _ = d.enc.Decrypt(string(rc))
e.Color, _ = d.enc.Decrypt(string(co))
e.Status, _ = d.enc.Decrypt(string(st))
e.OrganizerEmail, _ = d.enc.Decrypt(string(oe))
e.Attendees, _ = d.enc.Decrypt(string(at))
if e.Color == "" && e.AccountColor != "" {
e.Color = e.AccountColor
}
out = append(out, &e)
}
return out, nil
}
func (d *DB) UpsertCalendarEvent(e *models.CalendarEvent) error {
ti, _ := d.enc.Encrypt(e.Title)
de, _ := d.enc.Encrypt(e.Description)
lo, _ := d.enc.Encrypt(e.Location)
rc, _ := d.enc.Encrypt(e.RecurrenceRule)
co, _ := d.enc.Encrypt(e.Color)
st, _ := d.enc.Encrypt(e.Status)
oe, _ := d.enc.Encrypt(e.OrganizerEmail)
at, _ := d.enc.Encrypt(e.Attendees)
allDay := 0
if e.AllDay {
allDay = 1
}
if e.UID == "" {
e.UID = fmt.Sprintf("gwm-%d-%d", e.UserID, time.Now().UnixNano())
}
res, err := d.sql.Exec(`
INSERT INTO calendar_events
(user_id, account_id, uid, title, description, location,
start_time, end_time, all_day, recurrence_rule, color,
status, organizer_email, attendees)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(user_id, uid) DO UPDATE SET
title=excluded.title, description=excluded.description,
location=excluded.location, start_time=excluded.start_time,
end_time=excluded.end_time, all_day=excluded.all_day,
recurrence_rule=excluded.recurrence_rule, color=excluded.color,
status=excluded.status, organizer_email=excluded.organizer_email,
attendees=excluded.attendees,
updated_at=datetime('now')`,
e.UserID, e.AccountID, e.UID, ti, de, lo,
e.StartTime, e.EndTime, allDay, rc, co, st, oe, at)
if err != nil {
return err
}
if e.ID == 0 {
e.ID, _ = res.LastInsertId()
}
return nil
}
func (d *DB) DeleteCalendarEvent(id, userID int64) error {
_, err := d.sql.Exec(`DELETE FROM calendar_events WHERE id=? AND user_id=?`, id, userID)
return err
}
// DeleteCalendarEventsNotIn removes previously-synced events for accountID
// whose uid is no longer present on the CalDAV server (i.e. deleted remotely).
func (d *DB) DeleteCalendarEventsNotIn(accountID int64, keepUIDs []string) error {
if len(keepUIDs) == 0 {
_, err := d.sql.Exec(`DELETE FROM calendar_events WHERE account_id=?`, accountID)
return err
}
placeholders := make([]string, len(keepUIDs))
args := make([]interface{}, 0, len(keepUIDs)+1)
args = append(args, accountID)
for i, u := range keepUIDs {
placeholders[i] = "?"
args = append(args, u)
}
q := fmt.Sprintf(`DELETE FROM calendar_events WHERE account_id=? AND uid NOT IN (%s)`, strings.Join(placeholders, ","))
_, err := d.sql.Exec(q, args...)
return err
}
// ======== CalDAV Tokens ========
func (d *DB) CreateCalDAVToken(userID int64, label string) (*models.CalDAVToken, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return nil, err
}
token := base64.URLEncoding.EncodeToString(raw)
_, err := d.sql.Exec(`INSERT INTO caldav_tokens (user_id, token, label) VALUES (?,?,?)`,
userID, token, label)
if err != nil {
return nil, err
}
return &models.CalDAVToken{UserID: userID, Token: token, Label: label}, nil
}
func (d *DB) ListCalDAVTokens(userID int64) ([]*models.CalDAVToken, error) {
rows, err := d.sql.Query(`
SELECT id, user_id, token, label, created_at, COALESCE(last_used,'')
FROM caldav_tokens WHERE user_id=? ORDER BY created_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*models.CalDAVToken
for rows.Next() {
var t models.CalDAVToken
rows.Scan(&t.ID, &t.UserID, &t.Token, &t.Label, &t.CreatedAt, &t.LastUsed)
out = append(out, &t)
}
return out, nil
}
func (d *DB) DeleteCalDAVToken(id, userID int64) error {
_, err := d.sql.Exec(`DELETE FROM caldav_tokens WHERE id=? AND user_id=?`, id, userID)
return err
}
func (d *DB) GetUserByCalDAVToken(token string) (int64, error) {
var userID int64
err := d.sql.QueryRow(`SELECT user_id FROM caldav_tokens WHERE token=?`, token).Scan(&userID)
if err != nil {
return 0, err
}
d.sql.Exec(`UPDATE caldav_tokens SET last_used=datetime('now') WHERE token=?`, token)
return userID, nil
}
// ---- Trusted Certificates (for self-signed certs) ----
func (d *DB) TrustCertificate(accountID int64, fingerprint, certPEM, hostname string) error {
_, err := d.sql.Exec(`
INSERT OR IGNORE INTO trusted_certs (account_id, cert_fingerprint, cert_pem, hostname)
VALUES (?, ?, ?, ?)`,
accountID, fingerprint, certPEM, hostname)
return err
}
func (d *DB) IsCertTrusted(accountID int64, fingerprint string) (bool, error) {
var count int
err := d.sql.QueryRow(`SELECT COUNT(*) FROM trusted_certs WHERE account_id=? AND cert_fingerprint=?`, accountID, fingerprint).Scan(&count)
return count > 0, err
}
func (d *DB) GetTrustedCert(accountID int64, fingerprint string) (string, error) {
var certPEM string
err := d.sql.QueryRow(`SELECT cert_pem FROM trusted_certs WHERE account_id=? AND cert_fingerprint=?`, accountID, fingerprint).Scan(&certPEM)
if err == sql.ErrNoRows {
return "", nil
}
return certPEM, err
}
// ---- Web Push subscriptions ----
// PushSubscription is a decrypted row from push_subscriptions, shaped to match
// webpush.Subscription directly (Endpoint + Keys.P256dh/Auth).
type PushSubscription struct {
ID int64
Endpoint string
P256dh string
Auth string
}
// UpsertPushSubscription stores (or refreshes) a browser's PushSubscription for userID.
// p256dh/auth are encrypted at rest like other sensitive fields.
func (d *DB) UpsertPushSubscription(userID int64, endpoint, p256dh, auth string) error {
encP256dh, err := d.enc.Encrypt(p256dh)
if err != nil {
return err
}
encAuth, err := d.enc.Encrypt(auth)
if err != nil {
return err
}
_, err = d.sql.Exec(`
INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth)
VALUES (?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET user_id=excluded.user_id, p256dh=excluded.p256dh, auth=excluded.auth`,
userID, endpoint, encP256dh, encAuth)
return err
}
// DeletePushSubscription removes a subscription by its endpoint, scoped to userID so one
// user can't unsubscribe another's device.
func (d *DB) DeletePushSubscription(userID int64, endpoint string) error {
_, err := d.sql.Exec(`DELETE FROM push_subscriptions WHERE user_id=? AND endpoint=?`, userID, endpoint)
return err
}
// DeletePushSubscriptionByEndpoint removes a subscription regardless of owner — used when
// the push service reports the endpoint is gone (410/404), so no user_id is known at that point.
func (d *DB) DeletePushSubscriptionByEndpoint(endpoint string) error {
_, err := d.sql.Exec(`DELETE FROM push_subscriptions WHERE endpoint=?`, endpoint)
return err
}
// GetPushSubscriptionsForUser returns every device subscribed to push for userID.
func (d *DB) GetPushSubscriptionsForUser(userID int64) ([]*PushSubscription, error) {
rows, err := d.sql.Query(`SELECT id, endpoint, p256dh, auth FROM push_subscriptions WHERE user_id=?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*PushSubscription
for rows.Next() {
var s PushSubscription
var encP256dh, encAuth string
if err := rows.Scan(&s.ID, &s.Endpoint, &encP256dh, &encAuth); err != nil {
continue
}
s.P256dh, _ = d.enc.Decrypt(encP256dh)
s.Auth, _ = d.enc.Decrypt(encAuth)
out = append(out, &s)
}
return out, rows.Err()
}