diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
new file mode 100644
index 0000000..2ec093e
--- /dev/null
+++ b/ARCHITECTURE.md
@@ -0,0 +1,495 @@
+# Hedwig Architecture
+
+> Covers the server as of the `log-queue` branch. The durable log queue is the
+> default subject; the legacy filesystem path is described where it
+> differs. Design rationale lives in [the log-queue plan](docs/plans/2026-07-20-durable-log-queue.md); this document describes
+> what is actually built and where.
+
+## 1. The big picture
+
+Hedwig is a single-process MTA in two crates:
+
+- `smtp/` — the SMTP protocol library: session state machine, parser, TLS
+ (implicit + STARTTLS), timeouts. It owns the wire; everything else is
+ reached through the `SmtpCallbacks` trait.
+- `smtp-server/` — the server: callbacks, queue storage, delivery workers,
+ DKIM, MTA-STS, rate limiting, metrics, CLI.
+
+The central idea of the log-queue design is **separating two rates that have
+nothing to do with each other**:
+
+- *Inbound* is bounded by how fast complete messages can be appended to disk.
+- *Outbound* is bounded by DNS, remote MTAs, and politeness rate limits.
+
+The durable log sits between them. Acceptance (`250 OK`) waits only for the
+message to be written into the kernel page cache; delivery happens whenever
+the outbound side gets to it.
+
+```mermaid
+flowchart LR
+ subgraph inbound [Inbound]
+ C[SMTP clients] --> L[smtp crate session + parser]
+ L --> CB[callbacks process_email_log]
+ end
+ subgraph queue [Durable log queue]
+ CB -->|"append(msg)"| W0[writer shard 0]
+ CB -->|append| W1[writer shard N]
+ W0 --> S0[(segments + journal shard 0)]
+ W1 --> S1[(segments + journal shard N)]
+ S0 & S1 -.->|discovery cursors| D[dispatcher one task]
+ end
+ subgraph outbound [Outbound]
+ D -->|claims| LW[log workers]
+ LW -->|read body by location| S0
+ LW --> MX[remote MTAs]
+ LW -->|"outcome (delivered / deferred / bounced / rate-limited)"| D
+ end
+ D -->|persist-then-apply| S0
+```
+
+With `storage_type = "fs"` the old path is used instead:
+`storage.put()` → bounded `async_channel` → channel workers → periodic
+deferred-directory scans. That path is untouched and is why the log queue is
+a separate, selectable backend (`storage_type = "log"`).
+
+Module map:
+
+| Area | Files |
+|---|---|
+| Record format | `smtp-server/src/logqueue/record.rs` |
+| Segments | `smtp-server/src/logqueue/segment.rs` |
+| Shard/spool layout, lock | `smtp-server/src/logqueue/{shard,spool}.rs` |
+| Append writers | `smtp-server/src/logqueue/writer.rs` |
+| Journal + checkpoints | `smtp-server/src/logqueue/state.rs` |
+| Dispatcher (scheduling, GC, compaction) | `smtp-server/src/logqueue/dispatcher.rs` |
+| Delivery workers | `smtp-server/src/worker/{mod,log_worker}.rs` |
+| Acceptance path | `smtp-server/src/callbacks.rs` (`process_email_log`) |
+| Wiring & shutdown | `smtp-server/src/main.rs` |
+| Operator CLI, migration | `smtp-server/src/{queue_cli,migrate}.rs` |
+
+## 2. On-disk layout
+
+```text
+/
+ bounced/ # bounce archive (legacy fs format, retention-cleaned)
+ spool/
+ format-version # "1"
+ .lock # exclusive flock, held for the server's lifetime
+ shard-0000/
+ segment-000000000007.log # sealed payload segments (immutable)
+ segment-000000000009.log
+ segment-000000000010.open # the one active append target
+ journal-000000000003.log # state journal (current)
+ checkpoint # latest checkpoint (atomic rename)
+ shard-0001/ ...
+```
+
+Rules the layout encodes:
+
+- One shard per append writer; a shard is owned by exactly one writer task.
+- At most one `.open` segment per shard. Sealing renames `.open → .log`;
+ sealed segments are immutable forever.
+- Segment ordinals are never reused; the active segment always has the
+ shard's highest ordinal.
+- The number of files is proportional to *live backlog + unreclaimed
+ garbage*, never to historical volume — fully dead segments are unlinked.
+- Changing `append_writers` requires an empty queue (checked at startup in
+ `Spool::open`).
+
+## 3. Payload record format
+
+Records are self-framing and versioned (`record.rs`). Everything the
+scheduler needs is in the header, so discovery never reads message bodies.
+
+```text
+offset size field
+ 0 4 magic "HWLQ"
+ 4 2 format version (=1)
+ 6 2 flags (reserved, zero)
+ 8 4 record_len — total size; scanner skips to next record with this
+ 12 4 header_len — body starts here
+ 16 4 header_crc — crc32 over [0..16) ++ [20..header_len)
+ 20 4 payload_crc — crc32 over body
+ 24 16 message id (binary ULID)
+ 40 8 enqueue timestamp (unix ms) — survives relocation, drives age metrics
+ 48 4 relocation generation — bumped when compaction copies a record
+ 52 4 per-segment ordinal
+ 56 var envelope: sender, recipient list (u16-length-prefixed strings)
+ … var body (record_len - header_len bytes)
+```
+
+The two checksums serve different failures: `header_crc` catches torn or
+corrupt headers during scans; `payload_crc` is verified on every body read so
+bit rot in a sealed segment surfaces at delivery time, not silently.
+
+**Tail policy** (`segment.rs`): on recovery the active segment is scanned
+from 0; the first invalid position truncates the file (a torn tail is mail
+that was never fully acknowledged, or falls inside the accepted power-loss
+window). Corruption *inside a sealed segment* is a hard error, never a skip —
+sealed data was durable and complete, so damage there means something is
+wrong enough that a human should look.
+
+## 4. Acceptance path (SMTP → disk)
+
+```mermaid
+sequenceDiagram
+ participant C as client
+ participant S as smtp session
+ participant CB as callbacks
+ participant AH as AppendHandle
+ participant W as shard writer task
+ participant DI as dispatcher
+
+ C->>S: DATA ... .
+ S->>CB: on_data(email)
+ CB->>CB: disk reserve check (452 if breached)
+ CB->>AH: append(AppendMessage)
+ AH->>AH: acquire byte permits (pending_append_bytes)
+ AH->>W: mpsc send (shard = hash(ulid) % N)
+ W->>W: encode record, write_all to active segment
+ W->>W: advance committed head (chain mutex)
+ W-->>DI: Notify (lossy hint)
+ W-->>AH: JobLocation via oneshot
+ AH-->>CB: Ok
+ CB-->>S: Ok
+ S-->>C: 250 OK
+```
+
+Key properties (`writer.rs`):
+
+- **Admission is byte-bounded, not count-bounded.** A tokio semaphore sized
+ by `pending_append_bytes` is acquired for the encoded record size before
+ queueing; it frees once the bytes reach the page cache. This is real
+ backpressure against disk throughput, never against outbound speed.
+- **Publish ordering per record:** write → advance committed head → notify →
+ complete the SMTP future. The committed head can never expose a partial
+ record, so concurrent readers below the head are always safe.
+- **Rotation is seal-first.** When a record would overflow
+ `segment_target_bytes`: seal (rename) the active segment, *then* create the
+ next one. A crash in between leaves zero `.open` files (recovery creates
+ one); create-first could leave two, which is an unrecoverable layout error.
+ If the create fails, `active` becomes `None` and the next append retries it
+ rather than ever writing into the sealed file.
+- Writers run on `spawn_blocking` threads and own their files exclusively —
+ no shared append offsets, no locks on the hot path except the tiny chain
+ mutex.
+
+What the writer publishes to the dispatcher is the **chain**: an ordered list
+of `SegmentHead { segment, committed, sealed }`, last entry = active. The
+chain is authoritative; the `Notify` is an optimization that may be lost or
+coalesced — a 500ms safety tick re-checks regardless.
+
+## 5. Dispatcher: one task, all scheduling
+
+The dispatcher (`dispatcher.rs`) is a single tokio task owning every
+scheduling structure. Nothing else mutates them, which is what makes the
+claim/outcome protocol race-free without fine-grained locking.
+
+```text
+Dispatcher state
+├── jobs: HashMap # location, attempts, remaining rcpts, state
+├── ready: BinaryHeap<(enqueue_ms, id)> # oldest-first dispatch order
+├── delayed: BinaryHeap<(due_ms, id)> # deferred retries + rate-limit holds
+├── waiting: VecDeque # parked workers wanting work
+└── per shard:
+ ├── cursor (segment, offset) # next undiscovered position
+ ├── tombstones: segment -> {ids} # terminal records, filter for re-scans
+ ├── stats: segment -> {total/dead bytes} # GC accounting
+ └── ShardStateStore # journal + checkpoint writer
+```
+
+### 5.1 Discovery
+
+On a notify or the safety tick, each shard's cursor is advanced through the
+chain: read headers from `cursor` to the committed head, register unknown
+message ids as `Ready`, hop to the next chain entry when a sealed segment is
+exhausted (pruning consumed entries). Two filters apply during the scan:
+
+- ids tombstoned in that segment are skipped (they went terminal after a
+ checkpoint but before this re-scan);
+- an id that is already tracked but appears with a **higher relocation
+ generation** is a compaction copy whose journal entry was lost in a crash —
+ the dispatcher re-journals the relocation so the accounting becomes durable.
+
+**Backpressure:** discovery stops while `jobs` holds `max_tracked_jobs`
+(100k) entries. The log itself holds everything beyond the window, so memory
+is bounded by config while the backlog is bounded only by disk. This applies
+during recovery too — a million-message backlog does not OOM the process.
+
+### 5.2 Job lifecycle
+
+```mermaid
+stateDiagram-v2
+ [*] --> Ready: discovered from log / recovered from checkpoint
+ Ready --> InFlight: claim (generation g)
+ InFlight --> Ready: worker abandoned claim (drop without report)
+ InFlight --> Delayed_p: Deferred outcome (persisted, attempts+1)
+ InFlight --> Delayed_m: RateLimited outcome (memory only, no attempt)
+ Ready --> Delayed_m: dispatch gate: domain exhausted
+ Delayed_p --> Ready: due time reached
+ Delayed_m --> Ready: due time reached
+ InFlight --> [*]: Delivered / Bounced (journaled, tombstoned)
+
+ Delayed_p: Delayed (persisted defer)
+ Delayed_m: Delayed (in-memory hold)
+```
+
+Workers pull with `DispatcherHandle::claim()`. A claim carries a
+monotonically increasing **generation**; outcomes and abandonments quote it,
+and anything stale is ignored — a hung worker's late report can never clobber
+a reassigned job. Dropping a `Claim` without reporting sends an abandonment
+(via its `Drop` impl), so a panicking worker returns its job to `Ready`
+automatically; a claim can never leak.
+
+The job a worker receives is payload-free: id, location, attempts, sender,
+remaining recipients. The body is read separately by position
+(`read_body`), with its checksum verified on every read.
+
+### 5.3 Rate limiting: gate + acquire
+
+Two checks share one token-bucket `RateLimiter`:
+
+1. **Dispatch gate** (dispatcher, `peek_sync`): non-consuming. If the first
+ remaining recipient's domain has no tokens, the job goes to the in-memory
+ delay heap instead of wasting a worker slot. A due time is not a token
+ reservation — the job re-passes the gate when it wakes, which prevents a
+ thundering herd on one domain.
+2. **Worker acquire** (`check_rate_limit`): consuming, immediately before
+ transmission — the authoritative check. Losing this race reports
+ `RateLimited`, which requeues in memory only: **no attempt increment, no
+ journal write**. Local throttling is not a delivery failure.
+
+## 6. Persistent state: journal + checkpoint
+
+A payload record implies `Ready` unless superseded — so acceptance costs one
+write, not two. Everything that changes afterwards is an entry in the shard's
+state journal (`state.rs`):
+
+```text
+DEFERRED { id, location, attempts, next_attempt_ms, remaining_recipients, last_error }
+DELIVERED { id, location, timestamp }
+BOUNCED { id, location, timestamp, reason }
+RELOCATED { id, old_location, new_location } # written by compaction
+```
+
+Entries are `[len][crc][payload]`-framed; the journal uses the same
+page-cache durability as payloads, and the same torn-tail truncation policy.
+
+**Persist-then-apply** is the ordering rule everywhere: the dispatcher writes
+the journal entry first and mutates its in-memory state only after the write
+succeeds. A failed write parks the entry in a retry queue with the job left
+in-flight — a job is never marked terminal, and never dropped from
+scheduling, on the strength of an unpersisted transition.
+
+### 6.1 Checkpoints
+
+Journals would grow forever, so once a shard writes
+`checkpoint_interval_bytes` of journal it snapshots. A checkpoint is
+**self-sufficient for every segment still on disk**; without that, deleting
+journal history could resurrect delivered mail (payload implies Ready!). It
+contains: terminal tombstones per live segment, the discovered ready set
+(with attempts *and remaining recipients*), the deferred set, per-segment GC
+stats, the discovery cursor, and the journal position it covers.
+
+The write sequence keeps the dispatcher unblocked and the disk safe:
+
+```text
+1. fsync current journal # nothing covered may be less durable than the checkpoint
+2. rotate: start journal N+1 # entries during the snapshot land after the cut
+3. snapshot in-memory state # cheap, synchronous
+4. spawn_blocking:
+ write checkpoint.tmp -> fsync -> rename -> fsync dir
+5. on success: delete journals <= N # the only place journal history dies
+```
+
+A crash anywhere before step 5 is safe: the old checkpoint plus the complete
+journal chain reproduces the same state. Step 5 is a *destructive boundary*,
+which is why steps 1 and 4 fsync (see §8).
+
+### 6.2 Recovery
+
+```mermaid
+flowchart TD
+ A[Spool::open lock + version + shard-count check] --> B[per shard: validate active tail truncate torn suffix]
+ B --> C[load checkpoint crc-verified]
+ C --> D[replay journals newer than checkpoint ordered; torn tail of newest truncated]
+ D --> E[reconcile with validated chain: clamp cursor to committed head, drop jobs whose payload died with the tail]
+ E --> F[dispatcher starts: ready/deferred/tombstones/stats live]
+ F --> G[discovery resumes from cursor = lazy payload reconciliation, backpressure applies]
+ G --> H[listener binds; workers pull]
+```
+
+Restart semantics: terminal stays terminal; deferred keeps attempts, due
+time, and remaining recipients; everything else — including jobs that were
+in-flight at the crash — becomes ready and is redispatched (at-least-once).
+There is no "feed the whole backlog through a channel" step; startup cost is
+checkpoint size + journal delta, not queue depth.
+
+Two guards worth knowing: journals must form a contiguous chain starting at
+the checkpoint's position (or ordinal 1 when there is no checkpoint) — a gap
+is a hard error, because silently lost journal history can resurrect
+delivered mail. And recovery scans always use the *format's* maximum record
+size, not the configured one, so lowering `max_message_size` can never make
+previously accepted mail look corrupt.
+
+## 7. Delivery (workers)
+
+`LogWorker::run` is a pull loop: `claim() → read_body() → process_claim() →
+report(outcome)`. `Worker::process_claim` (in `worker/mod.rs`) shares the
+per-recipient delivery core with the legacy path (`deliver_recipient`: MX
+lookup, MTA-STS policy, transport attempts, outcome classification) but
+differs deliberately:
+
+- **Per-recipient accounting.** Delivered recipients leave the remaining
+ set; only transiently-failed ones are retried. A partial multi-recipient
+ failure re-sends only to recipients that have not accepted the message
+ (the remaining set is persisted in the DEFERRED entry and survives
+ restarts and checkpoints).
+- **No sleeping in worker slots.** The legacy path sleeps on rate limits
+ inside the worker; the log path reports and moves on.
+- **Retry budget**: `attempts >= max_retries` bounces terminally.
+- **Bounce archive**: before reporting `Bounced`, the message is written to
+ `/bounced/` in the legacy one-file format, so operators keep the
+ same inspection workflow and `[storage.cleanup]` retention applies.
+ Archive failure is logged but never blocks the bounce.
+
+Backoff is `60s × 2^attempts`, capped at 24h — same curve as the legacy
+deferred worker, but scheduled by the dispatcher's due-time heap instead of a
+30-second directory scan.
+
+## 8. GC, compaction, and the durability model
+
+### 8.1 Reclamation
+
+Per-segment accounting is byte-based: `total_bytes` (known once sealed — it
+is the file length) and `dead_bytes` (accumulated as records go terminal or
+get relocated; tombstone sets make it idempotent).
+
+- **Deletion** is event-driven: the terminal transition that makes
+ `dead_bytes == total_bytes` deletes the segment immediately (fsync journal
+ → unlink → drop tombstones/stats/reader → remove from chain). A tick-driven
+ sweep backstops the one miss window (a segment whose last record dies
+ before the dispatcher observed its seal). At high delivery rates this is
+ the dominant path: burst segments die whole and are unlinked without any
+ copying.
+- **Compaction** handles segments pinned by long-deferred stragglers: sealed,
+ past `compaction_min_age`, `dead_ratio ≥ compaction_dead_ratio` (default
+ 0.5, ≈2× amplification bound), fully below the discovery cursor. One runs
+ at a time, driven in small batches from the dispatcher tick.
+
+```mermaid
+flowchart TD
+ A[pick source segment sealed, old, ≥50% dead] --> B[snapshot its live ids skip in-flight claims]
+ B --> C[per record: read old copy]
+ C --> D[re-append via the normal writer generation+1, original enqueue_ms]
+ D --> E[journal RELOCATED old→new persist-then-apply]
+ E --> F[apply: job.location = new, old copy counted dead]
+ F --> G{source fully dead?}
+ G -- yes --> H[fsync journal → unlink source]
+ G -- not yet --> I[left for next sweep ratio still high → re-picked]
+```
+
+Because relocated copies flow through the ordinary append path they are
+discovered like new records; the relocation generation resolves every
+"which copy wins" question, including after a crash that leaves both copies
+on disk. In-flight records are skipped rather than relocated, which is what
+makes stale-read refetch protocols unnecessary: a segment is only ever
+unlinked when nothing live — and therefore nothing readable — remains in it.
+A terminal outcome racing the copy cannot resurrect the message: the ordered
+journal replay marks whichever copy lost as garbage.
+
+### 8.2 What is durable when
+
+| Event | Guarantee |
+|---|---|
+| Process crash / SIGKILL | Nothing acknowledged is lost. Page cache survives the process; journals and segments replay. |
+| Machine crash / power loss | Recently acknowledged mail (page cache not yet written back) may be lost. **Documented, accepted tradeoff** — this is why acceptance is fast. |
+| Any crash, old queued mail | Never lost. Destructive operations (checkpoint truncation, segment deletion, compaction publication) fsync before removing anything, so power loss can only eat the recent write window, never history. |
+| Remote accepted, crash before journal | The message is redelivered — at-least-once, duplicates possible, loss not. |
+
+The rule of thumb encoded throughout: **fsync is reserved for the moments we
+destroy something**; everything additive rides the page cache.
+
+## 9. Startup, shutdown, config
+
+`main.rs` branches on `storage_type == "log"`:
+
+- Startup order: spool (lock lives until exit) → writers (tail validation) →
+ per-shard state recovery → dispatcher → log workers → listeners. No replay
+ channel; the deferred-scan worker is not spawned. The cleanup task runs
+ with `deferred_retention` forcibly disabled so it can never eat an
+ unmigrated legacy spool sitting at the same `base_path`.
+- Shutdown: cancel token stops listeners → workers finish their current
+ claims and exit when the dispatcher stops handing out work → dispatcher
+ drains in-flight outcomes, persists them, checkpoints every shard → append
+ admission closes last (pending appends flush) → spool lock releases.
+
+Config (`[queue]`, all optional):
+
+```toml
+[queue]
+append_writers = 1 # shards; change requires an empty queue
+pending_append_bytes = 134217728 # admission buffer bound (bytes, not msgs)
+segment_target_bytes = 67108864 # seal threshold; must fit max_message_size
+compaction_dead_ratio = 0.5
+compaction_min_age = "60s"
+disk_reserve_bytes = 1073741824 # 452-reject below this free space
+checkpoint_interval_bytes = 8388608
+```
+
+## 10. Operator tooling
+
+Binary segments are not `ls`-able, so the CLI is part of the design:
+
+```sh
+hedwig queue list --spool /spool # live messages: state, age, attempts, due
+hedwig queue show --spool /spool ID # envelope, state history, location
+hedwig queue stats --spool /spool # per-segment live/dead, ratios
+hedwig queue migrate --config # one-time legacy fs-spool migration
+```
+
+`list/show/stats` are strictly read-only and safe against a live spool: they
+take no lock, never truncate torn tails, and never create files. `migrate` is
+restart-safe (idempotent by message-id dedup against the target spool),
+preserves attempts/due-times/last-errors, verifies every record after the
+writers flush, and then renames `queued/`/`deferred/` to timestamped
+`.migrated-*` backups — it never deletes anything.
+
+## 11. Measured behavior
+
+Same machine, 1 KiB messages, 16 connections (see docs/plans/2026-07-20-durable-log-queue.md §27 for the full
+plan; these are the two headline experiments):
+
+| Scenario | fs backend | log backend |
+|---|---|---|
+| Outbound disabled (pure ingest, tmpfs) | 85–93k msg/s | 97–105k msg/s |
+| Outbound via a 500 ms/message MTA, 64 workers | **117 msg/s** (p99 accept 548ms) | **170,411 msg/s** (p99 accept 0.28ms) |
+| Outbound delivery rate in that test | ~121 msg/s | ~121 msg/s |
+
+The second row is the design goal made visible: with slow outbound the legacy
+bounded channel fills and acceptance collapses to the outbound drain rate,
+while the log backend keeps accepting at disk speed (5.97M messages parked in
+6.7 GiB) and delivers at exactly the same outbound rate. Sustained ingest
+reclaims itself: a 30s full-throughput run writes ~3.5 GiB and ends with only
+the active segment on disk.
+
+## 12. Invariants (the list to check before changing anything)
+
+1. The committed head never exposes a partial record.
+2. A record never spans segments; the active segment has the shard's highest
+ ordinal; `.open` files number 0 or 1 per shard.
+3. Terminal or relocated state is applied in memory only after its journal
+ entry is written (persist-then-apply).
+4. Journal history is deleted only below a durable (fsynced + renamed)
+ checkpoint; checkpoints are self-sufficient for every segment still on
+ disk.
+5. Segments are unlinked only when nothing live remains in them and the
+ journal covering their deaths is fsynced.
+6. Recovery scans use the format's absolute record bound, never the
+ configured one.
+7. Claims carry generations; a stale generation's outcome is ignored.
+8. Rate-limit holds are never persisted and never count as attempts.
+9. Relocation preserves message id and enqueue timestamp and bumps the
+ generation; the highest valid generation wins everywhere.
+10. Locations are always explicit (shard, segment, offset) — nothing may
+ re-derive a shard from the current writer count.
diff --git a/Cargo.lock b/Cargo.lock
index d8f972d..42fe3d0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -159,15 +159,6 @@ dependencies = [
"syn",
]
-[[package]]
-name = "atoi"
-version = "2.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
-dependencies = [
- "num-traits",
-]
-
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -251,9 +242,6 @@ name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
-dependencies = [
- "serde_core",
-]
[[package]]
name = "block-buffer"
@@ -505,21 +493,6 @@ dependencies = [
"libc",
]
-[[package]]
-name = "crc"
-version = "3.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636"
-dependencies = [
- "crc-catalog",
-]
-
-[[package]]
-name = "crc-catalog"
-version = "2.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
-
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -553,15 +526,6 @@ dependencies = [
"crossbeam-utils",
]
-[[package]]
-name = "crossbeam-queue"
-version = "0.3.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
-dependencies = [
- "crossbeam-utils",
-]
-
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -654,12 +618,6 @@ dependencies = [
"syn",
]
-[[package]]
-name = "dotenvy"
-version = "0.15.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
-
[[package]]
name = "dunce"
version = "1.0.5"
@@ -695,9 +653,6 @@ name = "either"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
-dependencies = [
- "serde",
-]
[[package]]
name = "email-address-parser"
@@ -765,17 +720,6 @@ dependencies = [
"windows-sys 0.59.0",
]
-[[package]]
-name = "etcetera"
-version = "0.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
-dependencies = [
- "cfg-if",
- "home",
- "windows-sys 0.48.0",
-]
-
[[package]]
name = "event-listener"
version = "5.4.0"
@@ -825,29 +769,12 @@ dependencies = [
"miniz_oxide",
]
-[[package]]
-name = "flume"
-version = "0.11.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
-dependencies = [
- "futures-core",
- "futures-sink",
- "spin",
-]
-
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
-[[package]]
-name = "foldhash"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
-
[[package]]
name = "form_urlencoded"
version = "1.2.1"
@@ -905,17 +832,6 @@ dependencies = [
"futures-util",
]
-[[package]]
-name = "futures-intrusive"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
-dependencies = [
- "futures-core",
- "lock_api",
- "parking_lot",
-]
-
[[package]]
name = "futures-io"
version = "0.3.31"
@@ -1075,11 +991,6 @@ name = "hashbrown"
version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
-dependencies = [
- "allocator-api2",
- "equivalent",
- "foldhash",
-]
[[package]]
name = "hashify"
@@ -1092,15 +1003,6 @@ dependencies = [
"syn",
]
-[[package]]
-name = "hashlink"
-version = "0.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
-dependencies = [
- "hashbrown 0.15.2",
-]
-
[[package]]
name = "heck"
version = "0.5.0"
@@ -1115,10 +1017,12 @@ dependencies = [
"async-stream",
"async-trait",
"base64 0.21.7",
+ "bytes",
"camino",
"chrono",
"clap",
"config",
+ "crc32fast",
"ed25519-dalek",
"email-address-parser",
"futures",
@@ -1129,6 +1033,7 @@ dependencies = [
"huml-rs",
"hyper 0.14.32",
"lettre",
+ "libc",
"mail-auth",
"mail-parser 0.9.4",
"mailparse",
@@ -1149,7 +1054,6 @@ dependencies = [
"serde",
"serde_json",
"smtp",
- "sqlx",
"subtle",
"tempfile",
"thiserror 1.0.69",
@@ -1162,12 +1066,6 @@ dependencies = [
"ulid",
]
-[[package]]
-name = "hex"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
-
[[package]]
name = "hickory-net"
version = "0.26.1"
@@ -1299,15 +1197,6 @@ dependencies = [
"tracing",
]
-[[package]]
-name = "hkdf"
-version = "0.12.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
-dependencies = [
- "hmac",
-]
-
[[package]]
name = "hmac"
version = "0.12.1"
@@ -1317,15 +1206,6 @@ dependencies = [
"digest",
]
-[[package]]
-name = "home"
-version = "0.5.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf"
-dependencies = [
- "windows-sys 0.59.0",
-]
-
[[package]]
name = "hostname"
version = "0.3.1"
@@ -1857,29 +1737,6 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
-[[package]]
-name = "libredox"
-version = "0.1.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
-dependencies = [
- "bitflags",
- "libc",
- "plain",
- "redox_syscall 0.7.3",
-]
-
-[[package]]
-name = "libsqlite3-sys"
-version = "0.30.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
-dependencies = [
- "cc",
- "pkg-config",
- "vcpkg",
-]
-
[[package]]
name = "linked-hash-map"
version = "0.5.6"
@@ -2264,7 +2121,7 @@ checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
dependencies = [
"cfg-if",
"libc",
- "redox_syscall 0.5.8",
+ "redox_syscall",
"smallvec",
"windows-targets 0.52.6",
]
@@ -2384,12 +2241,6 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2"
-[[package]]
-name = "plain"
-version = "0.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
-
[[package]]
name = "portable-atomic"
version = "1.10.0"
@@ -2646,15 +2497,6 @@ dependencies = [
"bitflags",
]
-[[package]]
-name = "redox_syscall"
-version = "0.7.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
-dependencies = [
- "bitflags",
-]
-
[[package]]
name = "regex"
version = "1.11.1"
@@ -3041,17 +2883,6 @@ dependencies = [
"serde",
]
-[[package]]
-name = "sha1"
-version = "0.10.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
-dependencies = [
- "cfg-if",
- "cpufeatures 0.2.17",
- "digest",
-]
-
[[package]]
name = "sha2"
version = "0.10.8"
@@ -3133,9 +2964,6 @@ name = "smallvec"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
-dependencies = [
- "serde",
-]
[[package]]
name = "smtp"
@@ -3167,9 +2995,6 @@ name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
-dependencies = [
- "lock_api",
-]
[[package]]
name = "spki"
@@ -3181,194 +3006,6 @@ dependencies = [
"der",
]
-[[package]]
-name = "sqlx"
-version = "0.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
-dependencies = [
- "sqlx-core",
- "sqlx-macros",
- "sqlx-mysql",
- "sqlx-postgres",
- "sqlx-sqlite",
-]
-
-[[package]]
-name = "sqlx-core"
-version = "0.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
-dependencies = [
- "base64 0.22.1",
- "bytes",
- "crc",
- "crossbeam-queue",
- "either",
- "event-listener",
- "futures-core",
- "futures-intrusive",
- "futures-io",
- "futures-util",
- "hashbrown 0.15.2",
- "hashlink",
- "indexmap",
- "log",
- "memchr",
- "once_cell",
- "percent-encoding",
- "serde",
- "serde_json",
- "sha2",
- "smallvec",
- "thiserror 2.0.11",
- "tokio",
- "tokio-stream",
- "tracing",
- "url",
-]
-
-[[package]]
-name = "sqlx-macros"
-version = "0.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
-dependencies = [
- "proc-macro2",
- "quote",
- "sqlx-core",
- "sqlx-macros-core",
- "syn",
-]
-
-[[package]]
-name = "sqlx-macros-core"
-version = "0.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
-dependencies = [
- "dotenvy",
- "either",
- "heck",
- "hex",
- "once_cell",
- "proc-macro2",
- "quote",
- "serde",
- "serde_json",
- "sha2",
- "sqlx-core",
- "sqlx-mysql",
- "sqlx-postgres",
- "sqlx-sqlite",
- "syn",
- "tokio",
- "url",
-]
-
-[[package]]
-name = "sqlx-mysql"
-version = "0.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
-dependencies = [
- "atoi",
- "base64 0.22.1",
- "bitflags",
- "byteorder",
- "bytes",
- "crc",
- "digest",
- "dotenvy",
- "either",
- "futures-channel",
- "futures-core",
- "futures-io",
- "futures-util",
- "generic-array",
- "hex",
- "hkdf",
- "hmac",
- "itoa",
- "log",
- "md-5",
- "memchr",
- "once_cell",
- "percent-encoding",
- "rand 0.8.5",
- "rsa",
- "serde",
- "sha1",
- "sha2",
- "smallvec",
- "sqlx-core",
- "stringprep",
- "thiserror 2.0.11",
- "tracing",
- "whoami",
-]
-
-[[package]]
-name = "sqlx-postgres"
-version = "0.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
-dependencies = [
- "atoi",
- "base64 0.22.1",
- "bitflags",
- "byteorder",
- "crc",
- "dotenvy",
- "etcetera",
- "futures-channel",
- "futures-core",
- "futures-util",
- "hex",
- "hkdf",
- "hmac",
- "home",
- "itoa",
- "log",
- "md-5",
- "memchr",
- "once_cell",
- "rand 0.8.5",
- "serde",
- "serde_json",
- "sha2",
- "smallvec",
- "sqlx-core",
- "stringprep",
- "thiserror 2.0.11",
- "tracing",
- "whoami",
-]
-
-[[package]]
-name = "sqlx-sqlite"
-version = "0.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
-dependencies = [
- "atoi",
- "flume",
- "futures-channel",
- "futures-core",
- "futures-executor",
- "futures-intrusive",
- "futures-util",
- "libsqlite3-sys",
- "log",
- "percent-encoding",
- "serde",
- "serde_urlencoded",
- "sqlx-core",
- "thiserror 2.0.11",
- "tracing",
- "url",
-]
-
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
@@ -3388,17 +3025,6 @@ dependencies = [
"windows-sys 0.59.0",
]
-[[package]]
-name = "stringprep"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
-dependencies = [
- "unicode-bidi",
- "unicode-normalization",
- "unicode-properties",
-]
-
[[package]]
name = "strsim"
version = "0.11.1"
@@ -3656,17 +3282,6 @@ dependencies = [
"tokio",
]
-[[package]]
-name = "tokio-stream"
-version = "0.1.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
-dependencies = [
- "futures-core",
- "pin-project-lite",
- "tokio",
-]
-
[[package]]
name = "tokio-util"
version = "0.7.13"
@@ -3747,7 +3362,6 @@ version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
- "log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -3850,12 +3464,6 @@ dependencies = [
"web-time",
]
-[[package]]
-name = "unicode-bidi"
-version = "0.3.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
-
[[package]]
name = "unicode-ident"
version = "1.0.16"
@@ -3868,21 +3476,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
-[[package]]
-name = "unicode-normalization"
-version = "0.1.25"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
-dependencies = [
- "tinyvec",
-]
-
-[[package]]
-name = "unicode-properties"
-version = "0.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
-
[[package]]
name = "unicode-width"
version = "0.1.14"
@@ -3945,12 +3538,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
-[[package]]
-name = "vcpkg"
-version = "0.2.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
-
[[package]]
name = "version_check"
version = "0.9.5"
@@ -3991,12 +3578,6 @@ dependencies = [
"wit-bindgen-rt",
]
-[[package]]
-name = "wasite"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
-
[[package]]
name = "wasm-bindgen"
version = "0.2.100"
@@ -4115,16 +3696,6 @@ dependencies = [
"rustls-pki-types",
]
-[[package]]
-name = "whoami"
-version = "1.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
-dependencies = [
- "libredox",
- "wasite",
-]
-
[[package]]
name = "widestring"
version = "1.1.0"
diff --git a/config.example.huml b/config.example.huml
index a68a94a..5e92a7d 100644
--- a/config.example.huml
+++ b/config.example.huml
@@ -49,7 +49,7 @@ storage::
bounced_retention: "7d"
deferred_retention: "2d"
interval: "1h"
- storage_type: "fs"
+ storage_type: "log"
filters::
- ::
@@ -66,3 +66,13 @@ filters::
log::
format: "fmt"
level: "info"
+
+# Optional durable append-log mail queue (not yet wired into the serving path)
+# queue::
+# append_writers: 1 # Number of shards / concurrent append writers (default: 1)
+# pending_append_bytes: 134217728 # Pending (not-yet-durable) append bytes before backpressure (default: 128 MiB)
+# segment_target_bytes: 67108864 # Target size of each active segment file, per shard (default: 64 MiB)
+# compaction_dead_ratio: 0.50 # Dead-byte fraction in a sealed segment that makes it compaction-eligible (default: 0.50)
+# compaction_min_age: "60s" # Minimum age of a sealed segment before compaction eligibility (default: 60s)
+# disk_reserve_bytes: 1073741824 # Minimum free disk space required to accept new mail (default: 1 GiB)
+# checkpoint_interval_bytes: 8388608 # Bytes of appended data between durability checkpoints (default: 8 MiB)
diff --git a/config.example.toml b/config.example.toml
index 722f8cb..350ebbc 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -78,29 +78,25 @@ key_type = "rsa" # "rsa" or "ed25519"
# Storage configuration
[storage]
-storage_type = "fs" # "fs" for filesystem, "sqlite" for SQLite-backed storage
+storage_type = "log" # "log" (durable log queue, default) or "fs" (legacy one file per message)
base_path = "/var/lib/hedwig/mail"
-# SQLite storage example (num_shards/batch_* live under [storage], tuning under [storage.sqlite]):
-# [storage]
-# storage_type = "sqlite"
-# base_path = "/var/lib/hedwig/mail"
-# num_shards = 16 # Number of database shards (default: 16)
-# batch_size = 100 # Write-batch size (default: 100)
-# batch_timeout_ms = 5 # Max time (ms) to wait before flushing a batch (default: 5)
-#
-# [storage.sqlite]
-# synchronous = "NORMAL" # SQLite synchronous setting: OFF | NORMAL | FULL (default: NORMAL)
-# cache_size_mb = 1600 # Page-cache size per shard in MiB (default: 1600)
-# busy_timeout_ms = 5000 # SQLite busy-timeout in ms (default: 5000)
-# pool_max_connections = 10 # sqlx pool max connections per shard (default: 10)
-
# Optional retention policy for local spool cleanup
[storage.cleanup]
bounced_retention = "7d"
deferred_retention = "2d"
interval = "1h"
+# Optional durable append-log mail queue (not yet wired into the serving path)
+# [queue]
+# append_writers = 1 # Number of shards / concurrent append writers (default: 1)
+# pending_append_bytes = 134217728 # Pending (not-yet-durable) append bytes before backpressure (default: 128 MiB)
+# segment_target_bytes = 67108864 # Target size of each active segment file, per shard (default: 64 MiB)
+# compaction_dead_ratio = 0.50 # Dead-byte fraction in a sealed segment that makes it compaction-eligible (default: 0.50)
+# compaction_min_age = "60s" # Minimum age of a sealed segment before compaction eligibility (default: 60s)
+# disk_reserve_bytes = 1073741824 # Minimum free disk space required to accept new mail (default: 1 GiB)
+# checkpoint_interval_bytes = 8388608 # Bytes of appended data between durability checkpoints (default: 8 MiB)
+
# Optional email filters
[[filters]]
type = "from_domain_filter"
diff --git a/dev/config.sqlite.toml b/dev/config.sqlite.toml
deleted file mode 100644
index 521cf0b..0000000
--- a/dev/config.sqlite.toml
+++ /dev/null
@@ -1,32 +0,0 @@
-# Development configuration for Hedwig SMTP Server (SQLite storage)
-# Used by `just run-sqlite` - runs locally
-
-[server]
-workers = 4
-disable_outbound = true
-
-# Plaintext listener on port 2526
-[[server.listeners]]
-addr = "0.0.0.0:2526"
-
-[log]
-level = "debug"
-format = "fmt"
-
-# Test credentials
-[[server.auth]]
-username = "test"
-password = "test"
-
-[storage]
-storage_type = "sqlite"
-base_path = "/tmp/hedwig-dev-sqlite/"
-num_shards = 16
-batch_size = 100
-batch_timeout_ms = 5
-
-[storage.sqlite]
-synchronous = "NORMAL"
-cache_size_mb = 1600
-busy_timeout_ms = 5000
-pool_max_connections = 10
diff --git a/docs/PRODUCTION_HARDENING.md b/docs/PRODUCTION_HARDENING.md
index 0254e5e..ab3cf11 100644
--- a/docs/PRODUCTION_HARDENING.md
+++ b/docs/PRODUCTION_HARDENING.md
@@ -50,9 +50,9 @@ Production deployments should set `server.helo_hostname` to the public FQDN for
## 🟡 Important
-### 6. ~~Filesystem storage lacks durability guarantees~~ → Addressed (SQLite backend)
+### 6. ~~Filesystem storage lacks durability guarantees~~ → Addressed (log-queue backend)
-**Status:** Addressed via `SqliteStorage` backend (`storage_type = "sqlite"`). SQLite transactions provide atomic writes — no partial writes, no fsync gaps. See `docs/specs/2026-03-29-sqlite-storage-design.md`.
+**Status:** Addressed via the durable log queue (`storage_type = "log"`): checksummed append-only records with torn-tail recovery, and fsync barriers at every destructive boundary. (Previously addressed by the SQLite backend, since removed.) See `ARCHITECTURE.md`.
**Problem:** `fs_storage.rs` uses `tokio::fs::write()` directly — no temp-file + rename, no fsync. On crash or power loss:
- Partially written files can corrupt the queue
@@ -70,9 +70,9 @@ Production deployments should set `server.helo_hostname` to the public FQDN for
---
-### 7. ~~Filesystem storage doesn't scale to millions of files~~ → Addressed (SQLite backend)
+### 7. ~~Filesystem storage doesn't scale to millions of files~~ → Addressed (log-queue backend)
-**Status:** Addressed via `SqliteStorage` backend. Sharded SQLite databases with indexed queries replace flat directory walks. See `docs/specs/2026-03-29-sqlite-storage-design.md`.
+**Status:** Addressed via the log queue: segmented append-only storage keeps file count proportional to live backlog, with no per-message files and no directory scans. (Previously addressed by the SQLite backend, since removed.)
**Problem:** Flat directories (`queued/`, `deferred/`, `bounced/`) with millions of files means very slow `readdir()` calls. Startup replay and cleanup become directory-walk bound. ext4 performance degrades significantly past ~100K files per directory.
diff --git a/docs/plans/2026-07-20-durable-log-queue.md b/docs/plans/2026-07-20-durable-log-queue.md
new file mode 100644
index 0000000..c7f42ec
--- /dev/null
+++ b/docs/plans/2026-07-20-durable-log-queue.md
@@ -0,0 +1,1217 @@
+# Hedwig Durable Log Queue Plan
+
+> Status: design captured for future implementation; revised 2026-07-20 after pair review
+>
+> Date: 2026-07-20
+>
+> Scope: replace the filesystem spool plus worker-coupled in-memory job queue with a single-process dispatcher backed by segmented append-only message logs. One append writer by default; the on-disk format is shard-capable so more writers can be enabled if benchmarks justify them.
+
+## 1. Executive summary
+
+Hedwig currently persists an incoming message and then awaits capacity in the bounded in-memory worker channel before returning SMTP `250 OK`. When outbound workers are slow, sleeping for rate limits, or otherwise occupied, the channel eventually fills and inbound acceptance becomes limited by outbound drain speed.
+
+The intended architecture is:
+
+- Hedwig remains a **single process**.
+- There is **one dispatcher** responsible for scheduling all delivery work.
+- There is **one append writer** by default. The record format, job locations, and dispatcher are shard-aware, so `N` writers can be enabled later without a format change; multiple writers ship only if benchmarks justify them.
+- Each append writer exclusively owns its shard and writes to that shard's segmented log files. Logs are segmented: the active file is sealed at a target size and a new one is started, so no single file grows without bound regardless of total throughput.
+- The complete message is stored in the log record, eliminating one spool file per message and avoiding large flat-directory scans.
+- SMTP acceptance waits only for the append write to complete into the kernel page cache. It does **not** wait for `fsync`, the dispatcher, a worker, or outbound delivery capacity.
+- The dispatcher discovers appended records using per-shard committed-tail positions and maintains ready, deferred, and in-flight state.
+- Workers pull jobs from the dispatcher and report outcomes.
+- Delivered records become garbage. Segment reclamation is event-driven: a segment whose live count reaches zero is deleted immediately, and a sealed segment is queued for compaction as soon as its garbage ratio crosses the threshold. Periodic sweeps exist only as a safety-net backstop.
+- A small amount of temporary disk amplification is accepted; delivered payloads do not remain indefinitely.
+- Multiple processes, shared queues, leader election, distributed claims, and exactly-once delivery are explicitly out of scope.
+
+The main result is that inbound SMTP acceptance is bounded by disk append throughput rather than outbound worker drain throughput.
+
+## 2. Context and current problem
+
+### 2.1 Current acceptance path
+
+The current path is effectively:
+
+```text
+SMTP DATA
+ -> persist message as Queued
+ -> await sender_channel.send(job)
+ -> return 250 OK
+```
+
+Relevant current code:
+
+- `smtp-server/src/callbacks.rs:248-280` persists the queued message and then awaits sending its `Job` to the worker channel.
+- `smtp/src/lib.rs:465-469` waits for the DATA callback before returning `250 OK`.
+- `smtp-server/src/main.rs:108-112` creates a bounded job channel using `queue_buffer`.
+
+The body has already been written to the filesystem at this point — with page-cache durability only: no `fsync` and no atomic rename — but the SMTP client is not acknowledged until an in-memory worker slot becomes available. Once the channel fills, inbound acceptance proceeds only as quickly as workers remove jobs from the channel.
+
+### 2.2 Why workers may not drain promptly
+
+A worker occupies its slot while performing the complete delivery lifecycle, including:
+
+- loading and parsing the message;
+- signing it;
+- waiting for rate limits;
+- DNS and MX work;
+- connecting to remote MTAs;
+- attempting SMTP delivery.
+
+In particular, a rate-limited job currently sleeps in the worker in `smtp-server/src/worker/mod.rs:610-617`. Enough sleeping jobs can consume every worker slot even when unrelated destinations could proceed.
+
+### 2.3 Current scanning costs
+
+The current filesystem backend uses status directories containing one file per message. Existing operations include:
+
+- flat directory enumeration for queued messages;
+- startup replay of the entire queued set before accepting new connections;
+- periodic deferred metadata scans;
+- per-message path, inode, open, and file creation work.
+
+The SQLite backend avoids directory scans but still performs whole-status queries. The new architecture must avoid relying on full per-message scans during normal startup and scheduling.
+
+## 3. Goals
+
+### 3.1 Primary goals
+
+1. Decouple inbound acceptance from outbound worker drain speed.
+2. Keep disk as the source of truth; message bodies must not remain in memory while waiting for outbound delivery.
+3. Replace one-file-per-message storage with sequential append-oriented storage.
+4. Avoid large queued/deferred directory scans.
+5. Preserve at-least-once delivery and retry-attempt recovery across process restarts.
+6. Keep the design optimized for a single Hedwig process.
+7. Keep the on-disk format and dispatcher shard-capable so append throughput can later scale across multiple independently owned log shards.
+8. Reclaim delivered-message disk space through segment deletion and compaction.
+9. Move delayed retry waiting out of worker slots and into dispatcher scheduling.
+10. Maintain or improve observability of queue depth, age, retries, and delivery outcomes.
+
+### 3.2 Secondary goals
+
+- Bound in-memory admission buffering by bytes rather than message count.
+- Permit tuning the append-writer count without changing dispatcher semantics.
+- Make startup recovery sequential and deterministic.
+- Keep the on-disk format versioned and independently testable.
+- Preserve the existing filesystem backend's accepted page-cache durability tradeoff.
+
+## 4. Non-goals
+
+The initial implementation will not provide:
+
+- multiple processes sharing one queue;
+- active-active dispatchers;
+- leader election or fencing;
+- distributed leases;
+- online resharding of an existing non-empty queue;
+- exactly-once delivery to remote SMTP servers;
+- strict global FIFO delivery order;
+- an `fsync` per message or group-commit durability;
+- a general-purpose external message broker;
+- removal of all disk-capacity safeguards.
+
+Operators who need multiple Hedwig instances can run independent processes with independent queues and load-balance or round-robin inbound traffic themselves.
+
+## 5. Decisions captured
+
+### 5.1 Process model
+
+- Hedwig is always single-process for this queue design.
+- There is one dispatcher.
+- There is one append writer (one shard) by default. The design supports `N` writers, each owning exactly one shard; enabling more than one is a benchmark-driven decision, not the v1 default.
+- Workers remain ordinary tasks inside the same process.
+
+### 5.2 Storage model
+
+- The full message body and delivery envelope are stored in segmented append-only payload logs.
+- Each shard owns one active segment and zero or more sealed segments.
+- No two append writers write to the same active file.
+- Each record is identified by a stable message ID and a physical location.
+- Delivered records are marked terminal and later reclaimed through segment deletion or compaction.
+
+### 5.3 Durability model
+
+- SMTP `250 OK` may be returned after the complete record has been accepted by the kernel via successful write calls.
+- Hedwig will not require `fsync` before acknowledging the message.
+- A process crash should recover page-cache-backed data normally.
+- A machine crash or power loss may lose recently acknowledged messages. This is an explicit and accepted performance tradeoff.
+- State updates use the same page-cache durability policy.
+- Destructive operations are the exception: before deleting or truncating anything that holds live data, the replacement must be durable. Compaction fsyncs its output segment and the location manifest before unlinking the source; checkpoints are fsynced before covered journal history is truncated. Without these barriers a power loss could destroy arbitrarily old queued mail, not just recently accepted mail.
+
+### 5.4 Scheduling model
+
+- The append log is the authoritative admission queue.
+- Dispatcher wake-up notifications are hints, not authoritative job storage.
+- The dispatcher tracks one discovery cursor per shard.
+- Workers pull or are assigned one claim at a time from the dispatcher.
+- Worker ownership is in-memory only.
+- On restart, every non-terminal, non-deferred record is eligible for redispatch.
+- Deferred jobs are scheduled by due time rather than occupying sleeping worker slots.
+
+### 5.5 Delivery semantics
+
+- Delivery remains at-least-once.
+- The delivery unit is one message. A job carries the whole message even when its recipients span multiple domains; per-domain delivery units are future work.
+- A deferred-state record persists the remaining (not-yet-accepted) recipient set, so a retry re-sends only to recipients that have not accepted the message. This removes the current behavior where a partial failure re-delivers to recipients that already accepted it.
+- If a remote MTA accepts a message and Hedwig crashes before recording terminal success, the message may be delivered again after restart.
+- Exactly-once SMTP delivery is not achievable and will not be claimed.
+
+### 5.6 Sharding model
+
+- New messages are assigned to a shard using a stable hash of the message ID.
+- The initial implementation will require the queue to be empty before changing the configured shard count.
+- Existing records retain their explicit physical shard and segment location; runtime lookup must not depend only on the current shard count.
+- Recipient domain is not used as the payload-sharding key because large domains could create hot writer shards.
+
+## 6. High-level architecture
+
+```text
+ +------------------+
+SMTP connections ------>| shard selection |
+ +--+----+----+------+
+ | | |
+ +--------+ | +--------+
+ v v v
+ writer 0 writer 1 writer N
+ shard 0 shard 1 shard N
+ | | |
+ +------ committed heads ----+
+ |
+ v
+ one dispatcher
+ +---------------+---------------+
+ | ready scheduling |
+ | deferred due-time heap |
+ | in-flight jobs |
+ | message locations |
+ | per-segment live/dead counts |
+ +---------------+---------------+
+ |
+ worker pulls
+ |
+ v
+ worker pool
+ |
+ delivery outcomes
+ |
+ v
+ dispatcher -> owning shard state
+ |
+ segment GC/compaction
+```
+
+## 7. On-disk layout
+
+A possible initial layout is:
+
+```text
+spool/
+ format-version
+ shard-0000/
+ segment-000000000001.log
+ segment-000000000001.state
+ segment-000000000002.open
+ state-journal.log
+ checkpoint
+
+ shard-0001/
+ segment-000000000001.log
+ segment-000000000001.state
+ segment-000000000002.open
+ state-journal.log
+ checkpoint
+```
+
+Properties:
+
+- The number of shard directories is small and configured.
+- The number of segment files is proportional to live data and temporary garbage, not total historical message count.
+- `.open` identifies the shard's current append target.
+- Sealing a segment renames it to `.log`.
+- Payload segments are immutable after sealing.
+- State transitions are maintained separately from immutable payload data.
+
+The exact state sidecar and journal division should be finalized during the format implementation. The intended behavior is:
+
+- append-only state transitions for crash-tolerant replay;
+- compact state checkpoints for fast startup;
+- per-segment live/dead accounting for GC;
+- no requirement to rewrite payload segments for ordinary state changes.
+
+## 8. Payload record format
+
+The record format must be versioned and self-framing. A conceptual record is:
+
+```text
++-------------------------+
+| magic |
+| format version |
+| header length |
+| total record length |
+| header checksum |
+| payload checksum |
+| message ID |
+| enqueue timestamp |
+| relocation generation |
+| envelope metadata |
+| message body length |
+| message body |
++-------------------------+
+```
+
+The fixed portion must contain enough information for the dispatcher to discover jobs without reading or decoding the body:
+
+- total record length;
+- message ID;
+- enqueue time;
+- sender/domain routing information needed by scheduling;
+- recipient information needed to construct a job;
+- body offset and length;
+- format and relocation generation.
+
+The dispatcher should be able to read the fixed header and skip directly to the next record.
+
+Checksums serve two different purposes:
+
+- detect a partial or corrupt active tail;
+- detect unexpected corruption in sealed segments.
+
+On startup:
+
+- an incomplete final record in an active segment may be truncated;
+- corruption in the middle of a sealed segment must be reported and handled explicitly rather than silently skipped.
+
+## 9. Append writers
+
+### 9.1 Ownership
+
+Each append writer owns:
+
+- one shard directory;
+- the active payload file;
+- active segment offsets and ordinals;
+- segment rotation;
+- shard state persistence commands;
+- shard-local live/dead accounting;
+- deletion eligibility;
+- coordination with compaction.
+
+No mutex is required around a shared append offset because there is no shared append file.
+
+### 9.2 Append request
+
+A conceptual request is:
+
+```rust
+struct AppendRequest {
+ message_id: MessageId,
+ envelope: Envelope,
+ body: Bytes,
+ completion: oneshot::Sender>,
+}
+```
+
+The writer returns:
+
+```rust
+struct JobLocation {
+ shard: u16,
+ segment: u64,
+ offset: u64,
+ length: u32,
+ ordinal: u32,
+ generation: u32,
+}
+```
+
+The SMTP callback awaits only this append completion before returning `250 OK`.
+
+### 9.3 Admission queue
+
+The append request queue must be bounded by pending bytes, not only pending record count.
+
+This prevents a large number of messages waiting for disk admission from consuming unbounded RAM. Backpressure at this boundary is valid because it represents actual storage throughput rather than outbound delivery throughput.
+
+The bound applies only until records are copied into the page cache. Message bodies are never retained in this queue while waiting for workers or remote MTAs.
+
+The append bound alone does not cap inbound memory: each SMTP session buffers its full DATA payload before the callback runs. Total inbound memory is governed by the composition of `max_connections`, `max_message_size`, and the pending-append byte bound, and the chosen defaults must be documented together as one memory budget.
+
+### 9.4 Write batching
+
+The writer may batch adjacent pending requests into fewer write operations. Since no `fsync` is required, the batching policy can prioritize throughput without introducing a durability timer.
+
+Initial implementation may use straightforward buffered `write_all` operations. More advanced `writev` batching should be justified by benchmarks.
+
+### 9.5 Publish ordering
+
+For each record, the writer must:
+
+1. encode the complete record;
+2. write the complete record successfully;
+3. update the shard's published committed tail with release ordering;
+4. notify the dispatcher;
+5. complete the SMTP append request.
+
+The committed tail must never expose a partial record.
+
+## 10. Shard selection
+
+Initial selection:
+
+```text
+shard = stable_hash(message_id) % append_writer_count
+```
+
+ULID randomness should distribute messages sufficiently over time.
+
+If short-term size imbalance becomes measurable, a later optimization may use power-of-two choices:
+
+1. derive two candidate shards from the message ID;
+2. inspect each writer's pending-byte count;
+3. choose the less-loaded writer.
+
+This is an optimization, not part of the initial correctness model.
+
+## 11. Dispatcher discovery
+
+### 11.1 Per-shard committed heads
+
+Each writer publishes:
+
+```rust
+struct ShardHead {
+ segment: u64,
+ committed_offset: u64,
+}
+```
+
+The dispatcher owns one discovery cursor per shard:
+
+```rust
+struct ShardCursor {
+ segment: u64,
+ offset: u64,
+}
+```
+
+When notified, the dispatcher reads headers between its cursor and the committed head and adds newly discovered messages to its scheduler.
+
+### 11.2 Notifications are hints
+
+The dispatcher notification mechanism must not become another authoritative bounded work queue.
+
+A `Notify`-style wake-up is sufficient because:
+
+- notifications may be coalesced or lost;
+- the committed head remains authoritative;
+- the dispatcher always compares its cursor against the current head;
+- a low-frequency safety tick can discover work even if no notification is observed.
+
+A descriptor fast-path channel (writer `try_send(JobDescriptor)` with cursor catch-up on overflow) was considered and cut from v1: the cursor plus notification path is the only discovery mechanism. Reintroduce the fast path only if header rereads measurably matter.
+
+### 11.3 Segment rotation
+
+When a writer rotates its active segment:
+
+1. finish the current record;
+2. seal the current segment;
+3. publish its final committed length;
+4. create the next active segment;
+5. publish the new active segment identity;
+6. notify the dispatcher.
+
+The dispatcher must be able to advance from the end of one segment to the start of the next without requiring per-message directory enumeration.
+
+## 12. Dispatcher state
+
+A conceptual in-memory dispatcher is:
+
+```rust
+struct Dispatcher {
+ shard_cursors: Vec,
+ ready: ReadyScheduler,
+ deferred: BinaryHeap>,
+ inflight: HashMap,
+ locations: HashMap,
+ segment_stats: HashMap,
+}
+```
+
+Dispatcher memory must be bounded from the start, not as a deferred optimization, because a disk-sized backlog otherwise becomes an OOM that repeats on every restart. The baseline mechanism is discovery backpressure: the dispatcher stops advancing shard cursors while its in-memory tables hold a configured maximum of undispatched entries. The log itself preserves everything beyond that window, so nothing is lost; discovery resumes as entries drain. Memory per million queued and per million deferred records is a required benchmark, and a paged deferred index is added only if those measurements demand it.
+
+### 12.1 Job states
+
+Conceptual states are:
+
+```text
+Ready
+InFlight
+Deferred(next_attempt, attempts)
+TerminalDelivered
+TerminalBounced
+```
+
+`InFlight` is process-local and does not need to be persisted.
+
+### 12.2 Worker claims
+
+When a worker asks for work:
+
+```text
+Ready -> InFlight
+```
+
+Claims carry a mandatory in-memory generation so a late result from a cancelled or stalled worker cannot complete a later reassignment of the same message. Worker panic, cancellation, or closure of the outcome channel must surface to the dispatcher as an abandonment event that returns the job to `Ready`; a claim must never leak. Reassignment of a timed-out claim should still be conservative — the generation makes a duplicate result safe to ignore, but it cannot prevent a duplicate outbound delivery already in progress.
+
+Because all workers and the dispatcher are in one process, no durable lease expiry or cross-process fencing is required.
+
+### 12.3 Restart behavior
+
+After restart:
+
+- terminal records remain terminal based on persisted state;
+- deferred records return to the due-time scheduler with their attempt counts;
+- all other live records become ready;
+- records that were in flight before the crash are redispatched.
+
+## 13. Worker interaction
+
+Workers should receive a lightweight job containing identity, location, and delivery metadata, not the message body.
+
+```rust
+struct DeliveryJob {
+ message_id: MessageId,
+ location: JobLocation,
+ attempts: u32,
+ claim_generation: u64,
+}
+```
+
+The worker reads the body directly from the segment using positioned reads. Workers may read sealed and active segments concurrently with the owning append writer because they only access committed record ranges. If a positioned read fails because compaction relocated the record and removed the source segment, the worker re-fetches the current location from the dispatcher and retries before treating the failure as an error.
+
+The worker reports one of:
+
+```rust
+enum JobOutcome {
+ Delivered {
+ response: DeliveryResponse,
+ },
+ Deferred {
+ attempts: u32,
+ next_attempt: SystemTime,
+ remaining_recipients: Vec,
+ error: String,
+ },
+ RateLimited {
+ domain: String,
+ retry_after: Duration,
+ },
+ Bounced {
+ reason: String,
+ },
+}
+```
+
+`Deferred` is a real delivery failure: it increments the attempt count and is persisted together with the remaining recipient set. `RateLimited` is local throttling: it does not increment attempts and is requeued in memory only (section 14).
+
+The dispatcher validates the claim generation, applies the persistent state transition through the owning shard, and only then updates scheduling state (section 16).
+
+## 14. Retry scheduling
+
+Rate-limited and retryable jobs must not sleep inside worker slots, and the two cases are distinct.
+
+Delivery retry (a real failed attempt):
+
+```text
+worker attempt fails temporarily
+ -> reports Deferred(next_attempt, remaining_recipients)
+ -> dispatcher persists deferred state (attempts incremented)
+ -> dispatcher inserts job into due-time heap
+ -> worker immediately asks for another job
+```
+
+Rate limiting (local throttling, not an attempt):
+
+```text
+dispatcher checks the shared per-domain rate limiter before dispatching a claim
+ -> jobs for exhausted domains stay queued; other domains dispatch
+worker re-acquires the limit immediately before transmission
+ -> if it loses that race it reports RateLimited(retry_after)
+ -> dispatcher requeues in memory only: no journal write, no attempt increment
+```
+
+A due timestamp is not a token reservation: jobs waking at the same due time must re-pass the limiter at dispatch, which prevents a thundering herd against one domain. Rate-limit deferrals are never persisted — after a restart the job simply becomes ready and is gated by the limiter again.
+
+When a deferred job becomes due:
+
+```text
+Deferred -> Ready
+```
+
+This eliminates the current periodic full deferred scan and prevents delayed jobs from occupying the delivery worker pool.
+
+The initial scheduler can use a min-heap keyed by `next_attempt`. A timing wheel or paged on-disk due-time index is unnecessary until measurements show the heap is too large.
+
+## 15. Fair scheduling
+
+Durable append order does not have to equal delivery order, and strict global FIFO is not a goal.
+
+Because the v1 delivery unit is a whole message — which may span domains — true per-domain fair scheduling is not implementable yet and is deferred until per-domain delivery units exist. What v1 provides instead:
+
+- rate-limit gating at dispatch time (section 14), so exhausted domains do not consume worker slots;
+- no sleeping in workers, so a slow domain ties up at most the claims actively being attempted against it;
+- enqueue-age ordering, so old messages do not starve.
+
+When per-domain delivery units are introduced later, deficit round-robin over per-domain ready queues is the intended fairness mechanism. Payload sharding stays keyed by message ID either way.
+
+## 16. Persistent state
+
+Payload records remain immutable after append. Delivery state is stored separately.
+
+The intended model is:
+
+- per-shard append-only state journal for transitions;
+- periodic compact checkpoint containing the latest state of live records;
+- optional small per-segment state summary for GC accounting.
+
+Conceptual state entries include:
+
+```text
+DEFER(message_id, attempts, next_attempt, remaining_recipients, last_error)
+DELIVERED(message_id, timestamp, remote_response_summary)
+BOUNCED(message_id, timestamp, reason)
+```
+
+Persistence is the state boundary. The dispatcher applies an outcome to its in-memory state only after the journal write succeeds:
+
+```text
+DEFER persisted -> remove InFlight -> insert into deferred heap
+DELIVERED persisted -> remove InFlight -> decrement segment live count -> GC eligible
+state write failure -> job stays InFlight and the write is retried; a job is
+ never marked terminal or dropped from scheduling on a
+ failed write
+```
+
+An enqueue transition does not need a separate state record because the payload record itself implies `Ready` unless superseded by later state.
+
+State-journal writes follow the same page-cache durability policy as payload writes. A lost terminal transition may cause duplicate delivery after an exceptional system failure, which is permitted by at-least-once semantics.
+
+A checkpoint must be self-sufficient for every segment that still exists on disk. Because a payload record implies `Ready` unless superseded, truncating the journal entry that recorded a delivery — while that payload still sits in a partially dead segment — would resurrect delivered mail on the next restart. Every checkpoint therefore contains:
+
+- terminal tombstones (or a per-segment bitmap) for every terminal record in every still-present segment;
+- the deferred set with attempts, due times, and remaining recipients;
+- the per-shard discovery cursor;
+- the state-journal position (LSN) it covers;
+- the segment topology and current location generation;
+- a checksum and format version.
+
+Checkpointing must use copy-before-replace ordering:
+
+1. write a new checkpoint;
+2. `fsync` it (destructive-boundary rule, section 5.3);
+3. rename it into place;
+4. truncate only journal history at or below the checkpoint's recorded LSN.
+
+The exact binary format, checkpoint cadence, and journal rotation thresholds will be specified and tested as part of the storage-format phase.
+
+## 17. Segment deletion and compaction
+
+### 17.1 Fully terminal segments
+
+Each sealed segment tracks:
+
+- total records and bytes;
+- live records and bytes;
+- terminal records and bytes;
+- oldest and newest enqueue time.
+
+Reclamation is event-driven, not scan-based. The terminal transition that drops a sealed segment's live count to zero immediately schedules that segment for deletion; the transition that pushes its dead ratio over the compaction threshold immediately queues it as a compaction candidate. A low-frequency periodic sweep exists only as a backstop for missed events.
+
+When:
+
+```text
+live_records == 0
+```
+
+its payload, sidecar, and obsolete state data are deleted.
+
+At high delivery rates this is the dominant reclamation path: a segment written during a burst has essentially all of its records delivered within the retry horizon, dies completely, and is unlinked without any copying. Compaction is reserved for the minority of segments pinned by long-deferred stragglers.
+
+### 17.2 Partially live segments
+
+A segment with a small number of long-lived messages may otherwise retain a large amount of delivered garbage.
+
+Initial compaction policy:
+
+```text
+compact when:
+ segment is sealed
+ AND dead_bytes / total_bytes >= 0.50
+ AND segment is older than a short grace period
+```
+
+A 50% threshold limits uncompacted sealed-segment amplification to roughly 2x live bytes. A higher threshold such as 75% reduces copying but allows roughly 4x amplification. The threshold must be configurable and tuned through benchmarks.
+
+### 17.3 Compaction flow
+
+1. Select a sealed source segment.
+2. Snapshot its live record set.
+3. Copy each still-live record into an unpublished compaction output segment. Compaction output is never discovered as new admissions: it is excluded from the dispatcher's discovery cursors and enters the system only through the location switch below.
+4. Give relocated records a higher relocation generation.
+5. Recheck that copied records are still live; a record that went terminal during the copy is dropped from the relocation set. Terminal races must not resurrect messages.
+6. Seal and `fsync` the compaction output.
+7. Atomically publish the new location generation (manifest update, then `fsync`) and update dispatcher locations.
+8. Wait for in-flight readers of the source segment to drain; a worker holding a stale location that loses this race re-fetches the current location and retries (section 13).
+9. Unlink the source segment only after steps 6-8 are durable and complete.
+
+If both old and relocated records are observed during recovery, the record with the highest valid relocation generation wins.
+
+### 17.4 Compaction concurrency
+
+Append writers may be sharded, but their files commonly share one physical disk. Initial policy:
+
+```text
+maximum concurrent compactions = 1
+```
+
+A global semaphore prevents multiple shards from creating avoidable read/write interference. Append operations continue while a sealed segment is compacted.
+
+### 17.5 Long-deferred records
+
+A long-deferred message may be repeatedly moved as surrounding segments become garbage. Initial mitigations:
+
+- do not immediately recompact a newly produced compaction segment;
+- use a minimum segment age before eligibility;
+- track per-record relocation count for diagnostics.
+
+If repeated movement becomes significant, introduce deferred segments bucketed by due-time range. This is a future optimization, not required initially.
+
+## 18. Active segment sizing
+
+More shards create more partially filled active segments. Segment sizing should account for total active slack.
+
+A possible initial policy is:
+
+```text
+target total active segment capacity = 64-128 MiB
+per-shard segment size = max(8 MiB, target / shard_count)
+```
+
+Example:
+
+| Append writers | Segment size | Total active capacity |
+|---:|---:|---:|
+| 1 | 64 MiB | 64 MiB |
+| 2 | 32 MiB | 64 MiB |
+| 4 | 16 MiB | 64 MiB |
+| 8 | 8 MiB | 64 MiB |
+
+These are benchmark starting points, not final defaults. With the default single writer, a larger segment (128-256 MiB) may be preferable to keep file counts low at high volume.
+
+Two invariants and one observation:
+
+- A record never spans segments. Per-shard segment size must therefore be at least the configured maximum message size plus record overhead; configurations violating this are rejected at startup.
+- Sustained high throughput does not create huge files — it creates more sealed segments. At 3 million messages per hour averaging 10 KiB, one writer appends roughly 30 GiB/hour: about 470 sealed segments per hour at 64 MiB, or 120 at 256 MiB. Segment count, not file size, tracks throughput.
+- Steady-state disk use and file count are proportional to live backlog plus not-yet-reclaimed garbage, not to total historical volume, because fully dead segments are unlinked as soon as they die (section 17.1).
+
+## 19. Bounced-message retention
+
+A delivered message can be discarded immediately from the logical queue. A bounced message may need to remain available according to existing Hedwig behavior or future retention policy.
+
+Bounced retention must not pin active queue segments indefinitely. If the message body must be retained:
+
+1. copy or append it to separate bounce/archive storage;
+2. persist the bounce outcome;
+3. mark the original queue record terminal;
+4. allow normal queue-segment GC.
+
+Bounce/archive storage should have an explicit retention policy independent of the active delivery queue.
+
+## 20. Disk growth and safety
+
+The log design creates two categories of disk use:
+
+- live queued/deferred messages;
+- temporary garbage awaiting segment deletion or compaction.
+
+GC and compaction bound the second category. They cannot bound the first category when incoming mail continuously exceeds outgoing delivery capacity. The existing spool has the same fundamental behavior.
+
+The initial implementation should therefore retain a simple disk reserve check:
+
+```text
+if available disk space < configured reserve:
+ temporarily reject new SMTP DATA
+```
+
+This is an operational safety boundary, not a mechanism required specifically by append logs. An elaborate quota system is not required for the first implementation.
+
+Useful configuration may include:
+
+```toml
+[queue]
+append_writers = 1
+pending_append_bytes = 134217728
+segment_target_bytes = 67108864
+compaction_dead_ratio = 0.50
+compaction_min_age = "60s"
+max_concurrent_compactions = 1
+disk_reserve_bytes = 1073741824
+durability = "page-cache"
+```
+
+Exact names and defaults must follow the existing configuration style.
+
+## 21. Shard-count changes
+
+For the initial version:
+
+> Changing `append_writers` requires an empty queue.
+
+This avoids implementing online resharding before it is needed.
+
+The on-disk location stored for every job must still include its explicit shard, segment, and offset. Code must not attempt to locate an existing record by recalculating `hash(id) % current_writer_count`.
+
+A future extension could use generations:
+
+```text
+spool/generation-0001/ # old shard count
+spool/generation-0002/ # current shard count
+```
+
+The dispatcher could drain old generations while new appends use only the current one. This is not part of the initial implementation.
+
+## 22. Startup and recovery
+
+Startup should not await replaying every job through a bounded worker channel before binding the SMTP listener.
+
+Proposed startup sequence:
+
+1. Read and validate the spool format version.
+2. Discover the small set of configured shard directories and segment files.
+3. Validate each active tail and truncate any incomplete final record.
+4. Load each shard checkpoint.
+5. Replay state-journal entries newer than the checkpoint.
+6. Reconcile payload records newer than the checkpoint/discovery position.
+7. Rebuild ready, deferred, location, and segment-stat indexes.
+8. Start append writers and the dispatcher.
+9. Start workers.
+10. Bind the SMTP listener without first feeding the entire backlog through a bounded channel.
+11. Let workers begin pulling from the reconstructed dispatcher state.
+
+For a very large backlog, recovery may later be changed to open the listener after minimum metadata initialization and continue indexing in the background. The first implementation should prioritize correctness and deterministic recovery, then measure startup time. Discovery backpressure (section 12) applies during recovery as well, so index rebuild memory stays bounded.
+
+### 22.1 Shutdown ordering
+
+Shutdown must drain the ownership graph deterministically:
+
+1. stop accepting new SMTP connections;
+2. drain or cancel in-progress DATA sessions;
+3. close append admission and drain pending append requests;
+4. stop issuing new worker claims;
+5. wait for in-flight deliveries to finish, or abandon them explicitly (they redispatch on restart);
+6. persist all accepted outcomes through the state journal;
+7. stop compaction at a recoverable step boundary;
+8. flush and close state writers;
+9. exit.
+
+A claim owned by a worker that has not returned is abandoned, never silently reassigned during shutdown.
+
+## 23. Migration from the current filesystem spool
+
+The new format requires an explicit migration strategy.
+
+A one-time migration may:
+
+1. stop ordinary queue mutation;
+2. enumerate existing `Queued` and `Deferred` files;
+3. preserve message IDs, attempt counts, and next-attempt timestamps;
+4. append each live message to its selected new shard;
+5. write equivalent deferred state where needed;
+6. verify that every legacy live record has a new record;
+7. rename the legacy spool to a migration backup;
+8. activate the new format;
+9. delete the backup only after operator confirmation or a defined grace period.
+
+Migration must be restart-safe and exclusive:
+
+- a root manifest records the format version, shard topology, migration epoch, and activation state, and the switch to the new format is a single atomic manifest update;
+- migration is idempotent: re-running after a crash detects already-migrated message IDs instead of appending duplicates;
+- retry metadata is honored for queued bodies as well as deferred ones — a message that was mid-retry sits in `queued/` with its metadata still attached;
+- an exclusive OS-level lock on the spool root is held before migration, recovery, tail truncation, or writer startup, and independent Hedwig processes must use distinct spool roots.
+
+This migration is allowed to scan the old directories once. The new steady-state architecture must not depend on those scans.
+
+Alternative rollout approaches to evaluate during implementation:
+
+- introduce the log queue as a new storage backend and require operators to drain before switching;
+- provide an offline migration command;
+- auto-migrate on startup only when explicitly enabled.
+
+Automatic destructive migration without an explicit backup or operator opt-in is not acceptable.
+
+## 24. SQLite backend impact
+
+Decision: the log queue ships as a new selectable storage backend alongside the existing filesystem and SQLite backends. The existing backends keep their current scheduling path (bounded channel, deferred worker) unchanged while the log backend proves out. Deprecating the legacy backends is a separate later decision. The migration tooling in section 23 covers the filesystem spool first; SQLite-to-log migration is deferred.
+
+The append-log format must not be forced into the existing `Storage` trait if that makes the log hot path slower or more complicated; the log backend may use its own internal interfaces.
+
+## 25. Metrics and observability
+
+Add or adapt metrics for:
+
+### Admission
+
+- append latency by shard;
+- pending append requests;
+- pending append bytes;
+- bytes appended;
+- records appended;
+- append errors;
+- active segment size;
+- segment rotations.
+
+### Dispatcher
+
+- per-shard committed head;
+- per-shard discovery cursor;
+- dispatcher lag in records and bytes;
+- ready jobs;
+- deferred jobs;
+- in-flight jobs;
+- oldest ready age;
+- oldest deferred age;
+- jobs scheduled by destination domain.
+
+### Storage and GC
+
+- live bytes;
+- dead bytes;
+- active-segment bytes;
+- sealed segment count;
+- segments deleted;
+- compactions started/completed/failed;
+- bytes read and written by compaction;
+- relocation count;
+- disk free bytes and configured reserve.
+
+### Delivery
+
+Existing delivery, retry, bounce, and latency metrics should remain meaningful. Queue time should continue to use the original enqueue timestamp even after compaction relocation.
+
+### Operator tooling
+
+The one-file-per-message spool is inspectable with `ls` and repairable with `rm`; binary segments are not. A minimal queue CLI is therefore in scope, not an afterthought:
+
+- list queued/deferred messages with age, attempts, and next-attempt time;
+- show one message's envelope, state history, and current location;
+- remove a message from the queue (recorded as an operator-cancelled terminal state);
+- show per-segment and per-shard statistics (live/dead counts, garbage ratio).
+
+Read-only inspection must work against a live spool without stopping the server.
+
+## 26. Testing strategy
+
+### 26.1 Record format tests
+
+- encode/decode round trip;
+- variable metadata and body sizes;
+- maximum configured message size;
+- checksum failures;
+- unknown format version;
+- truncated fixed header;
+- truncated body;
+- partial final record recovery;
+- corruption in a sealed segment.
+
+### 26.2 Append writer tests
+
+- concurrent SMTP-side append requests;
+- exclusive shard ownership;
+- monotonic offsets and ordinals;
+- committed tail never exposes partial records;
+- rotation at target size;
+- byte-bounded admission;
+- append error propagation;
+- notification loss does not lose work.
+
+### 26.3 Dispatcher tests
+
+- discovers all records from multiple shard heads;
+- independent shard cursors;
+- merges records without requiring global order;
+- worker claim and completion transitions;
+- stale claim generation is ignored;
+- worker cancellation returns work to ready state;
+- deferred jobs become ready at the correct time;
+- rate-limited jobs do not occupy sleeping workers;
+- rate-limited jobs are requeued without incrementing attempts and without journal writes;
+- rate-limit gating at dispatch prevents one exhausted destination from monopolizing workers;
+- discovery backpressure bounds dispatcher memory without losing records.
+
+### 26.4 Recovery tests
+
+- restart with ready jobs;
+- restart with deferred jobs and preserved attempt counts;
+- restart with in-flight jobs causes redispatch;
+- restart after terminal state update;
+- terminal state loss may duplicate but does not lose a live message;
+- partial state-journal tail;
+- checkpoint plus journal replay;
+- journal truncation after checkpoint cannot resurrect a terminal record whose segment still exists;
+- restart mid-retry preserves the remaining-recipient set;
+- active-segment partial tail truncation;
+- duplicate old/new relocation records choose the latest valid generation.
+
+### 26.5 GC and compaction tests
+
+- fully terminal segment is deleted;
+- live record prevents deletion;
+- dead-ratio threshold triggers compaction;
+- live records survive compaction;
+- terminal race during compaction does not resurrect a message;
+- process interruption at each compaction step remains recoverable;
+- simulated power loss between compaction copy and source unlink loses no live records (fsync barriers);
+- a worker holding a stale location re-fetches and reads the relocated record;
+- a segment whose last live record goes terminal is deleted without waiting for a periodic sweep;
+- only one compaction runs initially;
+- long-deferred record does not permanently pin mostly dead storage;
+- dispatcher locations update after relocation.
+
+### 26.6 End-to-end tests
+
+- inbound acceptance continues when all workers are busy;
+- inbound acceptance continues when workers are rate-limited;
+- append backpressure occurs only when disk admission buffering is full;
+- outbound delivery reads the correct body from a segment;
+- successful delivery eventually reclaims disk;
+- deferred delivery survives restart;
+- retry after a partial multi-recipient failure re-sends only to remaining recipients;
+- graceful shutdown drains outcomes and restarts cleanly;
+- bounced-message retention does not pin active segments;
+- the existing dev DNS and fake-MTA verification harness passes.
+
+## 27. Benchmark plan
+
+Benchmark before and after each major stage.
+
+### 27.1 Admission benchmarks
+
+Measure at several message sizes, including at least:
+
+- 1 KiB;
+- 16 KiB;
+- 64 KiB;
+- 1 MiB where practical.
+
+Test:
+
+- append writers: `1`, `2`, `4`, `8`;
+- suitable segment sizes for each writer count;
+- outbound disabled;
+- outbound intentionally stalled;
+- tmpfs and a real local filesystem;
+- steady-state append plus background compaction.
+
+Metrics:
+
+- messages per second;
+- bytes per second;
+- median and tail SMTP DATA latency;
+- CPU usage;
+- write syscall count;
+- context switches;
+- pending append bytes;
+- dispatcher lag;
+- disk amplification.
+
+### 27.2 Recovery benchmarks
+
+Measure startup with:
+
+- 10 thousand queued messages;
+- 100 thousand queued messages;
+- 1 million queued messages if practical;
+- mixtures of ready, deferred, and terminal records;
+- many small segments versus fewer large segments.
+
+### 27.3 GC benchmarks
+
+Measure:
+
+- cost of deleting fully dead segments;
+- compaction throughput;
+- impact of compaction on SMTP acceptance latency;
+- repeated long-deferred-message relocation;
+- storage amplification at 50% and 75% thresholds.
+
+### 27.4 Expected writer count
+
+Do not assume more writers are always faster. A single batched writer may already saturate the disk or memory-copy path. Multiple writers are expected to help most on NVMe and tmpfs and may hurt on rotational disks.
+
+The implementation supports configurable sharding, but the default is a single writer; changing that default requires benchmark evidence.
+
+## 28. Implementation phases
+
+This work is intentionally deferred and should be implemented incrementally.
+
+Phases 1-7 are internal milestones: the log backend must not be the active backend of a deployed build until they are all complete. Making it selectable comes last because deploying it without recovery (phase 3) or reclamation (phase 6) would redeliver mail after every restart and grow disk without bound. Until then the new code lands alongside the untouched legacy path.
+
+### Phase 0: preserve baselines and invariants
+
+- Record current acceptance, delivery, startup, retry, and disk-use behavior.
+- Preserve existing benchmark tooling.
+- Document current at-least-once and durability semantics.
+- Add tests demonstrating that worker-channel saturation currently blocks SMTP acceptance.
+
+### Phase 1: on-disk format and shard primitives
+
+- Define versioned record and state formats and the root manifest.
+- Implement record encoding, decoding, checksums, and active-tail validation.
+- Implement shard directory and segment lifecycle, plus the exclusive spool lock.
+- Implement positioned message reads.
+- Enforce the record-never-spans-segments sizing invariant.
+- Add exhaustive format and corruption tests.
+
+### Phase 2: append writer
+
+- Implement one append writer with byte-bounded admission.
+- Return physical `JobLocation` after page-cache write completion.
+- Implement committed-head publication and segment rotation.
+- Keep the writer interface shard-capable; `N` writers stay configurable but default to 1.
+- Benchmark `1`, `2`, `4`, and `8` writers before revisiting the default.
+
+### Phase 3: persistent state and recovery
+
+- Implement per-shard state journals with the persist-then-apply ordering.
+- Implement checkpoints (terminal tombstones, LSN, cursors, topology) and journal replay.
+- Implement startup recovery: tail truncation, checkpoint load, journal replay, payload reconciliation.
+- Preserve retry attempts, next-attempt times, and remaining-recipient sets.
+- Validate restart behavior for ready, deferred, in-flight, and terminal messages.
+
+### Phase 4: dispatcher discovery
+
+- Implement per-shard discovery cursors.
+- Discover work from committed heads without an authoritative job channel.
+- Make notifications lossy hints with cursor-based recovery.
+- Reconstruct jobs from record headers without reading message bodies.
+- Implement discovery backpressure as the dispatcher memory bound.
+
+### Phase 5: worker pull protocol and retries
+
+- Replace the current shared job-channel lifecycle with dispatcher claims.
+- Keep job payloads out of the scheduling path; read bodies by `JobLocation` with stale-location refetch.
+- Add mandatory claim generations and abandonment recovery.
+- Add the deferred due-time heap; move delayed retry waiting out of workers.
+- Add dispatcher-side rate-limit gating and the in-memory `RateLimited` requeue path.
+- Ensure global per-domain rate limits remain correct across all workers.
+- Preserve delivery logging and metrics.
+
+### Phase 6: deletion and compaction
+
+- Delete fully terminal sealed segments, triggered by the terminal transition (event-driven).
+- Implement dead-ratio selection and the compaction candidate queue.
+- Implement copy-before-delete compaction with unpublished output, relocation generations, manifest publication, and fsync barriers at destructive boundaries.
+- Add one global compaction permit.
+- Add GC metrics and failure recovery.
+- Implement the disk reserve check.
+
+### Phase 7: bounce retention, migration, and backend selection
+
+- Separate bounce retention from active queue segments.
+- Implement the restart-safe legacy-spool migration (idempotent, manifest-gated, locked).
+- Make the log backend selectable alongside the filesystem and SQLite backends.
+- Add the queue inspection CLI.
+- Add explicit format/version and rollback handling.
+
+### Phase 8: SMTP acknowledgement cutover
+
+- Change the DATA callback to await only append completion when the log backend is active.
+- Return `250 OK` without waiting for dispatcher or worker capacity.
+- Remove startup replay through the bounded worker channel.
+- Verify that stalled outbound delivery does not block acceptance until disk-admission buffering or disk reserve is reached.
+
+### Phase 9: production hardening and tuning
+
+- Run end-to-end fake-MTA verification.
+- Run large-backlog startup tests.
+- Run append and compaction benchmarks on representative filesystems.
+- Confirm default writer count, segment size, pending-byte bound, and compaction threshold from benchmarks.
+- Update production-hardening documentation and operator guidance.
+
+## 29. Risks and tradeoffs
+
+### 29.1 Implementation size
+
+This is a large architectural change crossing SMTP acceptance, storage, scheduling, retry handling, workers, startup recovery, metrics, and migration. It should not be attempted as a single unreviewable patch.
+
+### 29.2 Compaction correctness
+
+Compaction is the most correctness-sensitive component because it rewrites live queued data. Copy-before-delete ordering, relocation generations, race handling, and interruption tests are mandatory.
+
+### 29.3 Memory use
+
+A dispatcher entry for every live message may become large at very high queue depths. Discovery backpressure (section 12) bounds this from the start; memory-per-million-record benchmarks validate the bound, and a paged deferred index is added only if measurements demand it.
+
+### 29.4 Disk write amplification
+
+Compaction rewrites long-lived records. Thresholds and minimum ages must balance disk utilization against copy cost.
+
+### 29.5 More writers are not automatically faster
+
+Multiple active files may reduce append contention but increase active-segment slack and randomize physical writes. Benchmarking determines the useful writer count.
+
+### 29.6 Page-cache durability
+
+The no-`fsync` decision prioritizes throughput. Documentation must state clearly that a machine crash or power loss can lose recently acknowledged mail. Destructive boundaries (compaction publication, checkpoint truncation, segment deletion) do use `fsync` so power loss can never destroy old queued mail (section 5.3).
+
+### 29.7 Migration
+
+Existing queued and deferred mail must not be silently dropped. Migration needs explicit validation and rollback behavior.
+
+## 30. Definition of done
+
+The architecture is complete when:
+
+- SMTP acknowledgement no longer waits for worker-channel capacity;
+- outbound slowdown does not block inbound acceptance except at actual disk-admission or disk-reserve boundaries;
+- complete messages are stored in sharded segmented logs;
+- one dispatcher schedules across all shards;
+- workers pull payload-free job descriptors and read bodies by location;
+- retry waits no longer consume worker slots;
+- rate-limit throttling neither consumes attempts nor writes journal entries;
+- retries re-send only to recipients that have not yet accepted the message;
+- attempt counts, deferred times, and remaining-recipient sets survive restart;
+- startup does not feed the full backlog through a bounded in-memory channel before listening;
+- fully dead segments are deleted as soon as they die, without waiting for a periodic sweep;
+- partially dead segments are compacted safely;
+- delivered payload garbage remains bounded by the configured GC policy;
+- migration or an explicit drain-before-switch path exists;
+- a queue inspection CLI covers list, show, remove, and segment statistics;
+- graceful shutdown drains state deterministically;
+- queue, dispatcher, shard, and GC metrics are available;
+- unit, recovery, compaction, integration, and end-to-end tests pass;
+- benchmarks establish justified defaults for writer count and segment sizing;
+- operator documentation clearly explains durability and disk-capacity behavior.
+
+## 31. Final intended model
+
+```text
+Inbound throughput is limited by:
+ SMTP parsing + memory copies + append writers + page-cache/disk throughput
+
+Outbound throughput is limited by:
+ workers + DNS + remote MTAs + rate limits
+
+The durable log separates these two rates.
+
+One dispatcher provides:
+ scheduling + fairness + retries + in-flight ownership
+
+The segmented append log provides:
+ admission bounded by disk throughput; one writer by default,
+ shard-capable when benchmarks justify parallel writers
+
+Segment GC provides:
+ deletion of fully dead data + bounded reclamation of partial garbage
+```
+
+This is the architectural direction to resume when Hedwig is ready for the queue rewrite.
diff --git a/docs/src/pages/reference/architecture.md b/docs/src/pages/reference/architecture.md
index d049bc1..d704234 100644
--- a/docs/src/pages/reference/architecture.md
+++ b/docs/src/pages/reference/architecture.md
@@ -8,97 +8,95 @@ description: Hedwig SMTP server architecture and data flow.
## Overview
-Hedwig is a high-performance, async SMTP server written in Rust that provides email relay functionality with advanced features including DKIM signing, retry mechanisms, and a modular storage layer. The server is designed with a modular architecture that separates concerns and provides extensibility.
+Hedwig is a high-performance, async SMTP server written in Rust that provides
+email relay functionality with DKIM signing, retry mechanisms, MTA-STS
+enforcement, and a modular storage layer.
-## High-level architecture
+For the deep, diagrammed walkthrough of the durable log queue — on-disk
+formats, scheduling, crash recovery, garbage collection, and the durability
+model — see [`ARCHITECTURE.md`](https://github.com/iamd3vil/hedwig/blob/main/ARCHITECTURE.md)
+in the repository root. This page is the short orientation.
-```
-SMTP Listeners → SMTP Callbacks → Storage Queue → Workers → Outbound SMTP Pool
-```
-
-## Core components
-
-### Main server
-- Configuration loading
-- TLS setup
-- Listener initialization
-- Worker initialization
+## Two queue architectures
-### SMTP callbacks
-- Authentication
-- Domain filtering
-- Rate limiting
-- Path validation
+Hedwig has two queueing paths, selected by `storage.storage_type`:
-### Worker system
-- Parse email
-- DKIM sign
-- MX lookup
-- Send via SMTP
+### Log queue (`storage_type = "log"`)
-### Outbound pool
-- Per-domain pooling
-- TLS configuration
-- Connection limits
-
-## Data flow
-
-### Inbound flow
-1. Client connection
-2. SMTP negotiation
-3. Email stored in queue
-4. Processing job created
+```
+SMTP listeners → callbacks → append writers → segmented log on disk
+ │
+ dispatcher (discovery, retries,
+ rate gating, GC/compaction)
+ │
+ workers pull claims
+ │
+ outbound SMTP pool
+```
-### Outbound flow
-1. Worker receives job
-2. Parse email, remove BCC, sign DKIM
-3. Resolve MX records
-4. Deliver via SMTP pool
+Complete messages are appended to sharded, segmented logs; `250 OK` is
+returned as soon as the record reaches the kernel page cache. A single
+dispatcher discovers appended records via per-shard cursors, hands
+payload-free claims to pulling workers, schedules retries by due time, and
+reclaims disk (fully delivered segments are deleted immediately; partially
+dead ones are compacted). Inbound acceptance is bounded by disk append
+throughput — slow or stalled outbound cannot block it.
+
+- Retries: exponential backoff scheduled in-process (no directory scans);
+ after `max_retries` the message bounces terminally.
+- Partial multi-recipient failures re-send only to recipients that have not
+ yet accepted the message.
+- Bounced messages are archived as plain files under `bounced/` and honor
+ `[storage.cleanup]` retention.
+- Durability: process crashes lose nothing; a machine crash or power loss
+ may lose the most recently acknowledged messages (documented tradeoff).
+ Destructive operations are fsync-guarded, so older queued mail is never
+ at risk.
+- Inspect a live spool with `hedwig queue list|show|stats`; migrate a legacy
+ spool with `hedwig queue migrate`.
+
+### Legacy queue (`storage_type = "fs"`)
-## Storage architecture
+```
+SMTP listeners → callbacks → storage (one file/row per message)
+ │
+ bounded in-memory job channel
+ │
+ workers consume jobs
+```
-The storage trait supports multiple backends. Current implementation is filesystem-based.
+One file per message plus a bounded channel between acceptance and
+workers. Acceptance waits for a channel slot, so sustained slow outbound
+eventually backpressures inbound. Deferred messages are re-queued by a
+periodic scan. This path is unchanged and remains the default.
-Directory structure:
+## Core components
-```
-/base_path/
- queued/
- deferred/
- bounced/
- meta/
-```
+- **Main server** — configuration, TLS setup, listeners, worker/dispatcher
+ startup, graceful shutdown.
+- **SMTP callbacks** — authentication, domain filtering, disk-reserve check,
+ queue admission.
+- **Workers** — parse, strip Bcc, DKIM-sign, resolve MX, apply MTA-STS,
+ deliver through the per-domain connection pool, classify outcomes.
+- **Outbound pool** — per-domain pooling, TLS, connection limits, MX caching.
## Security
-- Inbound TLS per listener
+- Inbound TLS per listener (implicit or STARTTLS)
- Optional SMTP AUTH
- DKIM signing (RSA or Ed25519)
+- MTA-STS policy enforcement
- Domain allow/deny filters
-## Performance
-
-- Tokio-based async runtime
-- Multi-listener concurrency
-- Per-domain connection pooling
-- MX record caching
-- Bounded channels for backpressure
-
## Monitoring
-- Structured logging via tracing
-- Prometheus metrics when enabled
-- Operational signals for queue depth and delivery rates
+- Structured logging via tracing (JSON or plain)
+- Prometheus metrics when enabled, including queue depth, dispatcher lag,
+ segment/GC statistics, and delivery outcomes
## Deployment
-- File-based configuration
+- File-based configuration (TOML or HUML)
- Environment override for log level
-- Graceful shutdown and queue recovery
-- File system permissions for storage and keys
-
-## Extension points
-
-- Additional storage backends (DB, S3)
-- Alternative auth methods (LDAP, OAuth2)
-- External filtering integrations
+- Graceful shutdown with state checkpointing and queue recovery
+- Exclusive spool lock: one process per spool root
diff --git a/docs/src/pages/storage.md b/docs/src/pages/storage.md
index 688f0c9..146cd81 100644
--- a/docs/src/pages/storage.md
+++ b/docs/src/pages/storage.md
@@ -8,7 +8,7 @@ description: Configure filesystem storage and retention policies.
```toml
[storage]
-storage_type = "fs" # Storage type: "fs" (filesystem)
+storage_type = "log" # "log" (default) or "fs" (legacy filesystem)
base_path = "/var/spool/hedwig" # Base directory for email storage
[storage.cleanup]
@@ -20,3 +20,58 @@ interval = "1h" # Run the cleanup task hourly
- All keys inside `[storage.cleanup]` are optional; omit them to disable specific cleanups
- Retention values accept human-readable durations (e.g., `"24h"`, `"5m"`)
- The cleanup task runs on a background interval and also executes once during startup
+
+## Log queue backend
+
+`storage_type = "log"` — the default when `storage_type` is omitted —
+selects the durable log queue: complete messages are
+stored in segmented append-only logs under `/spool/`, and SMTP
+`250 OK` is returned as soon as the record is written — inbound acceptance
+is bounded by disk append throughput, not by outbound delivery speed.
+Retries are scheduled by due time (no periodic spool scans), and retries
+after a partial multi-recipient failure re-send only to the recipients that
+have not yet accepted the message.
+
+```toml
+[storage]
+storage_type = "log"
+base_path = "/var/spool/hedwig" # spool/ (queue) + bounced/ (archive)
+
+[queue] # all optional; defaults shown
+append_writers = 1 # log shards; change requires an empty queue
+pending_append_bytes = 134217728 # in-memory admission buffer bound (bytes)
+segment_target_bytes = 67108864 # seal active segments at this size
+compaction_dead_ratio = 0.5 # compact sealed segments this dead
+compaction_min_age = "60s" # leave fresh segments alone
+disk_reserve_bytes = 1073741824 # reject mail when free disk drops below
+checkpoint_interval_bytes = 8388608 # checkpoint cadence per shard
+```
+
+**Durability tradeoff:** acknowledged mail is durable against process
+crashes and restarts, but the queue does not `fsync` per message — a machine
+crash or power loss can lose the most recently acknowledged messages. This
+is a deliberate throughput tradeoff. Destructive operations (segment
+deletion, compaction, checkpoint truncation) do use `fsync` barriers, so a
+power loss can never destroy older queued mail.
+
+**Disk behavior:** disk use is proportional to the live backlog plus
+not-yet-reclaimed garbage. Fully delivered segments are deleted as soon as
+their last message completes; partially dead segments are compacted once
+`compaction_dead_ratio` is exceeded. When free space falls below
+`disk_reserve_bytes`, new mail is rejected with a transient `452`.
+
+Bounced messages are archived as regular files under `bounced/` (same layout
+as the filesystem backend) and honor `[storage.cleanup]` retention.
+
+Inspect a spool — including a live one — with the built-in CLI:
+
+```sh
+hedwig queue list --spool /var/spool/hedwig/spool
+hedwig queue show --spool /var/spool/hedwig/spool
+hedwig queue stats --spool /var/spool/hedwig/spool
+```
+
+To migrate an existing filesystem spool, stop the server, set
+`storage_type = "log"`, then run `hedwig queue migrate --config `;
+legacy `queued/` and `deferred/` directories are preserved as timestamped
+`.migrated-*` backups.
diff --git a/smtp-server/Cargo.toml b/smtp-server/Cargo.toml
index 9734f13..c3d4565 100644
--- a/smtp-server/Cargo.toml
+++ b/smtp-server/Cargo.toml
@@ -62,13 +62,12 @@ rand = "0.8"
ed25519-dalek = { version = "2", features = ["pkcs8"] }
pem = "3"
memchr = "2.7.4"
-# Note: in sqlx 0.8 the "sqlite" feature already bundles libsqlite3 statically.
-# There is no separate "sqlite-bundled" feature; use "sqlite-unbundled" to link
-# against the system library instead.
-sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
mailparse = "0.16.1"
humantime-serde = "1.1"
chrono = { version = "0.4", features = ["serde"] }
hyper = { version = "0.14", features = ["full"] }
once_cell = "1.19.0"
prometheus = "0.13"
+crc32fast = "1.4"
+bytes = "1"
+libc = "0.2"
diff --git a/smtp-server/src/callbacks.rs b/smtp-server/src/callbacks.rs
index c483493..77acc88 100644
--- a/smtp-server/src/callbacks.rs
+++ b/smtp-server/src/callbacks.rs
@@ -40,12 +40,23 @@ fn cram_md5_digest(password: &str, challenge: &str) -> String {
.collect()
}
+/// The log-queue acceptance path: SMTP DATA appends to the durable log and
+/// acknowledges on page-cache write completion, never waiting for workers.
+pub struct LogQueueTap {
+ pub append: crate::logqueue::writer::AppendHandle,
+ pub spool_root: std::path::PathBuf,
+ pub disk_reserve_bytes: u64,
+}
+
/// The Callbacks struct holds the configuration, storage, and sender channel.
pub struct Callbacks {
cfg: Cfg,
auth_mapping: Mutex>,
storage: Arc,
sender_channel: async_channel::Sender,
+ /// `Some` when the log-queue backend is active: DATA goes to the append
+ /// log and the legacy storage/channel path is bypassed.
+ log_queue: Option,
}
/// Extracts the lowercased domain from an SMTP path like ``.
@@ -147,61 +158,10 @@ impl Callbacks {
receiver_channel: async_channel::Receiver,
cfg: Cfg,
) -> miette::Result<(Self, Vec>, Arc)> {
- let expiry = MXExpiry;
- let mx_cache: Cache<_, _> = Cache::builder()
- .max_capacity(10000)
- .expire_after(expiry)
- .build();
-
- // Create a shared DNS resolver for workers and MTA-STS.
- let resolver = TokioAsyncResolver::tokio_from_system_conf()
- .into_diagnostic()
- .wrap_err("failed to create DNS resolver")?;
-
- // Create the shared MTA-STS resolver.
- let mta_sts_fetcher = MtaStsFetcher::new(resolver.clone());
- let mta_sts_resolver = Arc::new(MtaStsResolver::new(mta_sts_fetcher));
-
- // Start workers.
- let worker_count = cfg.server.workers.unwrap_or(1).max(1);
- let rate_limit_config = cfg
- .server
- .rate_limits
- .as_ref()
- .map(|rl| rl.to_rate_limit_config())
- .unwrap_or_default();
+ let (worker_resources, mta_sts_resolver) = build_worker_resources(&cfg)?;
+ let worker_count = cfg.server.workers.unwrap_or(1).max(1);
let mut worker_handles = Vec::new();
- let helo_hostname = cfg.server.helo_hostname.clone();
- if let Some(name) = helo_hostname.as_deref() {
- if !name.contains('.') {
- warn!(
- helo_hostname = %name,
- "configured HELO/EHLO hostname does not look like a public FQDN"
- );
- }
- }
-
- let smtp_pool = build_smtp_pool_config(&cfg);
- info!(
- smtp_cache_size = smtp_pool.cache_size,
- smtp_pool_min_idle = smtp_pool.min_idle,
- smtp_pool_max_size = smtp_pool.max_size,
- "configured outbound SMTP pool"
- );
- let smtp_pool_manager = Arc::new(worker::PoolManager::new(
- smtp_pool,
- cfg.server.outbound_local.unwrap_or(false),
- helo_hostname,
- ));
- let worker_resources = worker::WorkerResources::new(
- mx_cache,
- smtp_pool_manager,
- resolver,
- Arc::clone(&mta_sts_resolver),
- rate_limit_config,
- );
-
for worker_index in 0..worker_count {
let receiver_channel = receiver_channel.clone();
let storage_cloned = storage.clone();
@@ -239,13 +199,90 @@ impl Callbacks {
sender_channel,
cfg,
auth_mapping: Mutex::new(auth_mapping),
+ log_queue: None,
};
Ok((callbacks, worker_handles, mta_sts_resolver))
}
+ /// Creates callbacks for the log-queue backend. No legacy workers are
+ /// spawned and the job channel is inert; the caller wires the returned
+ /// [`worker::WorkerResources`] into log workers instead. `storage` is
+ /// used only as the bounced-message archive.
+ pub async fn new_log(
+ storage: Arc,
+ tap: LogQueueTap,
+ cfg: Cfg,
+ ) -> miette::Result<(Self, worker::WorkerResources, Arc)> {
+ let (worker_resources, mta_sts_resolver) = build_worker_resources(&cfg)?;
+
+ let mut auth_mapping = HashMap::new();
+ if let Some(auth) = &cfg.server.auth {
+ for auth in auth.iter() {
+ auth_mapping.insert(auth.username.clone(), auth.password.clone());
+ }
+ }
+
+ // The legacy channel field is inert on this path.
+ let (sender_channel, _) = async_channel::bounded(1);
+ let callbacks = Callbacks {
+ storage,
+ sender_channel,
+ cfg,
+ auth_mapping: Mutex::new(auth_mapping),
+ log_queue: Some(tap),
+ };
+ Ok((callbacks, worker_resources, mta_sts_resolver))
+ }
+
+ /// Log-queue DATA path: check the disk reserve, then append and wait
+ /// only for page-cache write completion (PLAN §28 phase 8). Worker and
+ /// dispatcher capacity never gate acceptance.
+ async fn process_email_log(&self, tap: &LogQueueTap, email: Email) -> Result<(), SmtpError> {
+ if tap.disk_reserve_bytes > 0 {
+ match crate::logqueue::spool::disk_free_bytes(&tap.spool_root) {
+ Ok(free) if free < tap.disk_reserve_bytes => {
+ warn!(
+ free_bytes = free,
+ reserve_bytes = tap.disk_reserve_bytes,
+ "disk reserve reached; rejecting message"
+ );
+ return Err(SmtpError::Transient {
+ message: "4.3.1 insufficient system storage".into(),
+ });
+ }
+ Ok(_) => {}
+ Err(e) => {
+ // Fail open: a broken statvfs must not stop mail flow.
+ warn!(error = %e, "disk reserve check failed");
+ }
+ }
+ }
+
+ let message_id = crate::logqueue::MessageId::from_ulid(Ulid::new());
+ let msg = crate::logqueue::writer::AppendMessage {
+ message_id,
+ enqueue_ms: Utc::now().timestamp_millis(),
+ generation: 0,
+ sender: email.from,
+ recipients: email.to,
+ body: bytes::Bytes::from(email.body.into_bytes()),
+ };
+ tap.append.append(msg).await.map_err(|e| {
+ warn!(error = %e, "append to log queue failed");
+ SmtpError::Transient {
+ message: "4.3.0 queue write failed, try again later".into(),
+ }
+ })?;
+ metrics::email_received();
+ Ok(())
+ }
+
/// Processes an email by parsing it, storing it, and sending it to a worker.
async fn process_email(&self, email: Email) -> Result<(), SmtpError> {
+ if let Some(tap) = &self.log_queue {
+ return self.process_email_log(tap, email).await;
+ }
let ulid = Ulid::new().to_string();
// We are using ulid as the message id instead of message_id from the email.
// The issue is we can't depend on the email client to provide a unique message id.
@@ -281,6 +318,68 @@ impl Callbacks {
}
}
+/// Builds the delivery resources shared by every worker flavor: MX cache,
+/// DNS resolver, MTA-STS resolver, outbound SMTP pool, and the process-wide
+/// rate limiter.
+fn build_worker_resources(
+ cfg: &Cfg,
+) -> miette::Result<(worker::WorkerResources, Arc)> {
+ let expiry = MXExpiry;
+ let mx_cache: Cache<_, _> = Cache::builder()
+ .max_capacity(10000)
+ .expire_after(expiry)
+ .build();
+
+ // Create a shared DNS resolver for workers and MTA-STS.
+ let resolver = TokioAsyncResolver::tokio_from_system_conf()
+ .into_diagnostic()
+ .wrap_err("failed to create DNS resolver")?;
+
+ // Create the shared MTA-STS resolver.
+ let mta_sts_fetcher = MtaStsFetcher::new(resolver.clone());
+ let mta_sts_resolver = Arc::new(MtaStsResolver::new(mta_sts_fetcher));
+
+ let rate_limit_config = cfg
+ .server
+ .rate_limits
+ .as_ref()
+ .map(|rl| rl.to_rate_limit_config())
+ .unwrap_or_default();
+
+ let helo_hostname = cfg.server.helo_hostname.clone();
+ if let Some(name) = helo_hostname.as_deref() {
+ if !name.contains('.') {
+ warn!(
+ helo_hostname = %name,
+ "configured HELO/EHLO hostname does not look like a public FQDN"
+ );
+ }
+ }
+
+ let smtp_pool = build_smtp_pool_config(cfg);
+ info!(
+ smtp_cache_size = smtp_pool.cache_size,
+ smtp_pool_min_idle = smtp_pool.min_idle,
+ smtp_pool_max_size = smtp_pool.max_size,
+ "configured outbound SMTP pool"
+ );
+ let smtp_pool_manager = Arc::new(worker::PoolManager::new(
+ smtp_pool,
+ cfg.server.outbound_local.unwrap_or(false),
+ helo_hostname,
+ ));
+ Ok((
+ worker::WorkerResources::new(
+ mx_cache,
+ smtp_pool_manager,
+ resolver,
+ Arc::clone(&mta_sts_resolver),
+ rate_limit_config,
+ ),
+ mta_sts_resolver,
+ ))
+}
+
fn build_smtp_pool_config(cfg: &Cfg) -> worker::SmtpPoolConfig {
let smtp = cfg.server.smtp.as_ref();
let cache_size = smtp
@@ -697,12 +796,9 @@ mod tests {
storage_type: "mock".to_string(),
base_path: "/tmp/hedwig".to_string(),
cleanup: None,
- num_shards: None,
- batch_size: None,
- batch_timeout_ms: None,
- sqlite: None,
},
filters,
+ queue: None,
};
let (sender_channel, receiver_channel) = async_channel::unbounded::();
@@ -1572,12 +1668,9 @@ mod tests {
storage_type: "memory".to_string(),
base_path: "/tmp".to_string(),
cleanup: None,
- num_shards: None,
- batch_size: None,
- batch_timeout_ms: None,
- sqlite: None,
},
filters: None,
+ queue: None,
}
}
diff --git a/smtp-server/src/config.rs b/smtp-server/src/config.rs
index 2c1e5a0..c31c383 100644
--- a/smtp-server/src/config.rs
+++ b/smtp-server/src/config.rs
@@ -30,6 +30,7 @@ pub struct Cfg {
pub server: CfgServer,
pub storage: CfgStorage,
pub filters: Option>,
+ pub queue: Option,
}
#[derive(Debug, Deserialize, Clone)]
@@ -71,18 +72,13 @@ pub struct CfgFilter {
#[derive(Debug, Deserialize, Clone)]
pub struct CfgStorage {
+ /// "log" (durable log queue, the default) or "fs" (legacy one file per
+ /// message).
+ #[serde(default = "default_storage_type")]
pub storage_type: String,
pub base_path: String,
#[serde(default)]
pub cleanup: Option,
- /// Number of SQLite shards (default: 16). Only used when storage_type = "sqlite".
- pub num_shards: Option,
- /// Max writes per batch (default: 100). Only used when storage_type = "sqlite".
- pub batch_size: Option,
- /// Max wait to fill a batch in ms (default: 5). Only used when storage_type = "sqlite".
- pub batch_timeout_ms: Option,
- /// SQLite-specific tuning. Only used when storage_type = "sqlite".
- pub sqlite: Option,
}
#[derive(Debug, Deserialize, Clone, Default)]
@@ -177,18 +173,6 @@ pub struct CfgRateLimits {
pub domain_limits: Option>,
}
-#[derive(Debug, Deserialize, Clone, Default)]
-pub struct CfgSqlite {
- /// SQLite synchronous mode: OFF | NORMAL | FULL (default: NORMAL)
- pub synchronous: Option,
- /// Total cache size in MB across all shards (default: 1600)
- pub cache_size_mb: Option,
- /// SQLite busy timeout in ms (default: 5000)
- pub busy_timeout_ms: Option,
- /// Read connections per shard (default: 10)
- pub pool_max_connections: Option,
-}
-
/// Configuration for on-disk spool cleanup.
#[derive(Debug, Deserialize, Clone)]
pub struct CfgCleanup {
@@ -200,7 +184,88 @@ pub struct CfgCleanup {
pub interval: Duration,
}
+/// Configuration for the durable append-log mail queue (see
+/// docs/plans/2026-07-20-durable-log-queue.md).
+#[derive(Debug, Deserialize, Clone, Default)]
+pub struct CfgQueue {
+ /// Number of shards / concurrent append writers (default: 1).
+ pub append_writers: Option,
+ /// Bytes of pending (not-yet-durable) append data allowed before backpressure (default: 128 MiB).
+ pub pending_append_bytes: Option,
+ /// Target size of each active segment file, per shard (default: 64 MiB).
+ pub segment_target_bytes: Option,
+ /// Fraction of dead bytes in a sealed segment that makes it eligible for compaction (default: 0.50).
+ pub compaction_dead_ratio: Option,
+ /// Minimum age of a sealed segment before it is eligible for compaction (default: 60s).
+ #[serde(default, with = "humantime_serde::option")]
+ pub compaction_min_age: Option,
+ /// Minimum free disk space required to accept new mail (default: 1 GiB).
+ pub disk_reserve_bytes: Option,
+ /// Bytes of appended data between durability checkpoints (default: 8 MiB).
+ pub checkpoint_interval_bytes: Option,
+}
+
+impl CfgQueue {
+ pub fn append_writers(&self) -> u16 {
+ self.append_writers.unwrap_or(1)
+ }
+
+ pub fn pending_append_bytes(&self) -> u64 {
+ self.pending_append_bytes.unwrap_or(128 * 1024 * 1024)
+ }
+
+ pub fn segment_target_bytes(&self) -> u64 {
+ self.segment_target_bytes.unwrap_or(64 * 1024 * 1024)
+ }
+
+ pub fn compaction_dead_ratio(&self) -> f64 {
+ self.compaction_dead_ratio.unwrap_or(0.50)
+ }
+
+ pub fn compaction_min_age(&self) -> Duration {
+ self.compaction_min_age.unwrap_or(Duration::from_secs(60))
+ }
+
+ pub fn disk_reserve_bytes(&self) -> u64 {
+ self.disk_reserve_bytes.unwrap_or(1024 * 1024 * 1024)
+ }
+
+ pub fn checkpoint_interval_bytes(&self) -> u64 {
+ self.checkpoint_interval_bytes.unwrap_or(8 * 1024 * 1024)
+ }
+
+ /// Validate the resolved configuration. `max_message_size` is the
+ /// server's configured (or defaulted) maximum message size in bytes,
+ /// since the segment target must be able to hold one worst-case record.
+ pub fn validate(&self, max_message_size: usize) -> miette::Result<()> {
+ if self.append_writers() < 1 {
+ return Err(miette::miette!("queue.append_writers must be at least 1"));
+ }
+
+ let ratio = self.compaction_dead_ratio();
+ if !(ratio > 0.0 && ratio < 1.0) {
+ return Err(miette::miette!(
+ "queue.compaction_dead_ratio must be between 0.0 and 1.0 (exclusive), got {ratio}"
+ ));
+ }
+
+ crate::logqueue::spool::check_segment_sizing(
+ self.segment_target_bytes(),
+ max_message_size as u64,
+ )
+ .map_err(miette::Report::new)?;
+
+ Ok(())
+ }
+}
+
impl Cfg {
+ /// The resolved queue configuration, defaulted if the `[queue]` section
+ /// is absent from the loaded configuration.
+ pub fn queue(&self) -> CfgQueue {
+ self.queue.clone().unwrap_or_default()
+ }
+
pub fn load(cfg_path: &str) -> Result {
let path = Path::new(cfg_path);
@@ -258,3 +323,111 @@ impl CfgStorage {
fn default_cleanup_interval() -> Duration {
Duration::from_secs(60 * 60)
}
+
+/// The durable log queue is the default backend.
+fn default_storage_type() -> String {
+ "log".to_string()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use config::FileFormat;
+
+ /// Smallest configuration that satisfies every required (non-`Option`)
+ /// field on `Cfg`, so tests can focus on the `[queue]` section alone.
+ const MINIMAL_CFG: &str = r#"
+ [server]
+ listeners = []
+
+ [storage]
+ storage_type = "fs"
+ base_path = "/tmp/hedwig-test-spool"
+ "#;
+
+ fn parse(toml: &str) -> Cfg {
+ let settings = Config::builder()
+ .add_source(File::from_str(toml, FileFormat::Toml))
+ .build()
+ .expect("build config");
+ settings.try_deserialize().expect("deserialize config")
+ }
+
+ #[test]
+ fn storage_type_defaults_to_log() {
+ let cfg = parse(
+ r#"
+ [server]
+ listeners = []
+
+ [storage]
+ base_path = "/tmp/hedwig-test-spool"
+ "#,
+ );
+ assert_eq!(cfg.storage.storage_type, "log");
+ }
+
+ #[test]
+ fn queue_defaults_when_section_absent() {
+ let cfg = parse(MINIMAL_CFG);
+ assert!(cfg.queue.is_none());
+
+ let queue = cfg.queue();
+ assert_eq!(queue.append_writers(), 1);
+ assert_eq!(queue.pending_append_bytes(), 128 * 1024 * 1024);
+ assert_eq!(queue.segment_target_bytes(), 64 * 1024 * 1024);
+ assert_eq!(queue.compaction_dead_ratio(), 0.50);
+ assert_eq!(queue.compaction_min_age(), Duration::from_secs(60));
+ assert_eq!(queue.disk_reserve_bytes(), 1024 * 1024 * 1024);
+ assert_eq!(queue.checkpoint_interval_bytes(), 8 * 1024 * 1024);
+ }
+
+ #[test]
+ fn queue_section_parses_from_toml() {
+ let toml = format!(
+ r#"{MINIMAL_CFG}
+ [queue]
+ append_writers = 4
+ pending_append_bytes = 1048576
+ segment_target_bytes = 16777216
+ compaction_dead_ratio = 0.75
+ compaction_min_age = "30s"
+ disk_reserve_bytes = 2147483648
+ checkpoint_interval_bytes = 4194304
+ "#
+ );
+ let cfg = parse(&toml);
+ let queue = cfg.queue.expect("queue section present");
+ assert_eq!(queue.append_writers(), 4);
+ assert_eq!(queue.pending_append_bytes(), 1_048_576);
+ assert_eq!(queue.segment_target_bytes(), 16_777_216);
+ assert_eq!(queue.compaction_dead_ratio(), 0.75);
+ assert_eq!(queue.compaction_min_age(), Duration::from_secs(30));
+ assert_eq!(queue.disk_reserve_bytes(), 2_147_483_648);
+ assert_eq!(queue.checkpoint_interval_bytes(), 4_194_304);
+ }
+
+ #[test]
+ fn validate_accepts_defaults() {
+ let queue = CfgQueue::default();
+ assert!(queue.validate(25 * 1024 * 1024).is_ok());
+ }
+
+ #[test]
+ fn validate_rejects_dead_ratio_out_of_range() {
+ let queue = CfgQueue {
+ compaction_dead_ratio: Some(1.5),
+ ..CfgQueue::default()
+ };
+ assert!(queue.validate(25 * 1024 * 1024).is_err());
+ }
+
+ #[test]
+ fn validate_rejects_segment_smaller_than_max_message_size() {
+ let queue = CfgQueue {
+ segment_target_bytes: Some(1024),
+ ..CfgQueue::default()
+ };
+ assert!(queue.validate(25 * 1024 * 1024).is_err());
+ }
+}
diff --git a/smtp-server/src/logqueue/dispatcher.rs b/smtp-server/src/logqueue/dispatcher.rs
new file mode 100644
index 0000000..f507758
--- /dev/null
+++ b/smtp-server/src/logqueue/dispatcher.rs
@@ -0,0 +1,1973 @@
+//! The dispatcher: one task scheduling delivery across all shards.
+//!
+//! It discovers appended records via per-shard cursors against the writers'
+//! committed chains (notifications are lossy hints), hands payload-free
+//! claims to pulling workers, persists every outcome through the shard's
+//! state journal before applying it (persist-then-apply), schedules retries
+//! by due time, and gates dispatch on the per-domain rate limiter so
+//! throttled destinations never occupy worker slots.
+
+use std::cmp::Reverse;
+use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque};
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::time::Duration;
+
+use tokio::sync::{mpsc, oneshot};
+use tokio_util::sync::CancellationToken;
+
+use super::segment::{open_segment_reader, scan_headers, SegmentReader};
+use super::state::{
+ Checkpoint, DeferredJob, PendingCheckpoint, ReadyJob, RecoveredState, SegmentStats,
+ ShardStateStore, StateEntry,
+};
+use super::writer::ShardShared;
+use super::{JobLocation, MessageId, QueueError};
+
+/// Dispatch-time rate limiting. `check` returns how long the domain is
+/// exhausted for, or `None` when a send is allowed (and accounted).
+pub trait RateGate: Send + Sync {
+ fn check(&self, domain: &str) -> Option;
+}
+
+/// Allows everything. The server always installs `LimiterGate` (which
+/// itself allows everything when rate limits are disabled); tests use this
+/// to bypass gating entirely.
+#[cfg(test)]
+pub struct NoRateGate;
+
+#[cfg(test)]
+impl RateGate for NoRateGate {
+ fn check(&self, _domain: &str) -> Option {
+ None
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct DispatcherConfig {
+ /// Discovery backpressure: stop advancing cursors while this many jobs
+ /// are tracked in memory. The log holds everything beyond it.
+ pub max_tracked_jobs: usize,
+ /// Fallback wake-up for lost notifications and persist retries.
+ pub safety_tick: Duration,
+ /// Checkpoint a shard once its journal grows past this many bytes.
+ pub checkpoint_interval_bytes: u64,
+ /// Compact a sealed segment once this fraction of its bytes is dead.
+ pub compaction_dead_ratio: f64,
+ /// Leave freshly sealed segments alone for this long.
+ pub compaction_min_age: Duration,
+}
+
+impl Default for DispatcherConfig {
+ fn default() -> Self {
+ Self {
+ max_tracked_jobs: 100_000,
+ safety_tick: Duration::from_millis(500),
+ checkpoint_interval_bytes: 8 * 1024 * 1024,
+ compaction_dead_ratio: 0.5,
+ compaction_min_age: Duration::from_secs(60),
+ }
+ }
+}
+
+/// What a worker receives: identity, location, and delivery metadata —
+/// never the message body.
+#[derive(Debug, Clone)]
+pub struct DeliveryJob {
+ pub message_id: MessageId,
+ pub location: JobLocation,
+ pub attempts: u32,
+ pub claim_generation: u64,
+ pub enqueue_ms: i64,
+ pub sender: String,
+ /// Recipients that have not yet accepted the message.
+ pub recipients: Vec,
+}
+
+/// A worker's report for one claim.
+#[derive(Debug)]
+pub enum JobOutcome {
+ Delivered {
+ response: String,
+ },
+ /// A real failed attempt: increments the attempt count, persists the
+ /// remaining recipient set, and schedules the retry by due time.
+ Deferred {
+ next_attempt_ms: i64,
+ remaining_recipients: Vec,
+ error: String,
+ },
+ /// Lost the transmission-time rate-limit race: requeued in memory only.
+ /// No journal write, no attempt increment.
+ RateLimited {
+ retry_after: Duration,
+ },
+ Bounced {
+ reason: String,
+ },
+}
+
+enum WorkerEvent {
+ Outcome {
+ id: MessageId,
+ generation: u64,
+ outcome: JobOutcome,
+ },
+ Abandoned {
+ id: MessageId,
+ generation: u64,
+ },
+}
+
+/// A claimed job. Report exactly one outcome; dropping the claim without
+/// reporting counts as abandonment and returns the job to the ready queue.
+pub struct Claim {
+ pub job: DeliveryJob,
+ events: mpsc::UnboundedSender,
+ reported: bool,
+}
+
+impl Claim {
+ pub fn report(mut self, outcome: JobOutcome) {
+ self.reported = true;
+ let _ = self.events.send(WorkerEvent::Outcome {
+ id: self.job.message_id,
+ generation: self.job.claim_generation,
+ outcome,
+ });
+ }
+}
+
+impl Drop for Claim {
+ fn drop(&mut self) {
+ if !self.reported {
+ let _ = self.events.send(WorkerEvent::Abandoned {
+ id: self.job.message_id,
+ generation: self.job.claim_generation,
+ });
+ }
+ }
+}
+
+type ClaimWaiter = oneshot::Sender