MFA fix, added IP blacklist, update webmail client
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IPBlacklistEntry is a temporary (or manual) block on SMTP/IMAP traffic from one IP.
|
||||
// Deliberately separate from WhitelistedIP (esrv_whitelisted_ips), which authorizes
|
||||
// unauthenticated relay for a domain — a completely different concern.
|
||||
type IPBlacklistEntry struct {
|
||||
ID int64
|
||||
IPAddress string
|
||||
Reason string
|
||||
OffenseCount int
|
||||
Manual bool
|
||||
BlacklistedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// IPAbuseWhitelistEntry exempts one IP from abuse detection entirely.
|
||||
type IPAbuseWhitelistEntry struct {
|
||||
ID int64
|
||||
IPAddress string
|
||||
Note string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// smtpImapAuthTypes are the esrv_auth_logs auth_type values that count toward abuse
|
||||
// detection: SMTP/IMAP traffic, not admin/webmail dashboard logins (those already have
|
||||
// their own lockout in internal/webui/ratelimit.go).
|
||||
const smtpImapAuthTypesSQL = `auth_type IN ('sender', 'mailbox', 'sender_validation', 'mailbox_validation', 'ip', 'imap_login')`
|
||||
|
||||
// CountFailedAuthAttemptsByIP mirrors CountRecentFailedAttempts (queries.go:140) exactly,
|
||||
// including its documented since.UTC().Format("2006-01-02 15:04:05") requirement, but
|
||||
// scoped by ip_address across all SMTP/IMAP auth types instead of by identifier+one type.
|
||||
func (d *DB) CountFailedAuthAttemptsByIP(ip string, since time.Time) (int, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_auth_logs
|
||||
WHERE ip_address = ? AND success = 0 AND created_at >= ? AND `+smtpImapAuthTypesSQL,
|
||||
ip, since.UTC().Format("2006-01-02 15:04:05")).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// IsIPBlacklisted reports whether ip has a currently-active (unexpired) blacklist entry.
|
||||
func (d *DB) IsIPBlacklisted(ip string) (bool, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_ip_blacklist WHERE ip_address = ? AND expires_at > ?`,
|
||||
ip, time.Now().UTC().Format("2006-01-02 15:04:05")).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// IsIPAbuseWhitelisted reports whether ip is exempt from abuse detection.
|
||||
func (d *DB) IsIPAbuseWhitelisted(ip string) (bool, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_ip_abuse_whitelist WHERE ip_address = ?`, ip).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// BlacklistIP upserts ip's blacklist entry, escalating the block duration on repeat
|
||||
// offenses: duration = min(baseHours * 2^(offenseCount-1), maxHours), where offenseCount
|
||||
// is incremented on every call regardless of whether the previous entry had expired.
|
||||
func (d *DB) BlacklistIP(ip, reason string, baseHours, maxHours int) error {
|
||||
var existingCount int
|
||||
err := d.QueryRow(`SELECT offense_count FROM esrv_ip_blacklist WHERE ip_address = ?`, ip).Scan(&existingCount)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
newCount := existingCount + 1
|
||||
|
||||
hours := baseHours
|
||||
for i := 1; i < newCount; i++ {
|
||||
hours *= 2
|
||||
if hours >= maxHours {
|
||||
hours = maxHours
|
||||
break
|
||||
}
|
||||
}
|
||||
if hours > maxHours {
|
||||
hours = maxHours
|
||||
}
|
||||
expiresAt := time.Now().UTC().Add(time.Duration(hours) * time.Hour).Format("2006-01-02 15:04:05")
|
||||
|
||||
_, err = d.Exec(`INSERT INTO esrv_ip_blacklist (ip_address, reason, offense_count, manual, blacklisted_at, expires_at)
|
||||
VALUES (?, ?, ?, 0, CURRENT_TIMESTAMP, ?)
|
||||
ON CONFLICT(ip_address) DO UPDATE SET
|
||||
reason = excluded.reason,
|
||||
offense_count = excluded.offense_count,
|
||||
manual = 0,
|
||||
blacklisted_at = CURRENT_TIMESTAMP,
|
||||
expires_at = excluded.expires_at`,
|
||||
ip, reason, newCount, expiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// AddManualBlacklistEntry is an admin-initiated block: fixed duration, no escalation math.
|
||||
func (d *DB) AddManualBlacklistEntry(ip, reason string, hours int) error {
|
||||
expiresAt := time.Now().UTC().Add(time.Duration(hours) * time.Hour).Format("2006-01-02 15:04:05")
|
||||
_, err := d.Exec(`INSERT INTO esrv_ip_blacklist (ip_address, reason, offense_count, manual, blacklisted_at, expires_at)
|
||||
VALUES (?, ?, 1, 1, CURRENT_TIMESTAMP, ?)
|
||||
ON CONFLICT(ip_address) DO UPDATE SET
|
||||
reason = excluded.reason,
|
||||
manual = 1,
|
||||
blacklisted_at = CURRENT_TIMESTAMP,
|
||||
expires_at = excluded.expires_at`,
|
||||
ip, reason, expiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) ListBlacklist() ([]IPBlacklistEntry, error) {
|
||||
rows, err := d.Query(`SELECT id, ip_address, reason, offense_count, manual, blacklisted_at, expires_at
|
||||
FROM esrv_ip_blacklist ORDER BY blacklisted_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []IPBlacklistEntry
|
||||
for rows.Next() {
|
||||
var e IPBlacklistEntry
|
||||
var blacklistedAt, expiresAt string
|
||||
if err := rows.Scan(&e.ID, &e.IPAddress, &e.Reason, &e.OffenseCount, &e.Manual, &blacklistedAt, &expiresAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.BlacklistedAt, _ = parseTime(blacklistedAt)
|
||||
e.ExpiresAt, _ = parseTime(expiresAt)
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) RemoveBlacklistEntry(id int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_ip_blacklist WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) ListAbuseWhitelist() ([]IPAbuseWhitelistEntry, error) {
|
||||
rows, err := d.Query(`SELECT id, ip_address, note, created_at FROM esrv_ip_abuse_whitelist ORDER BY ip_address`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []IPAbuseWhitelistEntry
|
||||
for rows.Next() {
|
||||
var e IPAbuseWhitelistEntry
|
||||
var createdAt string
|
||||
if err := rows.Scan(&e.ID, &e.IPAddress, &e.Note, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) AddAbuseWhitelist(ip, note string) error {
|
||||
_, err := d.Exec(`INSERT INTO esrv_ip_abuse_whitelist (ip_address, note) VALUES (?, ?)
|
||||
ON CONFLICT(ip_address) DO UPDATE SET note = excluded.note`, ip, note)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) RemoveAbuseWhitelist(id int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_ip_abuse_whitelist WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// CountBlacklistEventsSince counts blacklist entries (auto or manual) created since the
|
||||
// given cutoff, for the dashboard's attack-count tiles.
|
||||
func (d *DB) CountBlacklistEventsSince(since time.Time) (int, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_ip_blacklist WHERE blacklisted_at >= ?`,
|
||||
since.UTC().Format("2006-01-02 15:04:05")).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CountFailedAuthSince counts failed SMTP/IMAP auth attempts (any IP) since the given
|
||||
// cutoff, for the dashboard's attack-count tiles.
|
||||
func (d *DB) CountFailedAuthSince(since time.Time) (int, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_auth_logs
|
||||
WHERE success = 0 AND created_at >= ? AND `+smtpImapAuthTypesSQL,
|
||||
since.UTC().Format("2006-01-02 15:04:05")).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func openTestDB(t *testing.T) *DB {
|
||||
t.Helper()
|
||||
database, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
return database
|
||||
}
|
||||
|
||||
// TestBlacklistIPEscalation confirms repeat offenses double the block duration up to the cap.
|
||||
func TestBlacklistIPEscalation(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const ip = "203.0.113.7"
|
||||
|
||||
wantHours := []int{12, 24, 48, 96, 168, 168} // caps at 168 (7 days)
|
||||
for i, want := range wantHours {
|
||||
if err := d.BlacklistIP(ip, "test", 12, 168); err != nil {
|
||||
t.Fatalf("offense %d: %v", i+1, err)
|
||||
}
|
||||
list, err := d.ListBlacklist()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("offense %d: expected 1 entry, got %d", i+1, len(list))
|
||||
}
|
||||
e := list[0]
|
||||
if e.OffenseCount != i+1 {
|
||||
t.Errorf("offense %d: OffenseCount = %d, want %d", i+1, e.OffenseCount, i+1)
|
||||
}
|
||||
gotHours := e.ExpiresAt.Sub(e.BlacklistedAt).Hours()
|
||||
if diff := gotHours - float64(want); diff < -1 || diff > 1 {
|
||||
t.Errorf("offense %d: duration = %.1fh, want ~%dh", i+1, gotHours, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCountFailedAuthAttemptsByIPMatchesCurrentTimestamp guards against the exact
|
||||
// SQLite time.Time/CURRENT_TIMESTAMP format mismatch already found once in
|
||||
// CountRecentFailedAttempts: a row inserted via CURRENT_TIMESTAMP must be found by a
|
||||
// since-cutoff comparison using a Go-side time.Time a moment earlier.
|
||||
func TestCountFailedAuthAttemptsByIPMatchesCurrentTimestamp(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const ip = "198.51.100.9"
|
||||
since := time.Now().Add(-1 * time.Minute)
|
||||
|
||||
if err := d.LogAuthAttempt("sender", "someone@example.com", ip, false, "bad password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n, err := d.CountFailedAuthAttemptsByIP(ip, since)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("CountFailedAuthAttemptsByIP = %d, want 1 (CURRENT_TIMESTAMP/time.Time format mismatch?)", n)
|
||||
}
|
||||
|
||||
blacklisted, err := d.IsIPBlacklisted(ip)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if blacklisted {
|
||||
t.Fatal("IP should not be blacklisted yet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPAbuseWhitelist(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const ip = "192.0.2.55"
|
||||
|
||||
whitelisted, err := d.IsIPAbuseWhitelisted(ip)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if whitelisted {
|
||||
t.Fatal("should not be whitelisted before AddAbuseWhitelist")
|
||||
}
|
||||
|
||||
if err := d.AddAbuseWhitelist(ip, "trusted scanner"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
whitelisted, err = d.IsIPAbuseWhitelisted(ip)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !whitelisted {
|
||||
t.Fatal("should be whitelisted after AddAbuseWhitelist")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package db
|
||||
|
||||
// CreateMailboxFolder records a custom folder's existence even before it holds any
|
||||
// messages — idempotent (a folder a filter rule already delivered into can be
|
||||
// explicitly created too, without erroring on the duplicate).
|
||||
func (d *DB) CreateMailboxFolder(mailboxID int64, name string) error {
|
||||
_, err := d.Exec(`INSERT OR IGNORE INTO esrv_mailbox_folders (mailbox_id, name) VALUES (?, ?)`, mailboxID, name)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteMailboxFolder removes a custom folder's record. Callers are responsible for
|
||||
// relocating any messages still in it first (see MoveAllMessagesInFolder) — this
|
||||
// alone doesn't touch esrv_mailbox_messages.
|
||||
func (d *DB) DeleteMailboxFolder(mailboxID int64, name string) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_folders WHERE mailbox_id = ? AND name = ?`, mailboxID, name)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListMailboxFolders returns a mailbox's explicitly-created custom folders — combine
|
||||
// with DistinctFoldersForMailbox (message-derived) for the full folder list, since a
|
||||
// folder can exist via either path (or both).
|
||||
func (d *DB) ListMailboxFolders(mailboxID int64) ([]string, error) {
|
||||
rows, err := d.Query(`SELECT name FROM esrv_mailbox_folders WHERE mailbox_id = ? ORDER BY name`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MoveAllMessagesInFolder reassigns every message in one folder to another — used
|
||||
// when deleting a custom folder, so its messages land in INBOX instead of becoming
|
||||
// orphaned in a folder nothing lists anymore.
|
||||
func (d *DB) MoveAllMessagesInFolder(mailboxID int64, from, to string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailbox_messages SET folder = ? WHERE mailbox_id = ? AND folder = ?`, to, mailboxID, from)
|
||||
return err
|
||||
}
|
||||
@@ -3,36 +3,47 @@ package db
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// InsertMessage records a stored message's index row (the ciphertext itself already
|
||||
// lives at storagePath — see internal/mailstore). Returns the new row's id, which
|
||||
// doubles as the IMAP UID in later milestones.
|
||||
func (d *DB) InsertMessage(mailboxID int64, folder, messageIDHeader, flags string, internalDate time.Time, sizeBytes int64, storagePath string, nonce []byte, cachedFrom, cachedSubject string) (int64, error) {
|
||||
func (d *DB) InsertMessage(mailboxID int64, folder, messageIDHeader, flags string, internalDate time.Time, sizeBytes int64, storagePath string, nonce []byte, cachedFrom, cachedTo, cachedSubject string) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_mailbox_messages
|
||||
(mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, storage_path, nonce, cached_from, cached_subject)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
mailboxID, folder, messageIDHeader, flags, internalDate, sizeBytes, storagePath, nonce, cachedFrom, cachedSubject)
|
||||
(mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, storage_path, nonce, cached_from, cached_to, cached_subject)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
mailboxID, folder, messageIDHeader, flags, internalDate, sizeBytes, storagePath, nonce, cachedFrom, cachedTo, cachedSubject)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *DB) GetMessageByUID(mailboxID, uid int64) (*MailboxMessage, error) {
|
||||
row := d.QueryRow(`SELECT id, mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, cached_from, cached_subject, storage_path, nonce, created_at
|
||||
FROM esrv_mailbox_messages WHERE id = ? AND mailbox_id = ?`, uid, mailboxID)
|
||||
const mailboxMessageColumns = `id, mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, cached_from, cached_to, cached_subject, storage_path, nonce, created_at`
|
||||
|
||||
func scanMailboxMessage(scan func(dest ...any) error) (MailboxMessage, error) {
|
||||
var m MailboxMessage
|
||||
var internalDate, createdAt string
|
||||
if err := row.Scan(&m.ID, &m.MailboxID, &m.Folder, &m.MessageIDHeader, &m.Flags, &internalDate, &m.SizeBytes, &m.CachedFrom, &m.CachedSubject, &m.StoragePath, &m.Nonce, &createdAt); err != nil {
|
||||
err := scan(&m.ID, &m.MailboxID, &m.Folder, &m.MessageIDHeader, &m.Flags, &internalDate, &m.SizeBytes, &m.CachedFrom, &m.CachedTo, &m.CachedSubject, &m.StoragePath, &m.Nonce, &createdAt)
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
m.InternalDate, _ = parseTime(internalDate)
|
||||
m.CreatedAt, _ = parseTime(createdAt)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (d *DB) GetMessageByUID(mailboxID, uid int64) (*MailboxMessage, error) {
|
||||
row := d.QueryRow(`SELECT `+mailboxMessageColumns+` FROM esrv_mailbox_messages WHERE id = ? AND mailbox_id = ?`, uid, mailboxID)
|
||||
m, err := scanMailboxMessage(row.Scan)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
m.InternalDate, _ = parseTime(internalDate)
|
||||
m.CreatedAt, _ = parseTime(createdAt)
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
@@ -41,6 +52,13 @@ func (d *DB) DeleteMessage(mailboxID, uid int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// MoveMessage reassigns a message to a different folder — pure metadata change, the
|
||||
// on-disk ciphertext at storage_path never moves.
|
||||
func (d *DB) MoveMessage(mailboxID, uid int64, newFolder string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailbox_messages SET folder = ? WHERE id = ? AND mailbox_id = ?`, newFolder, uid, mailboxID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListMessageUIDsForMailbox returns every stored message's UID for mailboxID — used by
|
||||
// mailbox removal to delete each one's on-disk ciphertext via mailstore before the
|
||||
// mailbox row itself is removed.
|
||||
@@ -61,28 +79,28 @@ func (d *DB) ListMessageUIDsForMailbox(mailboxID int64) ([]int64, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanMailboxMessages(rows *sql.Rows) ([]MailboxMessage, error) {
|
||||
defer rows.Close()
|
||||
var out []MailboxMessage
|
||||
for rows.Next() {
|
||||
m, err := scanMailboxMessage(rows.Scan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListMessagesForMailbox returns every stored message's full row for mailboxID,
|
||||
// ordered ascending by UID (id) — this ordering IS the IMAP sequence-number mapping
|
||||
// (index+1 == seqNum) that internal/imapserver relies on.
|
||||
func (d *DB) ListMessagesForMailbox(mailboxID int64) ([]MailboxMessage, error) {
|
||||
rows, err := d.Query(`SELECT id, mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, cached_from, cached_subject, storage_path, nonce, created_at
|
||||
FROM esrv_mailbox_messages WHERE mailbox_id = ? ORDER BY id ASC`, mailboxID)
|
||||
rows, err := d.Query(`SELECT `+mailboxMessageColumns+` FROM esrv_mailbox_messages WHERE mailbox_id = ? ORDER BY id ASC`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxMessage
|
||||
for rows.Next() {
|
||||
var m MailboxMessage
|
||||
var internalDate, createdAt string
|
||||
if err := rows.Scan(&m.ID, &m.MailboxID, &m.Folder, &m.MessageIDHeader, &m.Flags, &internalDate, &m.SizeBytes, &m.CachedFrom, &m.CachedSubject, &m.StoragePath, &m.Nonce, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.InternalDate, _ = parseTime(internalDate)
|
||||
m.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
return scanMailboxMessages(rows)
|
||||
}
|
||||
|
||||
// SetMessageFlags overwrites a message's stored IMAP flags (space-separated), scoped
|
||||
@@ -96,22 +114,129 @@ func (d *DB) SetMessageFlags(mailboxID, uid int64, flags string) error {
|
||||
// uses this (not the unscoped version) so a filter rule's move_to_folder action produces
|
||||
// mail that's actually browsable in its own folder, not mixed into every SELECT.
|
||||
func (d *DB) ListMessagesInFolder(mailboxID int64, folder string) ([]MailboxMessage, error) {
|
||||
rows, err := d.Query(`SELECT id, mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, cached_from, cached_subject, storage_path, nonce, created_at
|
||||
FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ? ORDER BY id ASC`, mailboxID, folder)
|
||||
rows, err := d.Query(`SELECT `+mailboxMessageColumns+` FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ? ORDER BY id ASC`, mailboxID, folder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanMailboxMessages(rows)
|
||||
}
|
||||
|
||||
// ListMessagesInFolderPage is ListMessagesInFolder with newest-first pagination, for
|
||||
// the webmail client's folder view — a mailbox can accumulate far more mail than is
|
||||
// reasonable to render in one page.
|
||||
func (d *DB) ListMessagesInFolderPage(mailboxID int64, folder string, offset, limit int) ([]MailboxMessage, error) {
|
||||
rows, err := d.Query(`SELECT `+mailboxMessageColumns+` FROM esrv_mailbox_messages
|
||||
WHERE mailbox_id = ? AND folder = ? ORDER BY id DESC LIMIT ? OFFSET ?`, mailboxID, folder, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanMailboxMessages(rows)
|
||||
}
|
||||
|
||||
// CountMessagesInFolder backs ListMessagesInFolderPage's pagination controls.
|
||||
func (d *DB) CountMessagesInFolder(mailboxID int64, folder string) (int, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ?`, mailboxID, folder).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// escapeLike backslash-escapes a user-supplied LIKE pattern's own special characters
|
||||
// (%, _, and the escape character itself) so a search for e.g. "50% off" or a
|
||||
// filename with an underscore doesn't get interpreted as a wildcard.
|
||||
func escapeLike(s string) string {
|
||||
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// SearchMessagesInFolder finds messages whose cached subject/from/to contain query
|
||||
// (case-insensitive substring, not a full-text index — see the webmail search
|
||||
// handler's doc comment for why that's the deliberate scope here), newest first.
|
||||
// folder == "" searches every folder in the mailbox.
|
||||
func (d *DB) SearchMessagesInFolder(mailboxID int64, folder, query string, offset, limit int) ([]MailboxMessage, error) {
|
||||
like := "%" + escapeLike(query) + "%"
|
||||
args := []any{mailboxID}
|
||||
folderClause := ""
|
||||
if folder != "" {
|
||||
folderClause = "AND folder = ? "
|
||||
args = append(args, folder)
|
||||
}
|
||||
args = append(args, like, like, like, limit, offset)
|
||||
rows, err := d.Query(`SELECT `+mailboxMessageColumns+` FROM esrv_mailbox_messages
|
||||
WHERE mailbox_id = ? `+folderClause+`AND (cached_subject LIKE ? ESCAPE '\' OR cached_from LIKE ? ESCAPE '\' OR cached_to LIKE ? ESCAPE '\')
|
||||
ORDER BY id DESC LIMIT ? OFFSET ?`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanMailboxMessages(rows)
|
||||
}
|
||||
|
||||
// CountSearchMessagesInFolder backs SearchMessagesInFolder's pagination controls.
|
||||
func (d *DB) CountSearchMessagesInFolder(mailboxID int64, folder, query string) (int, error) {
|
||||
like := "%" + escapeLike(query) + "%"
|
||||
args := []any{mailboxID}
|
||||
folderClause := ""
|
||||
if folder != "" {
|
||||
folderClause = "AND folder = ? "
|
||||
args = append(args, folder)
|
||||
}
|
||||
args = append(args, like, like, like)
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_messages
|
||||
WHERE mailbox_id = ? `+folderClause+`AND (cached_subject LIKE ? ESCAPE '\' OR cached_from LIKE ? ESCAPE '\' OR cached_to LIKE ? ESCAPE '\')`, args...).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CountUnreadByFolder returns every folder's unread count in one query (GROUP BY,
|
||||
// not one query per folder) — mirrors isUnread's own check
|
||||
// (internal/webui/webmail_mail.go) but done in SQL so the sidebar's badge counts are
|
||||
// cheap to compute on every folder-view render without a full row fetch. A folder
|
||||
// with zero unread messages simply has no entry in the returned map.
|
||||
func (d *DB) CountUnreadByFolder(mailboxID int64) (map[string]int, error) {
|
||||
rows, err := d.Query(`SELECT folder, COUNT(*) FROM esrv_mailbox_messages
|
||||
WHERE mailbox_id = ? AND flags NOT LIKE '%\Seen%' GROUP BY folder`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxMessage
|
||||
out := map[string]int{}
|
||||
for rows.Next() {
|
||||
var m MailboxMessage
|
||||
var internalDate, createdAt string
|
||||
if err := rows.Scan(&m.ID, &m.MailboxID, &m.Folder, &m.MessageIDHeader, &m.Flags, &internalDate, &m.SizeBytes, &m.CachedFrom, &m.CachedSubject, &m.StoragePath, &m.Nonce, &createdAt); err != nil {
|
||||
var folder string
|
||||
var n int
|
||||
if err := rows.Scan(&folder, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.InternalDate, _ = parseTime(internalDate)
|
||||
m.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, m)
|
||||
out[folder] = n
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SuggestRecipients returns up to 10 distinct addresses (as originally cached — a
|
||||
// display name like "Name <addr@example.com>" is kept as-is, not parsed apart, since
|
||||
// that's exactly what a To/Cc/Bcc field already accepts) this mailbox has previously
|
||||
// exchanged mail with — its own Sent "To" list plus INBOX "From" senders — whose
|
||||
// value contains prefix. Backs the compose recipient autocomplete; deliberately
|
||||
// reuses message history already stored rather than a dedicated contacts table.
|
||||
func (d *DB) SuggestRecipients(mailboxID int64, prefix string) ([]string, error) {
|
||||
like := "%" + escapeLike(prefix) + "%"
|
||||
rows, err := d.Query(`
|
||||
SELECT addr FROM (
|
||||
SELECT cached_to AS addr FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = 'Sent' AND cached_to != ''
|
||||
UNION
|
||||
SELECT cached_from AS addr FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = 'INBOX' AND cached_from != ''
|
||||
)
|
||||
WHERE addr LIKE ? ESCAPE '\'
|
||||
ORDER BY addr LIMIT 10`, mailboxID, mailboxID, like)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var addr string
|
||||
if err := rows.Scan(&addr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, addr)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// CreatePGPIdentity adds a new PGP identity for a mailbox — a mailbox may hold
|
||||
// several at once (see esrv_mailbox_pgp_identities in schema.go).
|
||||
func (d *DB) CreatePGPIdentity(mailboxID int64, label, email, fingerprint, publicKeyArmor, privateKeyArmor string) (int64, error) {
|
||||
res, err := d.Exec(`
|
||||
INSERT INTO esrv_mailbox_pgp_identities (mailbox_id, label, email, fingerprint, public_key_armor, private_key_armor)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, mailboxID, label, email, fingerprint, publicKeyArmor, privateKeyArmor)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// ListPGPIdentities returns a mailbox's PGP identities, most recent first.
|
||||
func (d *DB) ListPGPIdentities(mailboxID int64) ([]MailboxPGPIdentity, error) {
|
||||
rows, err := d.Query(`SELECT id, mailbox_id, label, email, fingerprint, public_key_armor, private_key_armor, created_at
|
||||
FROM esrv_mailbox_pgp_identities WHERE mailbox_id = ? ORDER BY created_at DESC, id DESC`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxPGPIdentity
|
||||
for rows.Next() {
|
||||
var id MailboxPGPIdentity
|
||||
if err := rows.Scan(&id.ID, &id.MailboxID, &id.Label, &id.Email, &id.Fingerprint, &id.PublicKeyArmor, &id.PrivateKeyArmor, &id.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetPGPIdentity returns nil, nil if no such identity exists for this mailbox —
|
||||
// scoped to mailboxID so one mailbox owner can't reach another's identity by
|
||||
// guessing its ID.
|
||||
func (d *DB) GetPGPIdentity(mailboxID, identityID int64) (*MailboxPGPIdentity, error) {
|
||||
row := d.QueryRow(`SELECT id, mailbox_id, label, email, fingerprint, public_key_armor, private_key_armor, created_at
|
||||
FROM esrv_mailbox_pgp_identities WHERE mailbox_id = ? AND id = ?`, mailboxID, identityID)
|
||||
var id MailboxPGPIdentity
|
||||
if err := row.Scan(&id.ID, &id.MailboxID, &id.Label, &id.Email, &id.Fingerprint, &id.PublicKeyArmor, &id.PrivateKeyArmor, &id.CreatedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
// DeletePGPIdentity removes one identity, scoped to mailboxID.
|
||||
func (d *DB) DeletePGPIdentity(mailboxID, identityID int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_pgp_identities WHERE mailbox_id = ? AND id = ?`, mailboxID, identityID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpsertPGPContact adds a contact's PGP public key, replacing any existing key
|
||||
// already on file for that email (e.g. after the contact rotates their key).
|
||||
func (d *DB) UpsertPGPContact(mailboxID int64, email, label, fingerprint, publicKeyArmor string) error {
|
||||
_, err := d.Exec(`
|
||||
INSERT INTO esrv_mailbox_pgp_contacts (mailbox_id, email, label, fingerprint, public_key_armor)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(mailbox_id, email) DO UPDATE SET label = excluded.label, fingerprint = excluded.fingerprint, public_key_armor = excluded.public_key_armor
|
||||
`, mailboxID, email, label, fingerprint, publicKeyArmor)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetPGPContact returns nil, nil if no key is on file for that email.
|
||||
func (d *DB) GetPGPContact(mailboxID int64, email string) (*MailboxPGPContact, error) {
|
||||
row := d.QueryRow(`SELECT id, mailbox_id, email, label, public_key_armor, fingerprint, created_at FROM esrv_mailbox_pgp_contacts WHERE mailbox_id = ? AND email = ?`, mailboxID, email)
|
||||
var c MailboxPGPContact
|
||||
if err := row.Scan(&c.ID, &c.MailboxID, &c.Email, &c.Label, &c.PublicKeyArmor, &c.Fingerprint, &c.CreatedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// GetPGPContactByID returns nil, nil if no such contact exists for this mailbox —
|
||||
// scoped to mailboxID so one mailbox owner can't reach another's contact by guessing
|
||||
// its ID. Used by compose's recipient-key picker, which selects contacts by ID
|
||||
// rather than matching a To/Cc/Bcc address against GetPGPContact's stored email.
|
||||
func (d *DB) GetPGPContactByID(mailboxID, contactID int64) (*MailboxPGPContact, error) {
|
||||
row := d.QueryRow(`SELECT id, mailbox_id, email, label, public_key_armor, fingerprint, created_at FROM esrv_mailbox_pgp_contacts WHERE mailbox_id = ? AND id = ?`, mailboxID, contactID)
|
||||
var c MailboxPGPContact
|
||||
if err := row.Scan(&c.ID, &c.MailboxID, &c.Email, &c.Label, &c.PublicKeyArmor, &c.Fingerprint, &c.CreatedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// ListPGPContacts returns a mailbox's collected contact keys, alphabetical by email.
|
||||
func (d *DB) ListPGPContacts(mailboxID int64) ([]MailboxPGPContact, error) {
|
||||
rows, err := d.Query(`SELECT id, mailbox_id, email, label, public_key_armor, fingerprint, created_at FROM esrv_mailbox_pgp_contacts WHERE mailbox_id = ? ORDER BY email`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxPGPContact
|
||||
for rows.Next() {
|
||||
var c MailboxPGPContact
|
||||
if err := rows.Scan(&c.ID, &c.MailboxID, &c.Email, &c.Label, &c.PublicKeyArmor, &c.Fingerprint, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeletePGPContact removes one contact key, scoped to mailboxID so one mailbox
|
||||
// owner can't delete another's contact by guessing its ID.
|
||||
func (d *DB) DeletePGPContact(mailboxID, contactID int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_pgp_contacts WHERE mailbox_id = ? AND id = ?`, mailboxID, contactID)
|
||||
return err
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package db
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
func (d *DB) ListRulesForMailbox(mailboxID int64) ([]MailboxFilterRule, error) {
|
||||
rows, err := d.Query(`SELECT id, mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value, is_active, created_at
|
||||
rows, err := d.Query(`SELECT id, mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value, is_active, conditions_json, match_type, created_at
|
||||
FROM esrv_mailbox_filter_rules WHERE mailbox_id = ? ORDER BY priority ASC, id ASC`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -11,7 +13,7 @@ func (d *DB) ListRulesForMailbox(mailboxID int64) ([]MailboxFilterRule, error) {
|
||||
for rows.Next() {
|
||||
var r MailboxFilterRule
|
||||
var createdAt string
|
||||
if err := rows.Scan(&r.ID, &r.MailboxID, &r.Priority, &r.ConditionField, &r.ConditionOp, &r.ConditionValue, &r.Action, &r.ActionValue, &r.IsActive, &createdAt); err != nil {
|
||||
if err := rows.Scan(&r.ID, &r.MailboxID, &r.Priority, &r.ConditionField, &r.ConditionOp, &r.ConditionValue, &r.Action, &r.ActionValue, &r.IsActive, &r.ConditionsJSON, &r.MatchType, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.CreatedAt, _ = parseTime(createdAt)
|
||||
@@ -20,9 +22,28 @@ func (d *DB) ListRulesForMailbox(mailboxID int64) ([]MailboxFilterRule, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreateRule creates a single-condition rule — a thin wrapper over CreateRuleMulti
|
||||
// for the common one-condition case (and for existing callers/tests written before
|
||||
// multi-condition rules existed).
|
||||
func (d *DB) CreateRule(mailboxID int64, priority int, field, op, value, action, actionValue string) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_mailbox_filter_rules (mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`, mailboxID, priority, field, op, value, action, actionValue)
|
||||
return d.CreateRuleMulti(mailboxID, priority, []RuleCondition{{Field: field, Op: op, Value: value}}, "all", action, actionValue)
|
||||
}
|
||||
|
||||
// CreateRuleMulti creates a rule with one or more conditions combined per matchType
|
||||
// ("all"=AND, "any"=OR, defaulting to "all" for anything else). The first condition
|
||||
// also mirrors into the legacy condition_field/op/value columns so old code paths
|
||||
// reading them directly still see something sane.
|
||||
func (d *DB) CreateRuleMulti(mailboxID int64, priority int, conditions []RuleCondition, matchType, action, actionValue string) (int64, error) {
|
||||
if matchType != "any" {
|
||||
matchType = "all"
|
||||
}
|
||||
conditionsJSON, err := json.Marshal(conditions)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
first := conditions[0]
|
||||
res, err := d.Exec(`INSERT INTO esrv_mailbox_filter_rules (mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value, conditions_json, match_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, mailboxID, priority, first.Field, first.Op, first.Value, action, actionValue, string(conditionsJSON), matchType)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CreateSMIMEIdentity adds a new S/MIME identity for a mailbox — a mailbox may hold
|
||||
// several at once (see esrv_mailbox_smime_identities in schema.go).
|
||||
func (d *DB) CreateSMIMEIdentity(mailboxID int64, certPEM, keyPEM string, notAfter time.Time) (int64, error) {
|
||||
res, err := d.Exec(`
|
||||
INSERT INTO esrv_mailbox_smime_identities (mailbox_id, cert_pem, key_pem, not_after)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, mailboxID, certPEM, keyPEM, notAfter)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// ListSMIMEIdentities returns a mailbox's S/MIME identities, most recent first.
|
||||
func (d *DB) ListSMIMEIdentities(mailboxID int64) ([]MailboxSMIMEIdentity, error) {
|
||||
rows, err := d.Query(`SELECT id, mailbox_id, cert_pem, key_pem, not_after, created_at
|
||||
FROM esrv_mailbox_smime_identities WHERE mailbox_id = ? ORDER BY created_at DESC, id DESC`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxSMIMEIdentity
|
||||
for rows.Next() {
|
||||
var id MailboxSMIMEIdentity
|
||||
if err := rows.Scan(&id.ID, &id.MailboxID, &id.CertPEM, &id.KeyPEM, &id.NotAfter, &id.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetSMIMEIdentity returns nil, nil if no such identity exists for this mailbox —
|
||||
// scoped to mailboxID so one mailbox owner can't reach another's identity by
|
||||
// guessing its ID.
|
||||
func (d *DB) GetSMIMEIdentity(mailboxID, identityID int64) (*MailboxSMIMEIdentity, error) {
|
||||
row := d.QueryRow(`SELECT id, mailbox_id, cert_pem, key_pem, not_after, created_at
|
||||
FROM esrv_mailbox_smime_identities WHERE mailbox_id = ? AND id = ?`, mailboxID, identityID)
|
||||
var id MailboxSMIMEIdentity
|
||||
if err := row.Scan(&id.ID, &id.MailboxID, &id.CertPEM, &id.KeyPEM, &id.NotAfter, &id.CreatedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
// DeleteSMIMEIdentity removes one identity, scoped to mailboxID.
|
||||
func (d *DB) DeleteSMIMEIdentity(mailboxID, identityID int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_smime_identities WHERE mailbox_id = ? AND id = ?`, mailboxID, identityID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpsertSMIMEContact adds a contact certificate, replacing any existing certificate
|
||||
// already on file for that email (e.g. after the contact renews their cert).
|
||||
func (d *DB) UpsertSMIMEContact(mailboxID int64, email, certPEM string) error {
|
||||
_, err := d.Exec(`
|
||||
INSERT INTO esrv_mailbox_smime_contacts (mailbox_id, email, cert_pem)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(mailbox_id, email) DO UPDATE SET cert_pem = excluded.cert_pem
|
||||
`, mailboxID, email, certPEM)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSMIMEContact returns nil, nil if no certificate is on file for that email.
|
||||
func (d *DB) GetSMIMEContact(mailboxID int64, email string) (*MailboxSMIMEContact, error) {
|
||||
row := d.QueryRow(`SELECT id, mailbox_id, email, cert_pem, created_at FROM esrv_mailbox_smime_contacts WHERE mailbox_id = ? AND email = ?`, mailboxID, email)
|
||||
var c MailboxSMIMEContact
|
||||
if err := row.Scan(&c.ID, &c.MailboxID, &c.Email, &c.CertPEM, &c.CreatedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// ListSMIMEContacts returns a mailbox's collected contact certificates, alphabetical
|
||||
// by email.
|
||||
func (d *DB) ListSMIMEContacts(mailboxID int64) ([]MailboxSMIMEContact, error) {
|
||||
rows, err := d.Query(`SELECT id, mailbox_id, email, cert_pem, created_at FROM esrv_mailbox_smime_contacts WHERE mailbox_id = ? ORDER BY email`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxSMIMEContact
|
||||
for rows.Next() {
|
||||
var c MailboxSMIMEContact
|
||||
if err := rows.Scan(&c.ID, &c.MailboxID, &c.Email, &c.CertPEM, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteSMIMEContact removes one contact certificate, scoped to mailboxID so one
|
||||
// mailbox owner can't delete another's contact by guessing its ID.
|
||||
func (d *DB) DeleteSMIMEContact(mailboxID, contactID int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_smime_contacts WHERE mailbox_id = ? AND id = ?`, mailboxID, contactID)
|
||||
return err
|
||||
}
|
||||
@@ -195,6 +195,11 @@ func (d *DB) RemoveMailboxCascade(id int64) error {
|
||||
`DELETE FROM esrv_mailbox_filter_rules WHERE mailbox_id = ?`,
|
||||
`DELETE FROM esrv_mailbox_sessions WHERE mailbox_id = ?`,
|
||||
`DELETE FROM esrv_mailbox_webauthn_credentials WHERE mailbox_id = ?`,
|
||||
`DELETE FROM esrv_mailbox_folders WHERE mailbox_id = ?`,
|
||||
`DELETE FROM esrv_mailbox_smime_identities WHERE mailbox_id = ?`,
|
||||
`DELETE FROM esrv_mailbox_smime_contacts WHERE mailbox_id = ?`,
|
||||
`DELETE FROM esrv_mailbox_pgp_identities WHERE mailbox_id = ?`,
|
||||
`DELETE FROM esrv_mailbox_pgp_contacts WHERE mailbox_id = ?`,
|
||||
`DELETE FROM esrv_mailbox_messages WHERE mailbox_id = ?`,
|
||||
`DELETE FROM esrv_mailboxes WHERE id = ?`,
|
||||
} {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Mailbox is a real, IMAP-retrievable local mailbox — distinct from Sender (which is
|
||||
// relay/auth-only). PasswordHash authenticates the self-service web portal only;
|
||||
@@ -68,6 +71,8 @@ type MailboxAllowBlockEntry struct {
|
||||
}
|
||||
|
||||
// MailboxFilterRule is one priority-ordered, first-match-wins delivery rule.
|
||||
// ConditionField/Op/Value are the legacy single-condition columns; ConditionsJSON
|
||||
// (when non-empty) is the current multi-condition representation — see Conditions().
|
||||
type MailboxFilterRule struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
@@ -75,12 +80,40 @@ type MailboxFilterRule struct {
|
||||
ConditionField string // "from" | "to" | "subject"
|
||||
ConditionOp string // "contains" | "equals" | "starts_with"
|
||||
ConditionValue string
|
||||
Action string // "move_to_folder" | "delete" | "mark_read"
|
||||
Action string // "move_to_folder" | "delete" | "mark_read" | "mark_as_spam"
|
||||
ActionValue string
|
||||
IsActive bool
|
||||
ConditionsJSON string
|
||||
MatchType string // "all" (AND, default) | "any" (OR)
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// RuleCondition is one condition within a filter rule's "if" clause.
|
||||
type RuleCondition struct {
|
||||
Field string `json:"field"`
|
||||
Op string `json:"op"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Conditions returns this rule's conditions and how they combine ("all"=AND,
|
||||
// "any"=OR) — parses ConditionsJSON when present, falling back to the single legacy
|
||||
// condition_field/op/value columns for rules created before multi-condition support
|
||||
// existed. Shared by mailstore.ApplyRules (evaluation) and the webui (display), so
|
||||
// both stay in sync with the same fallback rule.
|
||||
func (r MailboxFilterRule) Conditions() ([]RuleCondition, string) {
|
||||
if r.ConditionsJSON != "" {
|
||||
var parsed []RuleCondition
|
||||
if err := json.Unmarshal([]byte(r.ConditionsJSON), &parsed); err == nil && len(parsed) > 0 {
|
||||
matchType := r.MatchType
|
||||
if matchType != "any" {
|
||||
matchType = "all"
|
||||
}
|
||||
return parsed, matchType
|
||||
}
|
||||
}
|
||||
return []RuleCondition{{Field: r.ConditionField, Op: r.ConditionOp, Value: r.ConditionValue}}, "all"
|
||||
}
|
||||
|
||||
// MailboxAppPassword is the only credential an IMAP/SMTP client ever uses. Plaintext
|
||||
// is shown once at creation and never stored. ExpiresAt is nil for a password that
|
||||
// never expires (the default).
|
||||
@@ -95,8 +128,10 @@ type MailboxAppPassword struct {
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
// MailboxMessage is one stored message. CachedFrom/CachedSubject are plaintext by
|
||||
// design (see schema.go); the rest of the message lives encrypted at StoragePath.
|
||||
// MailboxMessage is one stored message. CachedFrom/CachedTo/CachedSubject are
|
||||
// plaintext by design (see schema.go); the rest of the message lives encrypted at
|
||||
// StoragePath. CachedTo exists purely so folder listings (e.g. Sent) can show the
|
||||
// recipient without decrypting every message just to render a list.
|
||||
type MailboxMessage struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
@@ -106,8 +141,60 @@ type MailboxMessage struct {
|
||||
InternalDate time.Time
|
||||
SizeBytes int64
|
||||
CachedFrom string
|
||||
CachedTo string
|
||||
CachedSubject string
|
||||
StoragePath string
|
||||
Nonce []byte
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MailboxSMIMEIdentity is one of a mailbox's own S/MIME certificate + private key
|
||||
// pairs — a mailbox may hold several. Both halves are stored plain: S/MIME is
|
||||
// sign-only in this codebase, so the key never protects anything beyond what the
|
||||
// server already has access to.
|
||||
type MailboxSMIMEIdentity struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
CertPEM string
|
||||
KeyPEM string
|
||||
NotAfter time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MailboxSMIMEContact is another address's public certificate a mailbox owner has
|
||||
// collected, either added by hand or auto-captured off a verified signature.
|
||||
type MailboxSMIMEContact struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
Email string
|
||||
CertPEM string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MailboxPGPIdentity is one of a mailbox's own PGP keypairs — a mailbox may hold
|
||||
// several. PrivateKeyArmor is stored exactly as the pgp package serializes it,
|
||||
// already passphrase-protected via OpenPGP's own native key-encryption format (no
|
||||
// separate ciphertext/nonce/salt columns needed, unlike MailboxSMIMEIdentity).
|
||||
// Label is a free-text user note distinguishing keys (PGP keys have no expiry).
|
||||
type MailboxPGPIdentity struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
Label string
|
||||
Email string
|
||||
Fingerprint string
|
||||
PublicKeyArmor string
|
||||
PrivateKeyArmor string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MailboxPGPContact is another address's PGP public key a mailbox owner has
|
||||
// collected — mirrors MailboxSMIMEContact.
|
||||
type MailboxPGPContact struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
Email string
|
||||
Label string
|
||||
PublicKeyArmor string
|
||||
Fingerprint string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -132,6 +132,33 @@ func (d *DB) LogAuthAttempt(authType, identifier, ipAddress string, success bool
|
||||
return err
|
||||
}
|
||||
|
||||
// CountRecentFailedAttempts counts failed esrv_auth_logs rows for one identifier
|
||||
// (independent of which IP each attempt came from — a distributed credential-
|
||||
// stuffing attempt against a single account should still trip this) within
|
||||
// authType and since the given cutoff, powering the per-account lockout in
|
||||
// internal/webui/login.go and webmail_login.go.
|
||||
func (d *DB) CountRecentFailedAttempts(authType, identifier string, since time.Time) (int, error) {
|
||||
var n int
|
||||
// created_at is populated by SQLite's own CURRENT_TIMESTAMP: a plain
|
||||
// "YYYY-MM-DD HH:MM:SS" UTC string, space-separated, no fractional seconds, no
|
||||
// offset. modernc.org/sqlite instead binds a Go time.Time query parameter as
|
||||
// RFC3339Nano with a zone offset (e.g. "2026-08-14T06:57:50.497566315+01:00") —
|
||||
// a live check confirmed this by inserting a time.Time into a real column and
|
||||
// reading the stored text back. That format is structurally different from
|
||||
// CURRENT_TIMESTAMP's own (different separator, precision, and offset), so a
|
||||
// plain text >= comparison between the two doesn't reflect chronological order at
|
||||
// all (confirmed: it silently matched zero rows). Two Go-bound time.Time values
|
||||
// compared against each other DO work correctly, since the driver formats both
|
||||
// identically — this only breaks when one side is a raw SQL CURRENT_TIMESTAMP
|
||||
// default and the other is a Go-bound parameter, which happens on THIS column but
|
||||
// nowhere else in this codebase (checked every other DATETIME comparison).
|
||||
// Formatting since into CURRENT_TIMESTAMP's exact layout makes both sides match.
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_auth_logs
|
||||
WHERE auth_type = ? AND identifier = ? AND success = 0 AND created_at >= ?`,
|
||||
authType, identifier, since.UTC().Format("2006-01-02 15:04:05")).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func parseTime(s string) (time.Time, error) {
|
||||
for _, layout := range []string{"2006-01-02 15:04:05.999999999-07:00", "2006-01-02 15:04:05", time.RFC3339} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
|
||||
+141
-1
@@ -85,6 +85,40 @@ CREATE TABLE IF NOT EXISTS esrv_auth_logs (
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Matches CountRecentFailedAttempts' lockout-check query.
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_logs_lockout ON esrv_auth_logs(identifier, auth_type, created_at);
|
||||
-- Matches CountFailedAuthAttemptsByIP's abuse-detection query (internal/abuseguard) —
|
||||
-- a different access pattern than the lockout index above (by IP, not identifier).
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_logs_by_ip ON esrv_auth_logs(ip_address, created_at);
|
||||
|
||||
-- Temporary IP blocks, auto-created by internal/abuseguard when one IP racks up too
|
||||
-- many failed SMTP/IMAP auth attempts within a short window (see
|
||||
-- CountFailedAuthAttemptsByIP), or manually by an admin from the Blacklist page.
|
||||
-- offense_count drives escalating block duration on repeat offenders — see
|
||||
-- BlacklistIP's doc comment for the exact formula. Deliberately separate from
|
||||
-- esrv_whitelisted_ips (which authorizes unauthenticated relay for a domain, a
|
||||
-- completely different concern) and from the web login lockout in
|
||||
-- internal/webui/ratelimit.go (which never touches this table).
|
||||
CREATE TABLE IF NOT EXISTS esrv_ip_blacklist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip_address TEXT NOT NULL UNIQUE,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
offense_count INTEGER NOT NULL DEFAULT 1,
|
||||
manual INTEGER NOT NULL DEFAULT 0,
|
||||
blacklisted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ip_blacklist_expiry ON esrv_ip_blacklist(ip_address, expires_at);
|
||||
|
||||
-- IPs exempt from abuse detection (internal/abuseguard never blacklists or blocks
|
||||
-- these) — again deliberately separate from esrv_whitelisted_ips.
|
||||
CREATE TABLE IF NOT EXISTS esrv_ip_abuse_whitelist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip_address TEXT NOT NULL UNIQUE,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_dkim_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
|
||||
@@ -239,6 +273,12 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_allowblock (
|
||||
-- Simple first-match-wins filter rules, evaluated in priority order (lower first) at
|
||||
-- delivery time, before a message is encrypted and stored — so from/to/subject
|
||||
-- matching works against the real message, not just the plaintext cache columns below.
|
||||
-- condition_field/op/value are the legacy single-condition columns, kept for rows
|
||||
-- created before multi-condition support existed. Every rule created since then
|
||||
-- stores its full condition list in conditions_json (a JSON array of
|
||||
-- {field,op,value}) instead, combined per match_type ("all"=AND, "any"=OR); a rule
|
||||
-- with an empty conditions_json falls back to the legacy columns as a single
|
||||
-- condition — see MailboxFilterRule.Conditions() in mailbox_models.go.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_filter_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
@@ -246,9 +286,11 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_filter_rules (
|
||||
condition_field TEXT NOT NULL CHECK(condition_field IN ('from','to','subject')),
|
||||
condition_op TEXT NOT NULL CHECK(condition_op IN ('contains','equals','starts_with')),
|
||||
condition_value TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK(action IN ('move_to_folder','delete','mark_read')),
|
||||
action TEXT NOT NULL CHECK(action IN ('move_to_folder','delete','mark_read','mark_as_spam')),
|
||||
action_value TEXT NOT NULL DEFAULT '',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
conditions_json TEXT NOT NULL DEFAULT '',
|
||||
match_type TEXT NOT NULL DEFAULT 'all',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -265,11 +307,96 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_messages (
|
||||
internal_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
cached_from TEXT NOT NULL DEFAULT '',
|
||||
cached_to TEXT NOT NULL DEFAULT '',
|
||||
cached_subject TEXT NOT NULL DEFAULT '',
|
||||
storage_path TEXT NOT NULL,
|
||||
nonce BLOB NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Matches the folder view's exact WHERE mailbox_id = ? AND folder = ? ORDER BY
|
||||
-- internal_date pattern — the single hottest query in the whole webmail client, and
|
||||
-- previously unindexed (this schema had no indexes at all before this one).
|
||||
CREATE INDEX IF NOT EXISTS idx_mailbox_messages_folder ON esrv_mailbox_messages(mailbox_id, folder, internal_date);
|
||||
|
||||
-- Explicit record of a mailbox's custom folders, so a freshly created (still empty)
|
||||
-- one shows up in the folder list — esrv_mailbox_messages.folder alone can only prove
|
||||
-- a folder exists once it holds at least one message. Standard folders (INBOX, Spam,
|
||||
-- Sent, Drafts, Trash) are never stored here; they're always shown by the webui
|
||||
-- regardless of this table.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
name TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(mailbox_id, name)
|
||||
);
|
||||
|
||||
-- A mailbox's own S/MIME identities — a mailbox may hold several at once (e.g. one
|
||||
-- per external party it corresponds with, or after rotating an expiring one while
|
||||
-- keeping the old one around to read old mail). S/MIME is sign-only in this
|
||||
-- codebase (PGP handles encryption — see esrv_mailbox_pgp_identities below), so the
|
||||
-- private key is stored plain, same trust model as the PGP private key column: the
|
||||
-- server already holds everything needed to use it, with no separate
|
||||
-- passphrase-derived wrapper (that was tried and removed — see git history — it was
|
||||
-- pure friction for an asset that was never actually protecting anything a server
|
||||
-- compromise wouldn't already expose).
|
||||
-- Superseded esrv_mailbox_smime_identity (singular, one auto-unwrapped identity per
|
||||
-- mailbox) is left in place unused rather than migrated.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_smime_identities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
cert_pem TEXT NOT NULL,
|
||||
key_pem TEXT NOT NULL,
|
||||
not_after DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Other people's public certificates a mailbox owner has collected — added by hand
|
||||
-- or auto-captured off a verified incoming signature. Used to offer "Encrypt" for a
|
||||
-- recipient in compose and to flag a known signer on read; never chain-validated
|
||||
-- against a CA (see internal/smime package doc).
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_smime_contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
email TEXT NOT NULL,
|
||||
cert_pem TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(mailbox_id, email)
|
||||
);
|
||||
|
||||
-- A mailbox's own PGP keys — used only for encryption in this codebase (S/MIME,
|
||||
-- above, handles signing). A mailbox may hold several. OpenPGP's own private key
|
||||
-- packet format carries its own passphrase protection natively (see
|
||||
-- pgp.GenerateKeyPair's doc comment) — private_key_armor is stored exactly as the
|
||||
-- library serializes it, already passphrase-protected (unlike S/MIME's key_pem,
|
||||
-- which is stored plain).
|
||||
-- label is a free-text user note (PGP keys have no expiry to distinguish them by the
|
||||
-- way generated S/MIME certs do).
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_pgp_identities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
public_key_armor TEXT NOT NULL,
|
||||
private_key_armor TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Other people's PGP public keys a mailbox owner has collected, added by hand —
|
||||
-- mirrors esrv_mailbox_smime_contacts. Used to offer "Encrypt (PGP)" for a
|
||||
-- recipient in compose.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_pgp_contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
email TEXT NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
public_key_armor TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(mailbox_id, email)
|
||||
);
|
||||
`
|
||||
|
||||
// migrateAddedColumns best-effort ALTER TABLEs the columns added to esrv_domains
|
||||
@@ -292,6 +419,19 @@ func migrateAddedColumns(db *sql.DB) {
|
||||
`ALTER TABLE esrv_admin_users ADD COLUMN must_change_username INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE esrv_domains ADD COLUMN mfa_exempt INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE esrv_mailboxes ADD COLUMN mfa_exempt INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE esrv_mailbox_messages ADD COLUMN cached_to TEXT NOT NULL DEFAULT ''`,
|
||||
// conditions_json/match_type are retrofittable via ALTER TABLE, but the action
|
||||
// CHECK constraint (adding 'mark_as_spam') is not — SQLite doesn't support
|
||||
// altering a CHECK on an existing table. A dev DB created before this change
|
||||
// would need recreating to accept a mark_as_spam rule; a fresh install gets it
|
||||
// for free from the CREATE TABLE above.
|
||||
`ALTER TABLE esrv_mailbox_filter_rules ADD COLUMN conditions_json TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE esrv_mailbox_filter_rules ADD COLUMN match_type TEXT NOT NULL DEFAULT 'all'`,
|
||||
// key_pem replaces the old passphrase-wrapped key_ciphertext/key_nonce/key_salt
|
||||
// columns — a dev DB with pre-existing identities just loses their (now
|
||||
// unrecoverable-without-code-that-no-longer-exists) keys, same "not migrated"
|
||||
// treatment as the singular-table identities before them.
|
||||
`ALTER TABLE esrv_mailbox_smime_identities ADD COLUMN key_pem TEXT NOT NULL DEFAULT ''`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
db.Exec(stmt)
|
||||
|
||||
Reference in New Issue
Block a user