Files
web/mailsqlite/queue.go
T
gamertan 494b72fa3b
verify / verify (push) Successful in 4m29s
Release v0.1.0-preview.28: transactional account email
Reviewed source export adds verified TLS mail, encrypted outbox, mailbox verification and password reset protocols. Preserve public ancestry; omit local development history and operational queue. Consumer deployment and inbox delivery proof remain separate.
2026-09-11 04:12:17 -04:00

335 lines
12 KiB
Go

// 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)<?
ON CONFLICT(id) DO NOTHING RETURNING id`, message.ID, sealed, digest, message.CreatedAt.Unix(), expiresAt.Unix(), now.Unix(), now.Unix(), queue.maxPending).Scan(&inserted)
if err == nil {
return nil
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
var existing []byte
if err = tx.QueryRowContext(ctx, `SELECT digest FROM gwf_mail_outbox WHERE id=?`, message.ID).Scan(&existing); errors.Is(err, sql.ErrNoRows) {
return ErrFull
} else if err != nil {
return err
}
if !hmac.Equal(existing, digest) {
return ErrConflict
}
return nil
}
func (queue *Queue) Enqueue(ctx context.Context, message mail.Message, expiresAt time.Time) error {
tx, err := queue.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err = queue.EnqueueTx(ctx, tx, message, expiresAt); err != nil {
return err
}
return tx.Commit()
}
// Record is safe operational metadata; it contains no recipient/body/token.
// Expose it only through the application's current operator authority.
type Record struct {
ID, State, FailureStage string
SMTPCode, Attempts int
CreatedAt, ExpiresAt, NextAttemptAt time.Time
}
func (queue *Queue) Recent(ctx context.Context, limit int) ([]Record, error) {
if limit < 1 || limit > 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<?
AND (state='queued' OR (state='sending' AND lease_until<=?)) ORDER BY created_at,id LIMIT 1)
RETURNING id,payload,attempts,created_at,expires_at`, lease, now.Add(leaseDuration).Unix(), now.Unix(), now.Unix(), now.Unix(), maxAttempts, now.Unix()).Scan(&record.ID, &sealed, &record.Attempts, &created, &expires)
if errors.Is(err, sql.ErrNoRows) {
return Record{}, false, nil
}
if err != nil {
return Record{}, false, err
}
record.CreatedAt = time.Unix(created, 0).UTC()
record.ExpiresAt = time.Unix(expires, 0).UTC()
message, sendErr := queue.open(record.ID, sealed, expires)
payloadInvalid := sendErr != nil
if sendErr == nil {
// End transmission before the lease expires. Also stop when the message's
// own deadline expires; no stale verification link starts after expiry.
duration := min(30*time.Second, record.ExpiresAt.Sub(queue.now().UTC()))
if duration <= 0 {
sendErr = &mail.Error{Stage: "expired"}
} else {
sendContext, cancel := context.WithTimeout(ctx, duration)
sendErr = transport.Send(sendContext, message)
cancel()
}
}
finished := queue.now().UTC()
record.State = "accepted"
if payloadInvalid {
// A wrong key is an operational fault, not a delivery attempt. Preserve
// ciphertext until expiry so fixing configuration can recover pending
// messages. The same bounded delay also covers corrupt/invalid payloads;
// never pass them to SMTP or persist the decryption error.
record.Attempts--
record.State, record.FailureStage = "queued", "payload"
record.NextAttemptAt = finished.Add(5 * time.Minute)
if !record.ExpiresAt.After(finished) {
record.State = "expired"
}
} else if sendErr != nil {
stage, code, retry := "transport", 0, true
var failure *mail.Error
if errors.As(sendErr, &failure) {
stage, code, retry = failure.Stage, failure.Code, failure.Retryable
}
// Only bounded known stages may enter durable diagnostics, even for a
// caller-provided Transport. Never persist arbitrary error strings.
switch stage {
case "connect", "tls", "greeting", "starttls-required", "authentication", "sender", "recipient", "data", "acceptance", "payload", "expired":
default:
stage = "transport"
}
if code < 0 || code > 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
}