// SPDX-License-Identifier: MPL-2.0 // Package mailsqlite provides an encrypted transactional mail outbox. Callers // own schema-version journals, authorization, rate limits and connection opening. package mailsqlite import ( "context" "crypto/aes" "crypto/cipher" "crypto/hmac" "crypto/rand" "crypto/sha256" "database/sql" "encoding/json" "errors" "time" "gamertan.com/web/mail" ) var ( ErrInvalid = errors.New("mailsqlite: invalid outbox operation") ErrConflict = errors.New("mailsqlite: message identity or lease conflict") ErrFull = errors.New("mailsqlite: pending outbox limit reached") ) const maxAttempts = 5 const leaseDuration = time.Minute type Options struct { // EncryptionKey is a separate 32-byte application-managed secret. Back it up // separately from SQLite. Losing/changing it makes pending messages unreadable. EncryptionKey []byte MaxPending int Now func() time.Time } type Queue struct { db *sql.DB aead cipher.AEAD digestKey []byte maxPending int now func() time.Time } func (*Queue) String() string { return "mailsqlite.Queue{keys:redacted}" } func (queue *Queue) GoString() string { return queue.String() } func derive(key []byte, label string) []byte { mac := hmac.New(sha256.New, key) mac.Write([]byte(label)) return mac.Sum(nil) } func New(db *sql.DB, options Options) (*Queue, error) { if db == nil || len(options.EncryptionKey) != 32 { return nil, ErrInvalid } if options.MaxPending == 0 { options.MaxPending = 1000 } if options.MaxPending < 1 || options.MaxPending > 10000 { return nil, ErrInvalid } if options.Now == nil { options.Now = time.Now } block, err := aes.NewCipher(derive(options.EncryptionKey, "gwf.mail.encryption.v1")) if err != nil { return nil, err } aead, err := cipher.NewGCM(block) if err != nil { return nil, err } return &Queue{db: db, aead: aead, digestKey: derive(options.EncryptionKey, "gwf.mail.deduplication.v1"), maxPending: options.MaxPending, now: options.Now}, nil } // CreateSchema is called inside the application's explicit migration, never // request startup. A caller-owned transaction also makes schema creation atomic. func CreateSchema(ctx context.Context, tx *sql.Tx) error { if tx == nil { return ErrInvalid } for _, statement := range []string{ `CREATE TABLE IF NOT EXISTS gwf_mail_outbox ( id TEXT PRIMARY KEY,state TEXT NOT NULL CHECK(state IN ('queued','sending','accepted','failed','expired')), payload BLOB,digest BLOB NOT NULL CHECK(length(digest)=32),created_at INTEGER NOT NULL,expires_at INTEGER NOT NULL, next_attempt_at INTEGER NOT NULL,attempts INTEGER NOT NULL DEFAULT 0,lease_hash BLOB,lease_until INTEGER NOT NULL DEFAULT 0, last_stage TEXT NOT NULL DEFAULT '',smtp_code INTEGER NOT NULL DEFAULT 0,updated_at INTEGER NOT NULL, CHECK(expires_at>created_at),CHECK(attempts>=0),CHECK(lease_hash IS NULL OR length(lease_hash)=32))`, `CREATE INDEX IF NOT EXISTS gwf_mail_outbox_ready ON gwf_mail_outbox(state,next_attempt_at,created_at,id)`, `CREATE INDEX IF NOT EXISTS gwf_mail_outbox_expiry ON gwf_mail_outbox(expires_at)`, } { if _, err := tx.ExecContext(ctx, statement); err != nil { return err } } return nil } type payload struct { Version int Message mail.Message ExpiresAt time.Time } // EnqueueTx commits mail intent alongside the caller's domain change and audit. // It never commits, sends SMTP or logs plaintext. Reusing an ID is idempotent only // for the same message/expiry; a completed ID cannot send a second time. func (queue *Queue) EnqueueTx(ctx context.Context, tx *sql.Tx, message mail.Message, expiresAt time.Time) error { if tx == nil || message.Validate() != nil { return ErrInvalid } now := queue.now().UTC() expiresAt = expiresAt.UTC().Truncate(time.Second) message.CreatedAt = message.CreatedAt.UTC().Truncate(time.Second) if !expiresAt.After(now) || expiresAt.Sub(now) > 24*time.Hour || !expiresAt.After(message.CreatedAt) || message.CreatedAt.After(now.Add(time.Minute)) { return ErrInvalid } raw, err := json.Marshal(payload{Version: 1, Message: message, ExpiresAt: expiresAt}) if err != nil { return err } mac := hmac.New(sha256.New, queue.digestKey) mac.Write(raw) digest := mac.Sum(nil) nonce := make([]byte, queue.aead.NonceSize()) if _, err = rand.Read(nonce); err != nil { return err } sealed := append([]byte{1}, nonce...) sealed = queue.aead.Seal(sealed, nonce, raw, []byte("gwf.mail.v1:"+message.ID)) // First statement acquires SQLite's writer lock, avoiding read/write upgrade // races when multiple requests enqueue or compete for the bounded capacity. var inserted string err = tx.QueryRowContext(ctx, `INSERT INTO gwf_mail_outbox(id,state,payload,digest,created_at,expires_at,next_attempt_at,updated_at) SELECT ?,'queued',?,?,?,?,?,? WHERE (SELECT COUNT(*) FROM gwf_mail_outbox WHERE payload IS NOT NULL) 100 { return nil, ErrInvalid } rows, err := queue.db.QueryContext(ctx, `SELECT id,state,last_stage,smtp_code,attempts,created_at,expires_at,next_attempt_at FROM gwf_mail_outbox ORDER BY created_at DESC,id DESC LIMIT ?`, limit) if err != nil { return nil, err } defer rows.Close() var records []Record for rows.Next() { var record Record var created, expires, next int64 if err = rows.Scan(&record.ID, &record.State, &record.FailureStage, &record.SMTPCode, &record.Attempts, &created, &expires, &next); err != nil { return nil, err } record.CreatedAt = time.Unix(created, 0).UTC() record.ExpiresAt = time.Unix(expires, 0).UTC() if next > 0 && record.State == "queued" { record.NextAttemptAt = time.Unix(next, 0).UTC() } records = append(records, record) } return records, rows.Err() } // Sweep clears sensitive payloads for expired/exhausted work in batches of 100. // A running, unexpired lease is left alone. Metadata/deduplication tombstones stay. func (queue *Queue) Sweep(ctx context.Context) error { now := queue.now().UTC().Unix() _, err := queue.db.ExecContext(ctx, `UPDATE gwf_mail_outbox SET state=CASE WHEN expires_at<=? THEN 'expired' ELSE 'failed' END, payload=NULL,lease_hash=NULL,lease_until=0,next_attempt_at=0,updated_at=?,last_stage=CASE WHEN expires_at<=? THEN 'expired' ELSE 'attempt-limit' END WHERE id IN (SELECT id FROM gwf_mail_outbox WHERE payload IS NOT NULL AND (expires_at<=? OR attempts>=?) AND (state<>'sending' OR lease_until<=?) ORDER BY expires_at,id LIMIT 100)`, now, now, now, now, maxAttempts, now) return err } func (queue *Queue) open(id string, sealed []byte, expires int64) (mail.Message, error) { n := queue.aead.NonceSize() if len(sealed) < 1+n+queue.aead.Overhead() || sealed[0] != 1 { return mail.Message{}, ErrInvalid } raw, err := queue.aead.Open(nil, sealed[1:1+n], sealed[1+n:], []byte("gwf.mail.v1:"+id)) if err != nil { return mail.Message{}, ErrInvalid } var body payload if json.Unmarshal(raw, &body) != nil || body.Version != 1 || body.Message.ID != id || body.ExpiresAt.Unix() != expires || body.Message.Validate() != nil { return mail.Message{}, ErrInvalid } return body.Message, nil } // ProcessOne takes one bounded lease, commits it, then contacts SMTP without a // database write lock. It records safe status and clears terminal payloads. A // crash/uncertain acceptance can duplicate delivery; stable IDs aid diagnosis. // false means no ready work. A delivery failure is a Record, not a raw SMTP error. func (queue *Queue) ProcessOne(ctx context.Context, transport mail.Transport) (Record, bool, error) { if transport == nil { return Record{}, false, ErrInvalid } if err := queue.Sweep(ctx); err != nil { return Record{}, false, err } now := queue.now().UTC() lease := make([]byte, 32) if _, err := rand.Read(lease); err != nil { return Record{}, false, err } var record Record var sealed []byte var created, expires int64 err := queue.db.QueryRowContext(ctx, `UPDATE gwf_mail_outbox SET state='sending',attempts=attempts+1,lease_hash=?,lease_until=?,updated_at=? WHERE id=(SELECT id FROM gwf_mail_outbox WHERE payload IS NOT NULL AND expires_at>? AND next_attempt_at<=? AND attempts 599 { code = 0 } record.FailureStage, record.SMTPCode = stage, code record.State = "failed" record.NextAttemptAt = finished.Add(time.Minute * time.Duration(1<<(record.Attempts-1))) if !record.ExpiresAt.After(finished) || stage == "expired" { record.State = "expired" } else if retry && record.Attempts < maxAttempts && record.NextAttemptAt.Before(record.ExpiresAt) { record.State = "queued" } } var retain []byte var nextAttempt int64 if record.State == "queued" { retain = sealed nextAttempt = record.NextAttemptAt.Unix() } else { record.NextAttemptAt = time.Time{} } result, err := queue.db.ExecContext(ctx, `UPDATE gwf_mail_outbox SET state=?,payload=?,last_stage=?,smtp_code=?,attempts=?,next_attempt_at=?,lease_hash=NULL,lease_until=0,updated_at=? WHERE id=? AND state='sending' AND lease_hash=? AND lease_until>?`, record.State, retain, record.FailureStage, record.SMTPCode, record.Attempts, nextAttempt, finished.Unix(), record.ID, lease, finished.Unix()) if err != nil { return record, true, err } changed, err := result.RowsAffected() if err != nil { return record, true, err } if changed != 1 { return record, true, ErrConflict } return record, true, nil }