added IMAP, LetsEncrypt, update layout
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func (d *DB) ListAliasesForMailbox(mailboxID int64) ([]MailboxAlias, error) {
|
||||
rows, err := d.Query(`SELECT id, mailbox_id, email, domain_id, can_send_as, is_active, created_at
|
||||
FROM esrv_mailbox_aliases WHERE mailbox_id = ? ORDER BY email`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxAlias
|
||||
for rows.Next() {
|
||||
var a MailboxAlias
|
||||
var createdAt string
|
||||
if err := rows.Scan(&a.ID, &a.MailboxID, &a.Email, &a.DomainID, &a.CanSendAs, &a.IsActive, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetAliasByEmail mirrors GetMailboxByEmail: case-insensitive, active-only. Used by
|
||||
// mailstore.ResolveRecipient when a recipient address doesn't match any mailbox's
|
||||
// primary address directly.
|
||||
func (d *DB) GetAliasByEmail(email string) (*MailboxAlias, error) {
|
||||
row := d.QueryRow(`SELECT id, mailbox_id, email, domain_id, can_send_as, is_active, created_at
|
||||
FROM esrv_mailbox_aliases WHERE lower(email) = lower(?) AND is_active = 1`, email)
|
||||
var a MailboxAlias
|
||||
var createdAt string
|
||||
if err := row.Scan(&a.ID, &a.MailboxID, &a.Email, &a.DomainID, &a.CanSendAs, &a.IsActive, &createdAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
a.CreatedAt, _ = parseTime(createdAt)
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (d *DB) AliasEmailExists(email string, excludeID int64) (bool, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_aliases WHERE lower(email) = lower(?) AND id != ?`, email, excludeID).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func (d *DB) CreateAlias(mailboxID int64, email string, domainID int64, canSendAs bool) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_mailbox_aliases (mailbox_id, email, domain_id, can_send_as) VALUES (?, ?, ?, ?)`,
|
||||
mailboxID, email, domainID, canSendAs)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// RemoveAlias deletes an alias, scoped to mailboxID so a caller can't remove one
|
||||
// belonging to a different mailbox by guessing its id (mirrors RemoveAppPassword).
|
||||
func (d *DB) RemoveAlias(id, mailboxID int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_aliases WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||
return err
|
||||
}
|
||||
|
||||
// MailboxCanSendAs reports whether address is an active, send-as-enabled alias owned
|
||||
// by mailboxID — the authorization check for an authenticated mailbox's MAIL FROM
|
||||
// (see smtpserver's validateSenderAuthorization).
|
||||
func (d *DB) MailboxCanSendAs(mailboxID int64, address string) (bool, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_aliases WHERE mailbox_id = ? AND lower(email) = lower(?) AND can_send_as = 1 AND is_active = 1`,
|
||||
mailboxID, address).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"math/big"
|
||||
"time"
|
||||
)
|
||||
|
||||
const appPasswordChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
|
||||
|
||||
// GenerateAppPassword returns a random secret for IMAP/SMTP client login — the only
|
||||
// credential those protocols ever see, since AUTH has no interactive MFA step (see
|
||||
// esrv_mailbox_app_passwords in schema.go). Floors at 25 chars regardless of minLen.
|
||||
func GenerateAppPassword(minLen int) string {
|
||||
if minLen < 25 {
|
||||
minLen = 25
|
||||
}
|
||||
b := make([]byte, minLen)
|
||||
max := big.NewInt(int64(len(appPasswordChars)))
|
||||
for i := range b {
|
||||
n, _ := rand.Int(rand.Reader, max)
|
||||
b[i] = appPasswordChars[n.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (d *DB) ListAppPasswordsForMailbox(mailboxID int64) ([]MailboxAppPassword, error) {
|
||||
rows, err := d.Query(`SELECT id, mailbox_id, label, password_hash, is_active, created_at, last_used_at
|
||||
FROM esrv_mailbox_app_passwords WHERE mailbox_id = ? ORDER BY created_at`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxAppPassword
|
||||
for rows.Next() {
|
||||
var p MailboxAppPassword
|
||||
var createdAt string
|
||||
var lastUsedAt sql.NullString
|
||||
if err := rows.Scan(&p.ID, &p.MailboxID, &p.Label, &p.PasswordHash, &p.IsActive, &createdAt, &lastUsedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.CreatedAt, _ = parseTime(createdAt)
|
||||
if lastUsedAt.Valid {
|
||||
t, _ := parseTime(lastUsedAt.String)
|
||||
p.LastUsedAt = &t
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) CreateAppPassword(mailboxID int64, label, passwordHash string) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_mailbox_app_passwords (mailbox_id, label, password_hash) VALUES (?, ?, ?)`,
|
||||
mailboxID, label, passwordHash)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// VerifyMailboxAppPassword resolves the mailbox by its primary email (never an alias)
|
||||
// and bcrypt-checks it against every active app password. A mailbox's app-password
|
||||
// list is small, so a linear scan needs no index. Returns (nil, nil) on no match.
|
||||
func (d *DB) VerifyMailboxAppPassword(email, password string) (*Mailbox, error) {
|
||||
mbox, err := d.GetMailboxByEmail(email)
|
||||
if err != nil || mbox == nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := d.Query(`SELECT id, password_hash FROM esrv_mailbox_app_passwords WHERE mailbox_id = ? AND is_active = 1`, mbox.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var matchedID int64
|
||||
found := false
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var hash string
|
||||
if err := rows.Scan(&id, &hash); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if CheckPassword(password, hash) {
|
||||
matchedID = id
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
rowsErr := rows.Err()
|
||||
// Must close before the UPDATE below: the connection pool is capped to one
|
||||
// connection (see schema.go's Open), so an Exec while these rows are still open
|
||||
// would deadlock waiting for a connection that rows itself is holding.
|
||||
rows.Close()
|
||||
if rowsErr != nil {
|
||||
return nil, rowsErr
|
||||
}
|
||||
if !found {
|
||||
return nil, nil
|
||||
}
|
||||
if _, err := d.Exec(`UPDATE esrv_mailbox_app_passwords SET last_used_at = ? WHERE id = ?`, time.Now(), matchedID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mbox, nil
|
||||
}
|
||||
|
||||
func (d *DB) SetAppPasswordActive(id int64, active bool) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailbox_app_passwords SET is_active = ? WHERE id = ?`, active, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveAppPassword deletes an app password, scoped to mailboxID so a caller can't
|
||||
// remove one belonging to a different mailbox by guessing/manipulating its id —
|
||||
// mirrors DeleteWebAuthnCredential's (id, ownerID) pattern.
|
||||
func (d *DB) RemoveAppPassword(id, mailboxID int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_app_passwords WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package db
|
||||
|
||||
import "strings"
|
||||
|
||||
func (d *DB) ListAllowBlock(mailboxID int64) ([]MailboxAllowBlockEntry, error) {
|
||||
rows, err := d.Query(`SELECT id, mailbox_id, list_type, pattern, created_at
|
||||
FROM esrv_mailbox_allowblock WHERE mailbox_id = ? ORDER BY list_type, pattern`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxAllowBlockEntry
|
||||
for rows.Next() {
|
||||
var e MailboxAllowBlockEntry
|
||||
var createdAt string
|
||||
if err := rows.Scan(&e.ID, &e.MailboxID, &e.ListType, &e.Pattern, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) AddAllowBlockEntry(mailboxID int64, listType, pattern string) (int64, error) {
|
||||
res, err := d.Exec(`INSERT OR IGNORE INTO esrv_mailbox_allowblock (mailbox_id, list_type, pattern) VALUES (?, ?, ?)`,
|
||||
mailboxID, listType, strings.ToLower(pattern))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// RemoveAllowBlockEntry deletes an entry, scoped to mailboxID (mirrors RemoveAppPassword/RemoveAlias).
|
||||
func (d *DB) RemoveAllowBlockEntry(id, mailboxID int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_allowblock WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) IsBlocked(mailboxID int64, senderAddr string) (bool, error) {
|
||||
return matchesAllowBlock(d, mailboxID, "block", senderAddr)
|
||||
}
|
||||
|
||||
func (d *DB) IsAllowed(mailboxID int64, senderAddr string) (bool, error) {
|
||||
return matchesAllowBlock(d, mailboxID, "allow", senderAddr)
|
||||
}
|
||||
|
||||
// matchesAllowBlock checks senderAddr against every pattern of listType for mailboxID
|
||||
// — an exact address match, or a "@domain.com" wildcard matching senderAddr's domain.
|
||||
func matchesAllowBlock(d *DB, mailboxID int64, listType, senderAddr string) (bool, error) {
|
||||
senderAddr = strings.ToLower(senderAddr)
|
||||
domain := domainPart(senderAddr)
|
||||
rows, err := d.Query(`SELECT pattern FROM esrv_mailbox_allowblock WHERE mailbox_id = ? AND list_type = ?`, mailboxID, listType)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var pattern string
|
||||
if err := rows.Scan(&pattern); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.HasPrefix(pattern, "@") {
|
||||
if pattern[1:] == domain {
|
||||
return true, nil
|
||||
}
|
||||
} else if pattern == senderAddr {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"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) {
|
||||
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)
|
||||
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)
|
||||
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 {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
m.InternalDate, _ = parseTime(internalDate)
|
||||
m.CreatedAt, _ = parseTime(createdAt)
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (d *DB) DeleteMessage(mailboxID, uid int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_messages WHERE id = ? AND mailbox_id = ?`, 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.
|
||||
func (d *DB) ListMessageUIDsForMailbox(mailboxID int64) ([]int64, error) {
|
||||
rows, err := d.Query(`SELECT id FROM esrv_mailbox_messages WHERE mailbox_id = ? ORDER BY id ASC`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
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)
|
||||
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()
|
||||
}
|
||||
|
||||
// SetMessageFlags overwrites a message's stored IMAP flags (space-separated), scoped
|
||||
// to mailboxID so a session can't touch another mailbox's message by guessing a UID.
|
||||
func (d *DB) SetMessageFlags(mailboxID, uid int64, flags string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailbox_messages SET flags = ? WHERE id = ? AND mailbox_id = ?`, flags, uid, mailboxID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListMessagesInFolder is ListMessagesForMailbox scoped to one folder — internal/imapserver
|
||||
// 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)
|
||||
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()
|
||||
}
|
||||
|
||||
// DistinctFoldersForMailbox returns every folder name that has at least one stored
|
||||
// message, plus "INBOX" always (even if empty) — the folder list internal/imapserver's
|
||||
// LIST command reports.
|
||||
func (d *DB) DistinctFoldersForMailbox(mailboxID int64) ([]string, error) {
|
||||
rows, err := d.Query(`SELECT DISTINCT folder FROM esrv_mailbox_messages WHERE mailbox_id = ?`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
seen := map[string]bool{"INBOX": true}
|
||||
out := []string{"INBOX"}
|
||||
for rows.Next() {
|
||||
var folder string
|
||||
if err := rows.Scan(&folder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !seen[folder] {
|
||||
seen[folder] = true
|
||||
out = append(out, folder)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package db
|
||||
|
||||
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
|
||||
FROM esrv_mailbox_filter_rules WHERE mailbox_id = ? ORDER BY priority ASC, id ASC`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxFilterRule
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
r.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// RemoveRule deletes a rule, scoped to mailboxID (mirrors RemoveAppPassword/RemoveAlias).
|
||||
func (d *DB) RemoveRule(id, mailboxID int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_filter_rules WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- Sessions --- (mirrors crud_admin.go's session functions, parallel schema)
|
||||
|
||||
func newMailboxSessionToken() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (d *DB) CreateMailboxSession(mailboxID int64, mfaVerified bool, ttl time.Duration) (string, error) {
|
||||
token := newMailboxSessionToken()
|
||||
_, err := d.Exec(`INSERT INTO esrv_mailbox_sessions (token, mailbox_id, mfa_verified, expires_at) VALUES (?, ?, ?, ?)`,
|
||||
token, mailboxID, mfaVerified, time.Now().Add(ttl))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (d *DB) GetMailboxSession(token string) (*MailboxSession, error) {
|
||||
row := d.QueryRow(`SELECT token, mailbox_id, mfa_verified, created_at, expires_at FROM esrv_mailbox_sessions WHERE token = ?`, token)
|
||||
var s MailboxSession
|
||||
var createdAt, expiresAt string
|
||||
if err := row.Scan(&s.Token, &s.MailboxID, &s.MFAVerified, &createdAt, &expiresAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
s.CreatedAt, _ = parseTime(createdAt)
|
||||
s.ExpiresAt, _ = parseTime(expiresAt)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (d *DB) MarkMailboxSessionMFAVerified(token string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailbox_sessions SET mfa_verified = 1 WHERE token = ?`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DeleteMailboxSession(token string) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_sessions WHERE token = ?`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- WebAuthn credentials --- (mirrors crud_admin.go, parallel schema)
|
||||
|
||||
func (d *DB) ListMailboxWebAuthnCredentials(mailboxID int64) ([]MailboxWebAuthnCredential, error) {
|
||||
rows, err := d.Query(`SELECT id, mailbox_id, name, credential_id, credential_data, created_at FROM esrv_mailbox_webauthn_credentials WHERE mailbox_id = ? ORDER BY created_at`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxWebAuthnCredential
|
||||
for rows.Next() {
|
||||
var c MailboxWebAuthnCredential
|
||||
var createdAt string
|
||||
if err := rows.Scan(&c.ID, &c.MailboxID, &c.Name, &c.CredentialID, &c.CredentialData, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) CreateMailboxWebAuthnCredential(mailboxID int64, name, credentialID, credentialData string) error {
|
||||
_, err := d.Exec(`INSERT INTO esrv_mailbox_webauthn_credentials (mailbox_id, name, credential_id, credential_data) VALUES (?, ?, ?, ?)`,
|
||||
mailboxID, name, credentialID, credentialData)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DeleteMailboxWebAuthnCredential(id, mailboxID int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_webauthn_credentials WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) CountMailboxWebAuthnCredentials(mailboxID int64) (int, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_webauthn_credentials WHERE mailbox_id = ?`, mailboxID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
const mailboxColumns = `id, email, domain_id, password_hash, is_active, quota_bytes, used_bytes, dek_wrapped, dek_nonce, created_at, created_by, totp_secret, totp_enabled`
|
||||
|
||||
func scanMailbox(row *sql.Row) (*Mailbox, error) {
|
||||
var m Mailbox
|
||||
var createdAt string
|
||||
var createdBy sql.NullInt64
|
||||
if err := row.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
m.CreatedAt, _ = parseTime(createdAt)
|
||||
if createdBy.Valid {
|
||||
m.CreatedBy = &createdBy.Int64
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// MailboxWithDomain joins a Mailbox with its Domain's name, mirroring SenderWithDomain.
|
||||
type MailboxWithDomain struct {
|
||||
Mailbox
|
||||
DomainName string
|
||||
}
|
||||
|
||||
func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) {
|
||||
rows, err := d.Query(`SELECT m.id, m.email, m.domain_id, m.password_hash, m.is_active, m.quota_bytes, m.used_bytes, m.dek_wrapped, m.dek_nonce, m.created_at, m.created_by, m.totp_secret, m.totp_enabled, dm.domain_name
|
||||
FROM esrv_mailboxes m JOIN esrv_domains dm ON dm.id = m.domain_id ORDER BY m.email`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxWithDomain
|
||||
for rows.Next() {
|
||||
var m MailboxWithDomain
|
||||
var createdAt string
|
||||
var createdBy sql.NullInt64
|
||||
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.DomainName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.CreatedAt, _ = parseTime(createdAt)
|
||||
if createdBy.Valid {
|
||||
m.CreatedBy = &createdBy.Int64
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) ListMailboxesForDomain(domainID int64) ([]Mailbox, error) {
|
||||
rows, err := d.Query(`SELECT `+mailboxColumns+` FROM esrv_mailboxes WHERE domain_id = ? ORDER BY email`, domainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Mailbox
|
||||
for rows.Next() {
|
||||
var m Mailbox
|
||||
var createdAt string
|
||||
var createdBy sql.NullInt64
|
||||
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.CreatedAt, _ = parseTime(createdAt)
|
||||
if createdBy.Valid {
|
||||
m.CreatedBy = &createdBy.Int64
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) GetMailboxByID(id int64) (*Mailbox, error) {
|
||||
row := d.QueryRow(`SELECT `+mailboxColumns+` FROM esrv_mailboxes WHERE id = ?`, id)
|
||||
return scanMailbox(row)
|
||||
}
|
||||
|
||||
// GetMailboxByEmail mirrors GetSenderByEmail: case-insensitive, active-only. Used both
|
||||
// for SMTP local-delivery recipient resolution and as the base lookup for app-password
|
||||
// verification — an alias never resolves here directly, per "login is always the
|
||||
// primary mailbox address."
|
||||
func (d *DB) GetMailboxByEmail(email string) (*Mailbox, error) {
|
||||
row := d.QueryRow(`SELECT `+mailboxColumns+` FROM esrv_mailboxes WHERE lower(email) = lower(?) AND is_active = 1`, email)
|
||||
return scanMailbox(row)
|
||||
}
|
||||
|
||||
func (d *DB) MailboxEmailExists(email string, excludeID int64) (bool, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_mailboxes WHERE lower(email) = lower(?) AND id != ?`, email, excludeID).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// CreateMailbox inserts a new mailbox with its wrapped per-mailbox data encryption key
|
||||
// (see internal/mailstore for how wrappedDEK/dekNonce are produced).
|
||||
func (d *DB) CreateMailbox(email, passwordHash string, domainID int64, quotaBytes int64, wrappedDEK, dekNonce []byte) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_mailboxes (email, domain_id, password_hash, quota_bytes, dek_wrapped, dek_nonce)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`, email, domainID, passwordHash, quotaBytes, wrappedDEK, dekNonce)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *DB) SetMailboxActive(id int64, active bool) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailboxes SET is_active = ? WHERE id = ?`, active, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SetMailboxQuota(id int64, quotaBytes int64) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailboxes SET quota_bytes = ? WHERE id = ?`, quotaBytes, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SetMailboxPasswordHash(id int64, passwordHash string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailboxes SET password_hash = ? WHERE id = ?`, passwordHash, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SetMailboxTOTPSecret(id int64, secret string, enabled bool) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailboxes SET totp_secret = ?, totp_enabled = ? WHERE id = ?`, secret, enabled, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DisableMailboxTOTP(id int64) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailboxes SET totp_secret = '', totp_enabled = 0 WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// AddMailboxUsedBytes adjusts the cached running total by delta (positive on store,
|
||||
// negative on delete) in a single statement, avoiding a read-modify-write race.
|
||||
func (d *DB) AddMailboxUsedBytes(id int64, delta int64) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailboxes SET used_bytes = used_bytes + ? WHERE id = ?`, delta, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) GetDomainDefaultQuota(domainID int64) (int64, error) {
|
||||
var n int64
|
||||
err := d.QueryRow(`SELECT default_mailbox_quota_bytes FROM esrv_domains WHERE id = ?`, domainID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (d *DB) SetDomainDefaultQuota(domainID int64, bytes int64) error {
|
||||
_, err := d.Exec(`UPDATE esrv_domains SET default_mailbox_quota_bytes = ? WHERE id = ?`, bytes, domainID)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveMailboxCascade hard-deletes a mailbox and its app passwords / any remaining
|
||||
// message rows. Callers should delete each message's on-disk ciphertext via
|
||||
// mailstore.DeleteMessage first (see ListMessageUIDsForMailbox) — the message-row
|
||||
// DELETE here is just a safety net for any that weren't individually cleaned up.
|
||||
func (d *DB) RemoveMailboxCascade(id int64) error {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, stmt := range []string{
|
||||
`DELETE FROM esrv_mailbox_app_passwords WHERE mailbox_id = ?`,
|
||||
`DELETE FROM esrv_mailbox_aliases WHERE mailbox_id = ?`,
|
||||
`DELETE FROM esrv_mailbox_allowblock WHERE mailbox_id = ?`,
|
||||
`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_messages WHERE mailbox_id = ?`,
|
||||
`DELETE FROM esrv_mailboxes WHERE id = ?`,
|
||||
} {
|
||||
if _, err := tx.Exec(stmt, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package db
|
||||
|
||||
import "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;
|
||||
// IMAP/SMTP client login always goes through a MailboxAppPassword instead.
|
||||
type Mailbox struct {
|
||||
ID int64
|
||||
Email string
|
||||
DomainID int64
|
||||
PasswordHash string
|
||||
IsActive bool
|
||||
QuotaBytes int64
|
||||
UsedBytes int64
|
||||
DEKWrapped []byte
|
||||
DEKNonce []byte
|
||||
CreatedAt time.Time
|
||||
CreatedBy *int64
|
||||
TOTPSecret string
|
||||
TOTPEnabled bool
|
||||
}
|
||||
|
||||
// MailboxSession is a self-service webmail portal login — a parallel schema to
|
||||
// AdminSession, not shared (see esrv_mailbox_sessions in schema.go).
|
||||
type MailboxSession struct {
|
||||
Token string
|
||||
MailboxID int64
|
||||
MFAVerified bool
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// MailboxWebAuthnCredential is a mailbox owner's passkey — a parallel schema to
|
||||
// WebAuthnCredential, not shared.
|
||||
type MailboxWebAuthnCredential struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
Name string
|
||||
CredentialID string
|
||||
CredentialData string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MailboxAlias is an alternate address for a mailbox — receive-only by default, or
|
||||
// also usable as MAIL FROM once authenticated (CanSendAs). Login is always the
|
||||
// mailbox's own primary address, never an alias.
|
||||
type MailboxAlias struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
Email string
|
||||
DomainID int64
|
||||
CanSendAs bool
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MailboxAllowBlockEntry is one allow- or block-list pattern for a mailbox.
|
||||
type MailboxAllowBlockEntry struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
ListType string // "allow" | "block"
|
||||
Pattern string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MailboxFilterRule is one priority-ordered, first-match-wins delivery rule.
|
||||
type MailboxFilterRule struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
Priority int
|
||||
ConditionField string // "from" | "to" | "subject"
|
||||
ConditionOp string // "contains" | "equals" | "starts_with"
|
||||
ConditionValue string
|
||||
Action string // "move_to_folder" | "delete" | "mark_read"
|
||||
ActionValue string
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MailboxAppPassword is the only credential an IMAP/SMTP client ever uses. Plaintext
|
||||
// is shown once at creation and never stored.
|
||||
type MailboxAppPassword struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
Label string
|
||||
PasswordHash string
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
LastUsedAt *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.
|
||||
type MailboxMessage struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
Folder string
|
||||
MessageIDHeader string
|
||||
Flags string
|
||||
InternalDate time.Time
|
||||
SizeBytes int64
|
||||
CachedFrom string
|
||||
CachedSubject string
|
||||
StoragePath string
|
||||
Nonce []byte
|
||||
CreatedAt time.Time
|
||||
}
|
||||
+133
-1
@@ -21,7 +21,8 @@ CREATE TABLE IF NOT EXISTS esrv_domains (
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
verification_token TEXT NOT NULL DEFAULT '',
|
||||
is_verified INTEGER NOT NULL DEFAULT 0,
|
||||
verified_at DATETIME
|
||||
verified_at DATETIME,
|
||||
default_mailbox_quota_bytes INTEGER NOT NULL DEFAULT 5368709120
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_senders (
|
||||
@@ -149,6 +150,122 @@ CREATE TABLE IF NOT EXISTS esrv_webauthn_credentials (
|
||||
credential_data TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Mailboxes are a distinct identity from esrv_senders: senders are relay/auth-only,
|
||||
-- mailboxes are real IMAP-retrievable local storage. password_hash authenticates the
|
||||
-- (future) self-service web portal only, never IMAP/SMTP client login — those use an
|
||||
-- app password instead (esrv_mailbox_app_passwords), since IMAP/SMTP AUTH has no
|
||||
-- interactive MFA step. dek_wrapped/dek_nonce hold this mailbox's AES-256 data
|
||||
-- encryption key, sealed with the server-held master key (internal/mailstore) — a
|
||||
-- raw DB dump alone can't decrypt stored mail without that separate key file.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailboxes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
|
||||
password_hash TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
quota_bytes INTEGER NOT NULL DEFAULT 5368709120,
|
||||
used_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
dek_wrapped BLOB NOT NULL,
|
||||
dek_nonce BLOB NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES esrv_admin_users(id),
|
||||
totp_secret TEXT NOT NULL DEFAULT '',
|
||||
totp_enabled INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Self-service webmail portal sessions — deliberately a parallel schema to
|
||||
-- esrv_admin_sessions, not shared: a mailbox owner is a different actor type with no
|
||||
-- accessScope/domain-admin semantics of its own.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
mfa_verified INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_webauthn_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
credential_data TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- App passwords are the only credential IMAP/SMTP clients (Thunderbird etc.) ever see
|
||||
-- for a mailbox. plaintext is shown once at creation and never stored/re-shown.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_app_passwords (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at DATETIME
|
||||
);
|
||||
|
||||
-- A mailbox's receive-only (or, with can_send_as, send-as too) alternate addresses.
|
||||
-- Login is always the mailbox's own primary address (esrv_mailboxes.email), never an
|
||||
-- alias — an alias only changes which addresses can deliver here / be used as MAIL
|
||||
-- FROM by this mailbox once authenticated via its app password.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_aliases (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
|
||||
can_send_as INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Per-mailbox sender allow/block list. pattern is either an exact address
|
||||
-- ("spam@evil.com") or a whole-domain wildcard ("@evil.com"). A single table with a
|
||||
-- list_type column, not two near-identical tables.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_allowblock (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
list_type TEXT NOT NULL CHECK(list_type IN ('allow','block')),
|
||||
pattern TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(mailbox_id, list_type, pattern)
|
||||
);
|
||||
|
||||
-- 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.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_filter_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
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_value TEXT NOT NULL DEFAULT '',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- One row per stored message. cached_from/cached_subject are deliberately plaintext
|
||||
-- (a narrow, confirmed exception to "encrypted at rest") so IMAP LIST/basic SEARCH
|
||||
-- don't need to decrypt every message in a folder; body and every other header stay
|
||||
-- ciphertext-only at storage_path, decrypted solely on FETCH.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
folder TEXT NOT NULL DEFAULT 'INBOX',
|
||||
message_id_header TEXT NOT NULL DEFAULT '',
|
||||
flags TEXT NOT NULL DEFAULT '',
|
||||
internal_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
cached_from 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
|
||||
);
|
||||
`
|
||||
|
||||
// migrateAddedColumns best-effort ALTER TABLEs the columns added to esrv_domains
|
||||
@@ -164,6 +281,9 @@ func migrateAddedColumns(db *sql.DB) {
|
||||
`ALTER TABLE esrv_domains ADD COLUMN verified_at DATETIME`,
|
||||
`ALTER TABLE esrv_admin_users ADD COLUMN is_global_admin INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE esrv_admin_users ADD COLUMN created_by INTEGER`,
|
||||
`ALTER TABLE esrv_domains ADD COLUMN default_mailbox_quota_bytes INTEGER NOT NULL DEFAULT 5368709120`,
|
||||
`ALTER TABLE esrv_mailboxes ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE esrv_mailboxes ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
db.Exec(stmt)
|
||||
@@ -181,6 +301,18 @@ func Open(path string) (*DB, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
// The web UI, SMTP server, and IMAP server all share this one *sql.DB. SQLite only
|
||||
// allows one writer at a time, and PRAGMAs are per-connection — database/sql's
|
||||
// pool can silently open a second physical connection at any time, so a PRAGMA
|
||||
// set via Exec here isn't guaranteed to apply to whichever connection later hits a
|
||||
// lock. Capping the pool to one connection is the standard fix: every access is
|
||||
// serialized through a single physical connection, so no connection can ever
|
||||
// collide with another's in-progress write.
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if _, err := sqlDB.Exec(`PRAGMA busy_timeout = 5000`); err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, fmt.Errorf("set busy_timeout: %w", err)
|
||||
}
|
||||
if _, err := sqlDB.Exec(schema); err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, fmt.Errorf("create tables: %w", err)
|
||||
|
||||
Reference in New Issue
Block a user