--- id: MAS-WP-0012 type: workplan title: "One outbound path and one send cap" domain: communication repo: mail-address-semantics status: finished owner: claude topic_slug: coulomb-social created: "2026-10-19" updated: "2026-10-19" origin: residual origin_ref: MAS-WP-0010 state_hub_workstream_id: "be2db53f-9acc-5bf0-a6dc-fb99cbc8e46b" --- # One outbound path and one send cap Risk C in `history/2026-10-19T090725Z-scope-vs-intent.md`. With both roles in one deployment, the ticket service and `mas_modes.mailer` each keep their own outbox and cap. Total outbound can then reach about twice the configured cap. They also act differently over the cap: the ticket service keeps replies unsent, while the mailer refuses with `send-cap-reached`. Principle 10 says "caps how much it can send in total". ## Route all mail through the mailer ```task id: MAS-WP-0012-T01 status: done priority: medium state_hub_task_id: "5d9fefac-684d-5fe0-bead-f664952047e0" ``` Make `mas_modes.mailer.Mailer` the only outbound path. The ticket service then sends through a Mailer it is given, sharing the DB or an outbox service, instead of its own `outbox` table and cap. Settle one behavior over the cap, and record it in CONTRACT.md §6. Keep the ticket service's `/outbox` inspection working, and keep the deployment's `--max-sent` semantics. ## Test the shared cap ```task id: MAS-WP-0012-T02 status: done priority: medium state_hub_task_id: "431cc528-cf9f-5991-8044-7cbab2ac0021" ``` Add a test that a deployment with both roles stops at exactly N sends in total, and that the live entrypoint still passes `MAS_MAX_SENT` to the single cap. ## Outcome - **Founder interview (2026-10-19).** Over the cap, mail is **held and reported**. Held mail is purged after `email-held-duration-max` (default 7d) and not stored beyond `emails-held-storage-max` (default 100MB). The live counter is **migrated once at start**. - **`mas_modes.mailer.Mailer`** is the one outbound path: its own SQLite file and lock, principle 10, one lifetime cap, holding with a purge on every send and `purge_held()`, a storage limit, `import_legacy()` with a once-only marker, and `parse_duration` / `parse_size`. - **Ticket service.** It sends through a Mailer it is given, or builds one. `/outbox` shows the shared outbox (`ticket_id` = case ref). Its legacy `outbox` table is imported once and then left alone. New CLI options: `--outbox-db` (default `-outbox.sqlite3`), `--email-held-duration-max` and `--emails-held-storage-max`. Over the cap the closed notice reports `notified: false`. - **EmailMode** takes the shared Mailer. `mail.send` over the cap answers `send-cap-reached` with `held`. CONTRACT.md §5 and §6 are updated. - **Deploy.** The entrypoint passes `MAS_MAX_SENT` (300) and the two hold limits (`MAS_EMAIL_HELD_DURATION_MAX`, `MAS_EMAILS_HELD_STORAGE_MAX`). The migration was checked on a copy of the live DB: 9 legacy rows, 4 sent, carried over; no second import on restart. - **Tests.** `tests/test_outbound.py` (15): exactly N sends across both roles, purge by age, storage limit, sent mail never purged, the legacy migration, parsers, entrypoint flags. - **Deployed** 2026-10-19 with `mas-live:v0.2-480e69a`. The live counter was migrated: 4 of 300 (see MAS-WP-0007).