This commit is contained in:
2026-05-21 20:27:58 +00:00
parent 0d2615a9fd
commit 5a127bf2a2
38 changed files with 8644 additions and 0 deletions
+171
View File
@@ -0,0 +1,171 @@
// Package db provides the database/sql wrapper and driver registration.
// Default driver: modernc.org/sqlite (pure Go, no CGO).
// Additional drivers registered via build tags: postgres, mysql, mssql.
package db
import (
"context"
"database/sql"
"fmt"
"time"
_ "modernc.org/sqlite" // pure-Go SQLite driver
)
// DB wraps sql.DB with convenience methods and prepared statement caching.
type DB struct {
db *sql.DB
driver string
}
// Open opens and validates the database connection, runs migrations, returns DB.
func Open(driver, dsn string) (*DB, error) {
if driver == "" {
driver = "sqlite"
}
// Map friendly driver names to database/sql driver names.
sqlDriver := sqlDriverName(driver)
if driver == "sqlite" {
dsn = sqliteDSN(dsn)
}
sqlDB, err := sql.Open(sqlDriver, dsn)
if err != nil {
return nil, fmt.Errorf("db open %s: %w", driver, err)
}
// Connection pool tuning.
if driver == "sqlite" {
// SQLite: serialise with single connection to avoid SQLITE_BUSY.
sqlDB.SetMaxOpenConns(1)
sqlDB.SetMaxIdleConns(1)
sqlDB.SetConnMaxLifetime(0)
} else {
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := sqlDB.PingContext(ctx); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("db ping: %w", err)
}
d := &DB{db: sqlDB, driver: driver}
// Enable WAL mode for SQLite (dramatically improves concurrent read performance).
if driver == "sqlite" {
if _, err := sqlDB.Exec(`PRAGMA journal_mode=WAL`); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("sqlite WAL: %w", err)
}
if _, err := sqlDB.Exec(`PRAGMA foreign_keys=ON`); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("sqlite foreign_keys: %w", err)
}
if _, err := sqlDB.Exec(`PRAGMA busy_timeout=5000`); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("sqlite busy_timeout: %w", err)
}
}
if err := d.migrate(); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return d, nil
}
// Close closes the underlying sql.DB.
func (d *DB) Close() error { return d.db.Close() }
// Driver returns the driver name (sqlite / postgres / mysql / mssql).
func (d *DB) Driver() string { return d.driver }
// SQL returns the underlying *sql.DB for direct use when needed.
func (d *DB) SQL() *sql.DB { return d.db }
// Exec runs a query with a per-call context timeout.
func (d *DB) Exec(query string, args ...any) (sql.Result, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return d.db.ExecContext(ctx, query, args...)
}
// QueryRow runs a single-row query.
func (d *DB) QueryRow(query string, args ...any) *sql.Row {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return d.db.QueryRowContext(ctx, query, args...)
}
// Query runs a multi-row query.
func (d *DB) Query(query string, args ...any) (*sql.Rows, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return d.db.QueryContext(ctx, query, args...)
}
// WithTx runs fn inside a transaction, rolling back on error or panic.
func (d *DB) WithTx(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := d.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() {
if p := recover(); p != nil {
_ = tx.Rollback()
panic(p) // re-raise
}
}()
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
// ---- Placeholder helper ----
// Placeholder returns the SQL parameter placeholder for the current driver.
// SQLite and MySQL use ?, PostgreSQL uses $1, $2… MSSQL uses @p1, @p2…
func (d *DB) Placeholder(n int) string {
switch d.driver {
case "postgres":
return fmt.Sprintf("$%d", n)
case "mssql":
return fmt.Sprintf("@p%d", n)
default:
return "?"
}
}
// ---- Private helpers ----
func sqlDriverName(driver string) string {
switch driver {
case "sqlite":
return "sqlite" // modernc.org/sqlite registers as "sqlite"
case "postgres":
return "postgres"
case "mysql":
return "mysql"
case "mssql":
return "sqlserver"
default:
return driver
}
}
func sqliteDSN(path string) string {
if path == "" {
path = "./data/mail.db"
}
// modernc.org/sqlite DSN supports query parameters.
return path + "?_pragma=foreign_keys(1)"
}
+120
View File
@@ -0,0 +1,120 @@
package db
import (
"context"
"database/sql"
"fmt"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
)
// GetDomain returns the domain row by name, or nil if not found.
func (d *DB) GetDomain(ctx context.Context, name string) (*models.Domain, error) {
row := d.db.QueryRowContext(ctx, `
SELECT id, name, enabled, dkim_private_enc, dkim_public, dkim_selector,
dkim_algo, spf_policy, dmarc_policy, max_users, max_quota_bytes, created_at
FROM domains WHERE lower(name) = lower(?)`, name)
var dom models.Domain
var privEnc []byte
err := row.Scan(
&dom.ID, &dom.Name, &dom.Enabled,
&privEnc, &dom.DKIMPublic, &dom.DKIMSelector,
&dom.DKIMAlgo, &dom.SPFPolicy, &dom.DMARCPolicy,
&dom.MaxUsers, &dom.MaxQuotaBytes, &dom.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get domain: %w", err)
}
dom.DKIMPrivateEnc = privEnc
return &dom, nil
}
// GetDomainByID returns the domain row by ID.
func (d *DB) GetDomainByID(ctx context.Context, id int64) (*models.Domain, error) {
row := d.db.QueryRowContext(ctx, `
SELECT id, name, enabled, dkim_private_enc, dkim_public, dkim_selector,
dkim_algo, spf_policy, dmarc_policy, max_users, max_quota_bytes, created_at
FROM domains WHERE id = ?`, id)
var dom models.Domain
var privEnc []byte
err := row.Scan(
&dom.ID, &dom.Name, &dom.Enabled,
&privEnc, &dom.DKIMPublic, &dom.DKIMSelector,
&dom.DKIMAlgo, &dom.SPFPolicy, &dom.DMARCPolicy,
&dom.MaxUsers, &dom.MaxQuotaBytes, &dom.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get domain by id: %w", err)
}
dom.DKIMPrivateEnc = privEnc
return &dom, nil
}
// IsLocalDomain returns true if name is a known enabled domain.
func (d *DB) IsLocalDomain(ctx context.Context, name string) (bool, error) {
var count int
err := d.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM domains WHERE lower(name)=lower(?) AND enabled=1", name).
Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// ListDomains returns all domains ordered by name.
func (d *DB) ListDomains(ctx context.Context) ([]*models.Domain, error) {
rows, err := d.db.QueryContext(ctx, `
SELECT id, name, enabled, dkim_private_enc, dkim_public, dkim_selector,
dkim_algo, spf_policy, dmarc_policy, max_users, max_quota_bytes, created_at
FROM domains ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var doms []*models.Domain
for rows.Next() {
var dom models.Domain
var privEnc []byte
err := rows.Scan(
&dom.ID, &dom.Name, &dom.Enabled,
&privEnc, &dom.DKIMPublic, &dom.DKIMSelector,
&dom.DKIMAlgo, &dom.SPFPolicy, &dom.DMARCPolicy,
&dom.MaxUsers, &dom.MaxQuotaBytes, &dom.CreatedAt,
)
if err != nil {
return nil, err
}
dom.DKIMPrivateEnc = privEnc
doms = append(doms, &dom)
}
return doms, rows.Err()
}
// CreateDomain inserts a new domain. Returns the new ID.
func (d *DB) CreateDomain(ctx context.Context, name, selector, algo string) (int64, error) {
res, err := d.db.ExecContext(ctx, `
INSERT INTO domains (name, enabled, dkim_selector, dkim_algo)
VALUES (?, 1, ?, ?)`, name, selector, algo)
if err != nil {
return 0, fmt.Errorf("create domain: %w", err)
}
return res.LastInsertId()
}
// SaveDKIMKeys stores encrypted DKIM private key + public key for a domain.
func (d *DB) SaveDKIMKeys(ctx context.Context, domainID int64, privEnc []byte, pubPEM string) error {
_, err := d.db.ExecContext(ctx,
"UPDATE domains SET dkim_private_enc=?, dkim_public=? WHERE id=?",
privEnc, pubPEM, domainID)
return err
}
+284
View File
@@ -0,0 +1,284 @@
package db
import (
"context"
"database/sql"
"fmt"
"time"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
)
// IMAPMessage is a lightweight message descriptor used by the IMAP layer.
// The raw/body blobs are NOT loaded here — fetch separately via GetMessageRaw.
type IMAPMessage struct {
ID int64
MailboxID int64
UID uint32
MessageID string // RFC 2822 Message-ID header
Subject string
FromEmail string
FromName string
ToList string
Date time.Time
SizeBytes int64
HasAttachment bool
IsRead bool
IsStarred bool
IsDraft bool
IsDeleted bool // deleted_at IS NOT NULL
Flags string
SpamScore int
ReceivedAt time.Time
}
// ListIMAPMessages returns all non-deleted messages in a mailbox ordered by UID ascending.
func (d *DB) ListIMAPMessages(ctx context.Context, mailboxID int64) ([]*IMAPMessage, error) {
rows, err := d.db.QueryContext(ctx, `
SELECT id, mailbox_id, uid, message_id, subject, from_email, from_name,
to_list, date, size_bytes, has_attachment,
is_read, is_starred, is_draft, flags, spam_score, received_at
FROM messages
WHERE mailbox_id = ? AND deleted_at IS NULL
ORDER BY uid ASC`, mailboxID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*IMAPMessage
for rows.Next() {
m, err := scanIMAPMessage(rows)
if err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// GetIMAPMessageByUID returns one message by UID within a mailbox.
func (d *DB) GetIMAPMessageByUID(ctx context.Context, mailboxID int64, uid uint32) (*IMAPMessage, error) {
rows, err := d.db.QueryContext(ctx, `
SELECT id, mailbox_id, uid, message_id, subject, from_email, from_name,
to_list, date, size_bytes, has_attachment,
is_read, is_starred, is_draft, flags, spam_score, received_at
FROM messages
WHERE mailbox_id = ? AND uid = ? AND deleted_at IS NULL
LIMIT 1`, mailboxID, uid)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, nil
}
return scanIMAPMessage(rows)
}
// SetMessageFlags updates the mutable flags for a message.
func (d *DB) SetMessageFlags(ctx context.Context, messageID int64, isRead, isStarred, isDraft bool, extraFlags string) error {
_, err := d.db.ExecContext(ctx,
"UPDATE messages SET is_read=?, is_starred=?, is_draft=?, flags=? WHERE id=?",
isRead, isStarred, isDraft, extraFlags, messageID)
return err
}
// SoftDeleteMessage marks a message as deleted (sets deleted_at).
func (d *DB) SoftDeleteMessage(ctx context.Context, messageID int64) error {
_, err := d.db.ExecContext(ctx,
"UPDATE messages SET deleted_at=? WHERE id=?", time.Now().UTC(), messageID)
return err
}
// HardDeleteMessages physically removes all soft-deleted messages from a mailbox.
// Returns the UIDs of deleted messages (for EXPUNGE responses).
func (d *DB) HardDeleteMessages(ctx context.Context, mailboxID int64) ([]uint32, error) {
rows, err := d.db.QueryContext(ctx,
"SELECT uid FROM messages WHERE mailbox_id=? AND deleted_at IS NOT NULL ORDER BY uid ASC",
mailboxID)
if err != nil {
return nil, err
}
var uids []uint32
for rows.Next() {
var uid uint32
if err := rows.Scan(&uid); err != nil {
rows.Close()
return nil, err
}
uids = append(uids, uid)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
if len(uids) == 0 {
return nil, nil
}
// Delete attachments first (FK).
_, err = d.db.ExecContext(ctx, `
DELETE FROM attachments WHERE message_id IN (
SELECT id FROM messages WHERE mailbox_id=? AND deleted_at IS NOT NULL
)`, mailboxID)
if err != nil {
return nil, fmt.Errorf("delete attachments: %w", err)
}
_, err = d.db.ExecContext(ctx,
"DELETE FROM messages WHERE mailbox_id=? AND deleted_at IS NOT NULL", mailboxID)
if err != nil {
return nil, fmt.Errorf("delete messages: %w", err)
}
return uids, nil
}
// CopyMessageToMailbox duplicates a message row to another mailbox.
// Returns the new UID.
func (d *DB) CopyMessageToMailbox(ctx context.Context, srcMsgID, destMailboxID, userID int64) (uint32, error) {
// Read source.
var src struct {
mailboxID int64
uid uint32
messageID string
subject string
fromEmail string
fromName string
toList string
ccList string
bccList string
replyTo string
date time.Time
bodyTextEnc []byte
bodyHTMLEnc []byte
rawEnc []byte
sizeBytes int64
hasAttachment bool
isRead bool
isStarred bool
isDraft bool
flags string
spamScore int
}
err := d.db.QueryRowContext(ctx, `
SELECT mailbox_id, uid, message_id, subject, from_email, from_name,
to_list, cc_list, bcc_list, reply_to, date,
body_text_enc, body_html_enc, raw_enc,
size_bytes, has_attachment, is_read, is_starred, is_draft,
flags, spam_score
FROM messages WHERE id=? AND deleted_at IS NULL`, srcMsgID).Scan(
&src.mailboxID, &src.uid, &src.messageID, &src.subject,
&src.fromEmail, &src.fromName, &src.toList, &src.ccList, &src.bccList, &src.replyTo,
&src.date, &src.bodyTextEnc, &src.bodyHTMLEnc, &src.rawEnc,
&src.sizeBytes, &src.hasAttachment, &src.isRead, &src.isStarred, &src.isDraft,
&src.flags, &src.spamScore,
)
if err == sql.ErrNoRows {
return 0, fmt.Errorf("source message %d not found", srcMsgID)
}
if err != nil {
return 0, fmt.Errorf("copy message read: %w", err)
}
// Allocate UID in destination.
uid, err := d.NextUID(ctx, destMailboxID)
if err != nil {
return 0, fmt.Errorf("copy message uid: %w", err)
}
ins := &MessageInsert{
MailboxID: destMailboxID,
UID: uid,
MessageID: src.messageID,
Subject: src.subject,
FromEmail: src.fromEmail,
FromName: src.fromName,
ToList: src.toList,
CCList: src.ccList,
BCCList: src.bccList,
ReplyTo: src.replyTo,
Date: src.date,
BodyTextEnc: src.bodyTextEnc,
BodyHTMLEnc: src.bodyHTMLEnc,
RawEnc: src.rawEnc,
SizeBytes: src.sizeBytes,
HasAttachment: src.hasAttachment,
IsRead: src.isRead,
IsStarred: src.isStarred,
IsDraft: src.isDraft,
Flags: src.flags,
SpamScore: src.spamScore,
}
if _, err := d.InsertMessage(ctx, ins); err != nil {
return 0, fmt.Errorf("copy message insert: %w", err)
}
return uid, nil
}
// RenameMailbox updates the name field of a mailbox.
func (d *DB) RenameMailbox(ctx context.Context, mailboxID int64, newName string) error {
_, err := d.db.ExecContext(ctx,
"UPDATE mailboxes SET name=? WHERE id=?", newName, mailboxID)
return err
}
// SetMailboxSubscribed updates the subscribed flag on a mailbox.
func (d *DB) SetMailboxSubscribed(ctx context.Context, mailboxID int64, subscribed bool) error {
_, err := d.db.ExecContext(ctx,
"UPDATE mailboxes SET subscribed=? WHERE id=?", subscribed, mailboxID)
return err
}
// GetMailboxMessageCounts returns (total, unseen) counts for a mailbox.
func (d *DB) GetMailboxMessageCounts(ctx context.Context, mailboxID int64) (total, unseen int64, err error) {
err = d.db.QueryRowContext(ctx, `
SELECT COUNT(*), COUNT(CASE WHEN is_read=0 THEN 1 END)
FROM messages WHERE mailbox_id=? AND deleted_at IS NULL`, mailboxID).Scan(&total, &unseen)
return
}
// GetMailboxSize returns the total size in bytes of all messages in a mailbox.
func (d *DB) GetMailboxSize(ctx context.Context, mailboxID int64) (int64, error) {
var sz sql.NullInt64
err := d.db.QueryRowContext(ctx,
"SELECT SUM(size_bytes) FROM messages WHERE mailbox_id=? AND deleted_at IS NULL",
mailboxID).Scan(&sz)
if err != nil {
return 0, err
}
return sz.Int64, nil
}
// ---- helpers ----
func scanIMAPMessage(rows *sql.Rows) (*IMAPMessage, error) {
m := &IMAPMessage{}
err := rows.Scan(
&m.ID, &m.MailboxID, &m.UID, &m.MessageID, &m.Subject,
&m.FromEmail, &m.FromName, &m.ToList,
&m.Date, &m.SizeBytes, &m.HasAttachment,
&m.IsRead, &m.IsStarred, &m.IsDraft,
&m.Flags, &m.SpamScore, &m.ReceivedAt,
)
return m, err
}
// mailboxTypeToAttr converts our type string to an IMAP special-use string.
// Callers handle the conversion to imap.MailboxAttr themselves.
func MailboxTypeToSpecialUse(mboxType string) string {
switch mboxType {
case models.MailboxSent:
return `\Sent`
case models.MailboxDrafts:
return `\Drafts`
case models.MailboxTrash:
return `\Trash`
case models.MailboxSpam:
return `\Junk`
case models.MailboxArchive:
return `\Archive`
case models.MailboxInbox:
return `\Inbox`
}
return ""
}
+401
View File
@@ -0,0 +1,401 @@
package db
import (
"context"
"database/sql"
"fmt"
"math/rand"
"time"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
)
// GetMailbox returns the mailbox with the given name for a user, or nil.
func (d *DB) GetMailbox(ctx context.Context, userID int64, name string) (*models.Mailbox, error) {
row := d.db.QueryRowContext(ctx, `
SELECT id, user_id, name, type, parent_id, uid_validity, uid_next, subscribed, created_at
FROM mailboxes WHERE user_id=? AND name=?`, userID, name)
return scanMailbox(row)
}
// GetMailboxByType returns the first mailbox of the given type for a user.
func (d *DB) GetMailboxByType(ctx context.Context, userID int64, mboxType string) (*models.Mailbox, error) {
row := d.db.QueryRowContext(ctx, `
SELECT id, user_id, name, type, parent_id, uid_validity, uid_next, subscribed, created_at
FROM mailboxes WHERE user_id=? AND type=? LIMIT 1`, userID, mboxType)
return scanMailbox(row)
}
// GetMailboxByID returns the mailbox by ID.
func (d *DB) GetMailboxByID(ctx context.Context, id int64) (*models.Mailbox, error) {
row := d.db.QueryRowContext(ctx, `
SELECT id, user_id, name, type, parent_id, uid_validity, uid_next, subscribed, created_at
FROM mailboxes WHERE id=?`, id)
return scanMailbox(row)
}
// ListMailboxes returns all subscribed mailboxes for a user, ordered by name.
func (d *DB) ListMailboxes(ctx context.Context, userID int64) ([]*models.Mailbox, error) {
rows, err := d.db.QueryContext(ctx, `
SELECT id, user_id, name, type, parent_id, uid_validity, uid_next, subscribed, created_at
FROM mailboxes WHERE user_id=? ORDER BY name`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var mbs []*models.Mailbox
for rows.Next() {
mb, err := scanMailboxRow(rows)
if err != nil {
return nil, err
}
mbs = append(mbs, mb)
}
return mbs, rows.Err()
}
// CreateMailbox creates a mailbox. Returns the new mailbox with uid_validity set.
func (d *DB) CreateMailbox(ctx context.Context, userID int64, name, mboxType string, parentID *int64) (*models.Mailbox, error) {
uidValidity := uint32(rand.Int31()) //nolint:gosec — not a security value
if uidValidity == 0 {
uidValidity = 1
}
res, err := d.db.ExecContext(ctx, `
INSERT INTO mailboxes (user_id, name, type, parent_id, uid_validity, uid_next, subscribed, created_at)
VALUES (?, ?, ?, ?, ?, 1, 1, ?)`,
userID, name, mboxType, parentID, uidValidity, time.Now().UTC())
if err != nil {
return nil, fmt.Errorf("create mailbox: %w", err)
}
id, _ := res.LastInsertId()
return &models.Mailbox{
ID: id,
UserID: userID,
Name: name,
Type: mboxType,
ParentID: parentID,
UIDValidity: uidValidity,
UIDNext: 1,
Subscribed: true,
CreatedAt: time.Now().UTC(),
}, nil
}
// CreateDefaultMailboxes creates the standard mailbox set for a new user.
// Idempotent — skips any that already exist.
func (d *DB) CreateDefaultMailboxes(ctx context.Context, userID int64) error {
defaults := []struct {
name string
mboxType string
}{
{"INBOX", models.MailboxInbox},
{"Sent", models.MailboxSent},
{"Drafts", models.MailboxDrafts},
{"Trash", models.MailboxTrash},
{"Spam", models.MailboxSpam},
{"Archive", models.MailboxArchive},
}
for _, mb := range defaults {
existing, err := d.GetMailbox(ctx, userID, mb.name)
if err != nil {
return err
}
if existing != nil {
continue
}
if _, err := d.CreateMailbox(ctx, userID, mb.name, mb.mboxType, nil); err != nil {
return fmt.Errorf("create default mailbox %s: %w", mb.name, err)
}
}
return nil
}
// NextUID allocates the next UID for a mailbox atomically.
// Returns the UID to use for the new message.
func (d *DB) NextUID(ctx context.Context, mailboxID int64) (uint32, error) {
tx, err := d.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback() //nolint:errcheck
var next uint32
err = tx.QueryRowContext(ctx,
"SELECT uid_next FROM mailboxes WHERE id=?", mailboxID).Scan(&next)
if err != nil {
return 0, fmt.Errorf("read uid_next: %w", err)
}
_, err = tx.ExecContext(ctx,
"UPDATE mailboxes SET uid_next=uid_next+1 WHERE id=?", mailboxID)
if err != nil {
return 0, fmt.Errorf("increment uid_next: %w", err)
}
return next, tx.Commit()
}
// ---- Message operations ----
// InsertMessage stores a message record (body is already encrypted; call SaveRawBody separately).
func (d *DB) InsertMessage(ctx context.Context, m *MessageInsert) (int64, error) {
res, err := d.db.ExecContext(ctx, `
INSERT INTO messages
(mailbox_id, uid, message_id, subject, from_email, from_name,
to_list, cc_list, bcc_list, reply_to, date,
body_text_enc, body_html_enc, raw_enc,
size_bytes, has_attachment, is_read, is_starred, is_draft,
flags, spam_score, received_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
m.MailboxID, m.UID, m.MessageID, m.Subject, m.FromEmail, m.FromName,
m.ToList, m.CCList, m.BCCList, m.ReplyTo, m.Date,
m.BodyTextEnc, m.BodyHTMLEnc, m.RawEnc,
m.SizeBytes, m.HasAttachment, m.IsRead, m.IsStarred, m.IsDraft,
m.Flags, m.SpamScore, time.Now().UTC(),
)
if err != nil {
return 0, fmt.Errorf("insert message: %w", err)
}
return res.LastInsertId()
}
// MessageInsert is the data transfer object for inserting a new message.
type MessageInsert struct {
MailboxID int64
UID uint32
MessageID string
Subject string
FromEmail string
FromName string
ToList string
CCList string
BCCList string
ReplyTo string
Date time.Time
BodyTextEnc []byte
BodyHTMLEnc []byte
RawEnc []byte
SizeBytes int64
HasAttachment bool
IsRead bool
IsStarred bool
IsDraft bool
Flags string
SpamScore int
}
// InsertAttachment stores an attachment record for a message.
func (d *DB) InsertAttachment(ctx context.Context, a *AttachmentInsert) (int64, error) {
res, err := d.db.ExecContext(ctx, `
INSERT INTO attachments
(message_id, filename, content_type, size_bytes, data_enc, data_path,
content_id, inline, mime_path)
VALUES (?,?,?,?,?,?,?,?,?)`,
a.MessageID, a.Filename, a.ContentType, a.SizeBytes,
a.DataEnc, a.DataPath, a.ContentID, a.Inline, a.MIMEPath,
)
if err != nil {
return 0, fmt.Errorf("insert attachment: %w", err)
}
return res.LastInsertId()
}
// AttachmentInsert is the data transfer object for inserting an attachment.
type AttachmentInsert struct {
MessageID int64
Filename string
ContentType string
SizeBytes int64
DataEnc []byte
DataPath string
ContentID string
Inline bool
MIMEPath string
}
// GetMessageRaw returns the encrypted raw blob for a message.
func (d *DB) GetMessageRaw(ctx context.Context, messageID int64) ([]byte, error) {
var raw []byte
err := d.db.QueryRowContext(ctx,
"SELECT raw_enc FROM messages WHERE id=?", messageID).Scan(&raw)
if err == sql.ErrNoRows {
return nil, nil
}
return raw, err
}
// ListMessages returns messages in a mailbox ordered by UID descending.
// Only non-deleted messages are returned.
func (d *DB) ListMessages(ctx context.Context, mailboxID int64, limit, offset int) ([]*models.Message, error) {
rows, err := d.db.QueryContext(ctx, `
SELECT id, mailbox_id, uid, message_id, subject, from_email, from_name,
to_list, cc_list, bcc_list, reply_to, date,
size_bytes, has_attachment, is_read, is_starred, is_draft,
flags, spam_score, received_at
FROM messages
WHERE mailbox_id=? AND deleted_at IS NULL
ORDER BY uid DESC
LIMIT ? OFFSET ?`, mailboxID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var msgs []*models.Message
for rows.Next() {
var m models.Message
err := rows.Scan(
&m.ID, &m.MailboxID, &m.UID, &m.MessageID, &m.Subject,
&m.FromEmail, &m.FromName, &m.ToList, &m.CCList, &m.BCCList,
&m.ReplyTo, &m.Date, &m.SizeBytes, &m.HasAttachment,
&m.IsRead, &m.IsStarred, &m.IsDraft, &m.Flags,
&m.SpamScore, &m.ReceivedAt,
)
if err != nil {
return nil, err
}
msgs = append(msgs, &m)
}
return msgs, rows.Err()
}
// CountUnread returns the number of unread messages in a mailbox.
func (d *DB) CountUnread(ctx context.Context, mailboxID int64) (int, error) {
var n int
err := d.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM messages WHERE mailbox_id=? AND is_read=0 AND deleted_at IS NULL",
mailboxID).Scan(&n)
return n, err
}
// ---- Queue operations ----
// EnqueueMessage inserts a delivery queue entry. Returns the new queue ID.
func (d *DB) EnqueueMessage(ctx context.Context, domainID int64, from, to, msgID string, rawEnc []byte, maxAgeHours int) (int64, error) {
expires := time.Now().UTC().Add(time.Duration(maxAgeHours) * time.Hour)
res, err := d.db.ExecContext(ctx, `
INSERT INTO queue
(domain_id, from_addr, to_addr, raw_enc, message_id, status,
attempts, next_attempt, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?)`,
domainID, from, to, rawEnc, msgID,
time.Now().UTC(), time.Now().UTC(), expires)
if err != nil {
return 0, fmt.Errorf("enqueue: %w", err)
}
return res.LastInsertId()
}
// PeekQueue returns up to limit pending/retry-eligible queue entries.
func (d *DB) PeekQueue(ctx context.Context, limit int) ([]QueueRow, error) {
rows, err := d.db.QueryContext(ctx, `
SELECT id, domain_id, from_addr, to_addr, raw_enc, message_id,
status, attempts, expires_at
FROM queue
WHERE status IN ('pending','failed')
AND next_attempt <= ?
AND expires_at > ?
ORDER BY next_attempt ASC
LIMIT ?`,
time.Now().UTC(), time.Now().UTC(), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []QueueRow
for rows.Next() {
var q QueueRow
var domainID sql.NullInt64
err := rows.Scan(
&q.ID, &domainID, &q.FromAddr, &q.ToAddr,
&q.RawEnc, &q.MessageID, &q.Status, &q.Attempts, &q.ExpiresAt,
)
if err != nil {
return nil, err
}
if domainID.Valid {
q.DomainID = domainID.Int64
}
out = append(out, q)
}
return out, rows.Err()
}
// QueueRow is a minimal queue entry for the delivery worker.
type QueueRow struct {
ID int64
DomainID int64
FromAddr string
ToAddr string
RawEnc []byte
MessageID string
Status string
Attempts int
ExpiresAt time.Time
}
// SetQueueStatus updates the status of a queue entry.
func (d *DB) SetQueueStatus(ctx context.Context, id int64, status, errMsg string, nextAttempt *time.Time) error {
_, err := d.db.ExecContext(ctx, `
UPDATE queue
SET status=?, attempts=attempts+1, last_attempt=?,
error_log=error_log || ?, next_attempt=COALESCE(?, next_attempt)
WHERE id=?`,
status, time.Now().UTC(),
fmt.Sprintf("[%s] %s\n", time.Now().UTC().Format(time.RFC3339), errMsg),
nextAttempt, id)
return err
}
// LogDelivery inserts a delivery log entry.
func (d *DB) LogDelivery(ctx context.Context, queueID int64, from, to, status string, smtpCode int, smtpMsg, mxHost string) error {
_, err := d.db.ExecContext(ctx, `
INSERT INTO delivery_log (queue_id, from_addr, to_addr, status, smtp_code, smtp_message, mx_host, created_at)
VALUES (?,?,?,?,?,?,?,?)`,
queueID, from, to, status, smtpCode, smtpMsg, mxHost, time.Now().UTC())
return err
}
// ---- private ----
func scanMailbox(row *sql.Row) (*models.Mailbox, error) {
var mb models.Mailbox
var parentID sql.NullInt64
err := row.Scan(
&mb.ID, &mb.UserID, &mb.Name, &mb.Type,
&parentID, &mb.UIDValidity, &mb.UIDNext, &mb.Subscribed, &mb.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan mailbox: %w", err)
}
if parentID.Valid {
id := parentID.Int64
mb.ParentID = &id
}
return &mb, nil
}
func scanMailboxRow(rows *sql.Rows) (*models.Mailbox, error) {
var mb models.Mailbox
var parentID sql.NullInt64
err := rows.Scan(
&mb.ID, &mb.UserID, &mb.Name, &mb.Type,
&parentID, &mb.UIDValidity, &mb.UIDNext, &mb.Subscribed, &mb.CreatedAt,
)
if err != nil {
return nil, err
}
if parentID.Valid {
id := parentID.Int64
mb.ParentID = &id
}
return &mb, nil
}
+337
View File
@@ -0,0 +1,337 @@
package db
import (
"context"
"database/sql"
"fmt"
"time"
)
// migration is a versioned schema change.
type migration struct {
version int
up string // SQL to apply
}
// migrations must be append-only. Never edit an applied migration.
var migrations = []migration{
{1, schemav1},
}
// migrate applies any unapplied migrations in order.
func (d *DB) migrate() error {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// Ensure migrations table exists.
_, err := d.db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)`)
if err != nil {
return fmt.Errorf("create migrations table: %w", err)
}
for _, m := range migrations {
var count int
err := d.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?", m.version).Scan(&count)
if err != nil {
return fmt.Errorf("check migration %d: %w", m.version, err)
}
if count > 0 {
continue // already applied
}
if err := d.WithTx(ctx, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, m.up); err != nil {
return fmt.Errorf("apply migration %d: %w", m.version, err)
}
_, err := tx.ExecContext(ctx,
"INSERT INTO schema_migrations (version) VALUES (?)", m.version)
return err
}); err != nil {
return err
}
fmt.Printf("[db] applied migration %d\n", m.version)
}
return nil
}
// ---- Schema v1 (initial) ----
const schemav1 = `
-- Domains
CREATE TABLE IF NOT EXISTS domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
enabled BOOLEAN NOT NULL DEFAULT 1,
dkim_private_enc BLOB,
dkim_public TEXT,
dkim_selector TEXT NOT NULL DEFAULT 'mail',
dkim_algo TEXT NOT NULL DEFAULT 'rsa2048',
spf_policy TEXT,
dmarc_policy TEXT,
max_users INTEGER NOT NULL DEFAULT 0,
max_quota_bytes INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Users
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
quota_bytes INTEGER NOT NULL DEFAULT 1073741824,
used_bytes INTEGER NOT NULL DEFAULT 0,
enabled BOOLEAN NOT NULL DEFAULT 1,
admin BOOLEAN NOT NULL DEFAULT 0,
domain_admin BOOLEAN NOT NULL DEFAULT 0,
mfa_secret_enc BLOB,
mfa_enabled BOOLEAN NOT NULL DEFAULT 0,
recovery_codes_enc BLOB,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_domain ON users(domain_id);
-- User aliases
CREATE TABLE IF NOT EXISTS user_aliases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
alias_email TEXT NOT NULL UNIQUE
);
-- Mailboxes (IMAP folders)
CREATE TABLE IF NOT EXISTS mailboxes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'custom',
parent_id INTEGER REFERENCES mailboxes(id) ON DELETE CASCADE,
uid_validity INTEGER NOT NULL DEFAULT 1,
uid_next INTEGER NOT NULL DEFAULT 1,
subscribed BOOLEAN NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, name)
);
CREATE INDEX IF NOT EXISTS idx_mailboxes_user ON mailboxes(user_id);
-- Messages
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mailbox_id INTEGER NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
uid INTEGER NOT NULL,
message_id TEXT,
subject TEXT NOT NULL DEFAULT '',
from_email TEXT NOT NULL DEFAULT '',
from_name TEXT NOT NULL DEFAULT '',
to_list TEXT NOT NULL DEFAULT '',
cc_list TEXT NOT NULL DEFAULT '',
bcc_list TEXT NOT NULL DEFAULT '',
reply_to TEXT NOT NULL DEFAULT '',
date TIMESTAMP,
body_text_enc BLOB,
body_html_enc BLOB,
raw_enc BLOB,
size_bytes INTEGER NOT NULL DEFAULT 0,
has_attachment BOOLEAN NOT NULL DEFAULT 0,
is_read BOOLEAN NOT NULL DEFAULT 0,
is_starred BOOLEAN NOT NULL DEFAULT 0,
is_draft BOOLEAN NOT NULL DEFAULT 0,
flags TEXT NOT NULL DEFAULT '',
spam_score INTEGER NOT NULL DEFAULT 0,
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP,
UNIQUE(mailbox_id, uid)
);
CREATE INDEX IF NOT EXISTS idx_messages_mailbox ON messages(mailbox_id);
CREATE INDEX IF NOT EXISTS idx_messages_uid ON messages(mailbox_id, uid);
CREATE INDEX IF NOT EXISTS idx_messages_date ON messages(mailbox_id, date);
CREATE INDEX IF NOT EXISTS idx_messages_deleted ON messages(mailbox_id, deleted_at);
-- Attachments
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
data_enc BLOB,
data_path TEXT,
content_id TEXT,
inline BOOLEAN NOT NULL DEFAULT 0,
mime_path TEXT
);
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id);
-- Delivery queue
CREATE TABLE IF NOT EXISTS queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_id INTEGER REFERENCES domains(id),
from_addr TEXT NOT NULL,
to_addr TEXT NOT NULL,
raw_enc BLOB NOT NULL,
message_id TEXT,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt TIMESTAMP,
next_attempt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
error_log TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_queue_status ON queue(status, next_attempt);
-- Delivery log
CREATE TABLE IF NOT EXISTS delivery_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
queue_id INTEGER REFERENCES queue(id) ON DELETE SET NULL,
from_addr TEXT NOT NULL,
to_addr TEXT NOT NULL,
status TEXT NOT NULL,
smtp_code INTEGER NOT NULL DEFAULT 0,
smtp_message TEXT NOT NULL DEFAULT '',
mx_host TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_delivery_log_created ON delivery_log(created_at);
-- Sessions
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
-- IP bans
CREATE TABLE IF NOT EXISTS ip_bans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL UNIQUE,
reason TEXT NOT NULL DEFAULT '',
banned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
released_by TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_ip_bans_ip ON ip_bans(ip);
CREATE INDEX IF NOT EXISTS idx_ip_bans_expires ON ip_bans(expires_at);
-- Login attempts
CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
user_email TEXT NOT NULL DEFAULT '',
success BOOLEAN NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_login_attempts_ip ON login_attempts(ip, created_at);
-- Security events
CREATE TABLE IF NOT EXISTS security_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
ip TEXT NOT NULL DEFAULT '',
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
detail TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_security_events_created ON security_events(created_at);
-- External accounts (Gmail / Outlook / custom IMAP)
CREATE TABLE IF NOT EXISTS external_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
email_address TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
access_token_enc BLOB,
refresh_token_enc BLOB,
token_expiry TIMESTAMP,
imap_host TEXT NOT NULL DEFAULT '',
imap_port INTEGER NOT NULL DEFAULT 993,
smtp_host TEXT NOT NULL DEFAULT '',
smtp_port INTEGER NOT NULL DEFAULT 587,
enabled BOOLEAN NOT NULL DEFAULT 1,
sync_enabled BOOLEAN NOT NULL DEFAULT 1,
last_sync TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ext_accounts_user ON external_accounts(user_id);
-- Address books (CardDAV)
CREATE TABLE IF NOT EXISTS address_books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '#4A90E2',
sync_token INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Contacts (CardDAV)
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
address_book_id INTEGER NOT NULL REFERENCES address_books(id) ON DELETE CASCADE,
uid TEXT NOT NULL,
vcard_enc BLOB NOT NULL,
etag TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(address_book_id, uid)
);
CREATE INDEX IF NOT EXISTS idx_contacts_book ON contacts(address_book_id);
-- Calendars (CalDAV)
CREATE TABLE IF NOT EXISTS calendars (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '#4CAF50',
timezone TEXT NOT NULL DEFAULT 'UTC',
sync_token INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Calendar events (CalDAV)
CREATE TABLE IF NOT EXISTS calendar_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
calendar_id INTEGER NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,
uid TEXT NOT NULL,
ical_enc BLOB NOT NULL,
etag TEXT NOT NULL,
dt_start TIMESTAMP,
dt_end TIMESTAMP,
summary TEXT NOT NULL DEFAULT '',
recurring BOOLEAN NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(calendar_id, uid)
);
CREATE INDEX IF NOT EXISTS idx_events_calendar ON calendar_events(calendar_id);
CREATE INDEX IF NOT EXISTS idx_events_dtstart ON calendar_events(calendar_id, dt_start);
-- Spam Bayesian tokens (per-user)
CREATE TABLE IF NOT EXISTS spam_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL,
spam_count INTEGER NOT NULL DEFAULT 0,
ham_count INTEGER NOT NULL DEFAULT 0,
UNIQUE(user_id, token)
);
CREATE INDEX IF NOT EXISTS idx_spam_tokens_user ON spam_tokens(user_id, token);
`
+158
View File
@@ -0,0 +1,158 @@
package db
import (
"context"
"database/sql"
"fmt"
"time"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
)
// GetUserByEmail returns the user with the given email (case-insensitive), or nil.
func (d *DB) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
row := d.db.QueryRowContext(ctx, `
SELECT id, domain_id, username, email, password_hash, display_name,
quota_bytes, used_bytes, enabled, admin, domain_admin,
mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login
FROM users WHERE lower(email)=lower(?)`, email)
return scanUser(row)
}
// GetUserByID returns the user with the given ID, or nil.
func (d *DB) GetUserByID(ctx context.Context, id int64) (*models.User, error) {
row := d.db.QueryRowContext(ctx, `
SELECT id, domain_id, username, email, password_hash, display_name,
quota_bytes, used_bytes, enabled, admin, domain_admin,
mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login
FROM users WHERE id=?`, id)
return scanUser(row)
}
// UserExistsByEmail returns true if any user (enabled or not) has this email or alias.
func (d *DB) UserExistsByEmail(ctx context.Context, email string) (bool, error) {
var count int
err := d.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM (
SELECT 1 FROM users WHERE lower(email)=lower(?) AND enabled=1
UNION ALL
SELECT 1 FROM user_aliases WHERE lower(alias_email)=lower(?)
)`, email, email).Scan(&count)
return count > 0, err
}
// ResolveEmail returns the canonical user for an email or alias, or nil.
func (d *DB) ResolveEmail(ctx context.Context, email string) (*models.User, error) {
// Direct match first.
u, err := d.GetUserByEmail(ctx, email)
if err != nil || u != nil {
return u, err
}
// Alias match.
var userID int64
err = d.db.QueryRowContext(ctx,
"SELECT user_id FROM user_aliases WHERE lower(alias_email)=lower(?)", email).
Scan(&userID)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return d.GetUserByID(ctx, userID)
}
// CreateUser inserts a new user. Returns the new ID.
func (d *DB) CreateUser(ctx context.Context, domainID int64, username, email, passwordHash, displayName string, quotaBytes int64, domainAdmin bool) (int64, error) {
res, err := d.db.ExecContext(ctx, `
INSERT INTO users
(domain_id, username, email, password_hash, display_name, quota_bytes,
enabled, admin, domain_admin, created_at)
VALUES (?, ?, ?, ?, ?, ?, 1, 0, ?, ?)`,
domainID, username, email, passwordHash, displayName, quotaBytes, domainAdmin,
time.Now().UTC())
if err != nil {
return 0, fmt.Errorf("create user: %w", err)
}
return res.LastInsertId()
}
// UpdateUsedBytes sets the cached used_bytes for a user (approximate, updated on store).
func (d *DB) UpdateUsedBytes(ctx context.Context, userID int64, delta int64) error {
_, err := d.db.ExecContext(ctx,
"UPDATE users SET used_bytes = MAX(0, used_bytes + ?) WHERE id=?",
delta, userID)
return err
}
// UpdateLastLogin sets last_login to now.
func (d *DB) UpdateLastLogin(ctx context.Context, userID int64) {
d.db.ExecContext(ctx, //nolint:errcheck — best-effort
"UPDATE users SET last_login=? WHERE id=?", time.Now().UTC(), userID)
}
// ListUsers returns all users for a domain.
func (d *DB) ListUsers(ctx context.Context, domainID int64) ([]*models.User, error) {
rows, err := d.db.QueryContext(ctx, `
SELECT id, domain_id, username, email, password_hash, display_name,
quota_bytes, used_bytes, enabled, admin, domain_admin,
mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login
FROM users WHERE domain_id=? ORDER BY email`, domainID)
if err != nil {
return nil, err
}
defer rows.Close()
var users []*models.User
for rows.Next() {
var u models.User
var mfaEnc, rcEnc []byte
var lastLogin sql.NullTime
err := rows.Scan(
&u.ID, &u.DomainID, &u.Username, &u.Email, &u.PasswordHash,
&u.DisplayName, &u.QuotaBytes, &u.UsedBytes, &u.Enabled,
&u.Admin, &u.DomainAdmin,
&mfaEnc, &u.MFAEnabled, &rcEnc,
&u.CreatedAt, &lastLogin,
)
if err != nil {
return nil, err
}
u.MFASecretEnc = mfaEnc
u.RecoveryCodesEnc = rcEnc
if lastLogin.Valid {
u.LastLogin = lastLogin.Time
}
users = append(users, &u)
}
return users, rows.Err()
}
// ---- private ----
func scanUser(row *sql.Row) (*models.User, error) {
var u models.User
var mfaEnc, rcEnc []byte
var lastLogin sql.NullTime
err := row.Scan(
&u.ID, &u.DomainID, &u.Username, &u.Email, &u.PasswordHash,
&u.DisplayName, &u.QuotaBytes, &u.UsedBytes, &u.Enabled,
&u.Admin, &u.DomainAdmin,
&mfaEnc, &u.MFAEnabled, &rcEnc,
&u.CreatedAt, &lastLogin,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
u.MFASecretEnc = mfaEnc
u.RecoveryCodesEnc = rcEnc
if lastLogin.Valid {
u.LastLogin = lastLogin.Time
}
return &u, nil
}