MFA fix, added IP blacklist, update webmail client

This commit is contained in:
2026-08-14 13:04:55 +01:00
parent 6063f95504
commit 892f366a16
122 changed files with 13362 additions and 251 deletions
+184
View File
@@ -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
}
+99
View File
@@ -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")
}
}
+45
View File
@@ -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
}
+159 -34
View File
@@ -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()
}
+125
View File
@@ -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
}
+25 -4
View File
@@ -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
}
+111
View File
@@ -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
}
+5
View File
@@ -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 = ?`,
} {
+91 -4
View File
@@ -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
}
+27
View File
@@ -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
View File
@@ -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)