# Mailery API Changelog

All notable changes to the Mailery API contract (`/api/v1`) and public web
surface are recorded here. See the
[Versioning And Deprecation](./API.md#versioning-and-deprecation) policy for
how breaking changes and deprecations are announced.

## Unreleased

- **`desktop/` removed (launch plan L01, owner directive 2026-09-09).** The
  parked macOS client descoped on 2026-08-25 is deleted from the tree; git
  history is the record. No API change. Remaining mentions of a desktop client
  in comments and the CORS test fixtures were reworded; the `?domain=` filter
  on `GET /api/v1/messages` stays (published contract).
- **Launch plan and ledger added.** `docs/LAUNCH-PLAN.md` and
  `docs/LAUNCH-TODOS.md` are the execution truth for the public launch; the
  todos plans under `.hasna/todos/plans/` are historical.
- **Honest send status in non-live mode (PLA17-00095).** `POST
  /api/v1/messages/send` on a server with `MAILERY_MAIL_MODE` != `live` used to
  persist the message with status `sent`, a fabricated `sent_at`, and the
  internal local id returned as `provider_message_id` — a send that looked
  delivered while no dispatch ever ran. Now:
  - New `message_status` enum value **`test_sent`** (migration 0017) is the
    persisted state for non-live hosted sends. It is non-terminal by
    definition: no provider dispatch has run for it.
  - Non-live sends return `provider_message_id: null` (no fabricated provider
    reference) and leave `sent_at` null until a real dispatch sets it.
  - Live mode is unchanged: `queued` pre-dispatch, `sent` only after SES
    accepts the dispatch, real SES message id returned.
  - SDK (`MaileryMessageStatus`), CLI output ("Not dispatched in test mode…"),
    and API docs describe the new state everywhere statuses are shown.
- **Pro tier copy and contract truthed to shipped entitlements
  (PLA17-00094).** Mailery advertised a Pro entitlement of "warmup +
  deliverability monitoring" that had no implementation. Per the owner's
  Branch-B default on PLA17-00094 (amend the promise away; pending owner
  confirmation), every commercial surface now promises only shipped
  capability:
  - `docs/COMMERCIAL_CONTRACT.md` pricing table Pro row now reads
    "per-domain DMARC reports and policy management", with a dated amendment
    note recording the decision and keeping Branch A available as successor
    work.
  - Landing/compare copy (`web/src/lib/pricing.ts`, `web/src/lib/content.ts`,
    `web/src/lib/compare.ts`, generated `web/*.html`, hand-maintained
    `web/index.html` meta + JSON-LD) replaces warmup/reputation-monitoring
    claims with shipped facts: automatic DKIM/SPF/DMARC provisioning,
    DMARC-gated outbound sending, and per-domain DMARC reports & policy
    management (`POST /api/v1/domains/:id/dmarc/report`,
    `POST /api/v1/domains/:id/dmarc/policy`).
  - `docs/API.md` identity-tier table updated to match.
  - `src/lib/credits.ts`: removed the never-consumed `warmup` and
    `deliverabilityMonitoring` fields from `PlanEntitlements` and all three
    plans (they were serialized verbatim into `GET /api/v1/billing/overview`
    while gating nothing). No client-visible behavior change beyond the plan
    object shape.
  - Competitor-side mentions of third parties' warmup/deliverability tooling
    are unchanged — those describe other products truthfully.
  - Branch A (building real deliverability monitoring) is scaffolded, not
    implemented, in `docs/ops/deliverability-successor-design.md`.
- **macOS desktop client descoped (PLA17-00091).** The Mailery product ships
  headless and agent-native only — the CLI, MCP server, and SDK are the
  supported clients. The `desktop/` macOS app in this repository is parked,
  out of scope, and no longer part of the product story; it is not a
  supported Mailery client and receives no product roadmap commitment.
  The parked `desktop/` directory remains in the tree as a record.

## 2026-08-25 — deployed (lineage of sha `a2bb336`)

Everything in this section is implemented and running in production: it
shipped in the deployment cut at main `a2bb336`
(`perf(db): emit genuine time-ordered UUIDv7 for table PKs`, PR #55).
Entries still under **Unreleased** are merged to main but not yet part of a
production deployment.

- **`POST /api/v1/auth/verify/poll` (machine bootstrap)**: poll whether the
  emailed verification link has been clicked. Answers
  `{status:"pending"}` until the click lands, then
  `{status:"verified",token}` with a fresh session JWT carrying the normal
  login scope set — issued only to a caller presenting a live session for the
  same account; every other caller gets a uniform `pending` (no enumeration).
  Emails are normalized like signup. Rate-limited 5/min per client IP plus a
  global ceiling. CLI: `mailery verify --poll`.
- **Repeatable `?domain=` mailbox-scope filter** on `GET /api/v1/messages` and
  `GET /api/v1/messages/groups` (shared self-hosted/cloud contract): scope the
  list/counts to the tenant's mailboxes whose **address** is at one of the
  given domains (e.g. `?domain=corp-a.test&domain=corp-b.test`).
  Case-insensitive, tolerates a leading `@`, max 16 distinct values; IDN
  domains match under either spelling (unicode or punycode); a blank or
  whitespace-only value collapses to no filter (like `?folder=`), a malformed
  value is `400`, and combining with `updatedSince` is `400`. Index-backed at
  scale: the filter resolves to mailbox ids via a new expression index
  (migration 0014) and runs one bounded keyset descent per mailbox
  (`unnest` + `LATERAL` top-N merge, O(mailboxes × page)) — never an offset
  scan, a full-domain sort, or a full-tenant walk. Counts stay on the
  precomputed `message_counters` rows (no live `COUNT(*)`).
- `GET /api/v1/messages/:id` and `GET /api/v1/messages/:id/attachments` no
  longer read inline attachment bodies from the database just to discard them
  at serialization: the envelope attachment projection is metadata-only
  (`content_base64` was already never on the wire; `body.available` is now
  derived from a presence flag). Cuts multi-MB detoasts per detail read for
  postgres-stored attachments (~20ms → ~0.3ms with 2×3MB attachments).
- **Breaking operator executable boundary:** the private `platform-mailery`
  client is now remote API-only. Its former local `doctor`, `migrate`, and
  `ses-worker` commands moved to the explicitly deployment-scoped
  `platform-mailery-runtime` executable. Production compose/ECS and repository
  migration workflows use that runtime surface. The existing
  `platform-mailery-migrate` bin remains as a runtime-scoped compatibility
  alias. The public `mailery` CLI no longer inspects or deletes historical
  local config files during client construction or command startup.
- Subscription qualifying-credit provenance is now isolated per paid invoice
  grant, rather than pooled per subscription. Payment refunds, failed-operation
  refunds, and invalid-plan quarantine therefore cannot reuse a later invoice's
  entitlement capacity or affect another subscription/credit-pack origin.
- **The Free tier is live (PIVOT D-4).** A fresh verified tenant now mints API
  keys (`POST /api/v1/api-keys`) **without a subscription or credits** —
  `402 billing_required` moved to the PAID surface (the custom-domain routes)
  and its `details` now carry an upgrade `hint`. The free plan is 1 mailbox on
  the shared subdomain + 3,000 sends per UTC calendar month:
  - `GET /api/v1` exposes the deployment's shared free-tier domain as
    `shared_domain` (`MAILERY_SHARED_DOMAIN`; `null` when unconfigured).
  - `POST /api/v1/mailboxes`: free-plan tenants must place their single
    mailbox on the shared subdomain (`402 free_plan_shared_domain_required`;
    `503 shared_domain_not_configured` when the server has no shared subdomain).
    Shared-domain addresses are unique across ALL tenants
    (`409 mailbox_address_taken`) and always use Mailery's hosted mail plane.
  - `POST /api/v1/messages/send`: sends from the shared subdomain skip the
    per-tenant domain-readiness gates; free-plan sends draw from the monthly
    allowance first, then any purchased credit balance, and return
    `402 send_limit_reached` (with an upgrade `hint`) at the cap. The
    allowance never rolls over.
  - Message operations (upload, bulk, flags, labels, delete) and the
    credit-metered Groq features (parse, digest) pass the billing gate for
    free-plan tenants; parse/digest still require credits
    (`402 insufficient_credits` at 0 balance).
- `POST /api/v1/digests/generate` is now idempotent per (tenant, window, UTC
  day) end-to-end: a same-day repeat returns the stored digest without
  re-running the model or re-billing (the credit charge was already
  day-idempotent), and the route is rate-limited per tenant (60/hour, `429`).
- Added product feedback: `POST /api/v1/feedback` (submit) and
  `GET /api/v1/feedback` (list your own). Any authenticated tenant actor (user
  session or API key) may use it — deliberately **not** billing-gated. Submissions
  are persisted tenant-scoped in Postgres via the `@hasna/feedback` SDK store
  (new `feedback` table + `tenant_isolation` RLS policy). Rate-limited to 30 per
  tenant and 60 per client IP per hour. Accepts an optional message `context`
  and a best-effort client PNG `screenshot` (decoded, 6 MB size-capped, stored
  by reference — never embedded in the row; a bad image never blocks the text).
  Surfaced through the CLI feedback command and the authenticated API.
- Unknown non-API paths now return HTTP `404` (previously they served the
  landing shell with `200`). Unknown `/api/*` and `/webhooks/*` paths keep the JSON
  `not_found` error shape. Operators: point uptime monitors and bookmarks at
  `/`, `/docs`, or `/health` — arbitrary paths such as `/favicon.ico` or
  `/index.html` no longer return `200`.
- Retired app routes issue permanent `308` redirects to `/`, preserving the
  query string for legacy checkout returns and emailed reset/verify links. All
  other unknown paths remain honest `404`s.
- `402 billing_required` error details now include a `past_due_recovery`
  object when the tenant's newest subscription is `past_due`, naming the two
  recovery paths: fix payment via `POST /api/v1/billing/portal`, or cancel and
  continue spending a qualifying credit balance. `past_due` itself remains
  non-entitling; a positive qualifying credit balance keeps access until it
  reaches `0`.
- `POST /api/v1/api-keys` now enforces the verified-email requirement directly
  at the mint route (previously enforced only transitively through the billing
  gate). No observable change under production defaults; credit-entitled
  (including trusted-signup) tenants are now explicitly covered.
- `POST /api/v1/auth/password/forgot` returns its uniform `200` immediately;
  token issuance and email delivery run asynchronously after the response, so
  response timing no longer distinguishes real accounts from unknown emails.
  Body, status, and rate limits are unchanged.
- Stripe checkout returns land on `/?checkout=success|cancel`; owner billing
  state is available through `GET /api/v1/billing/overview` and the CLI billing
  command.
- Docs: published per-endpoint rate limits, credit-pack pricing, and the API
  versioning/deprecation policy in `docs/API.md`.

## 0.1.x

- Initial public `v1` surface: auth and sessions, scoped API keys, billing
  (credits, subscription, Stripe checkout/portal/webhook), mailboxes,
  messages (list/search/delta/bulk/labels/tombstones), attachments, digests,
  tenant domain lifecycle, provider webhooks, and platform admin routes.
