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.
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user