added IMAP, LetsEncrypt, update layout

This commit is contained in:
2026-08-12 21:14:19 +01:00
parent 6e103959b0
commit 70fa1a5f2c
222 changed files with 42947 additions and 14038 deletions
+76
View File
@@ -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
}
+117
View File
@@ -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
}
+72
View File
@@ -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()
}
+141
View File
@@ -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()
}
+36
View File
@@ -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
}
+90
View File
@@ -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
}
+179
View File
@@ -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()
}
+108
View File
@@ -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
View File
@@ -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)