Skip to content

fix(self-hosted): honest send outcomes, real per-provider SES credentials, and a reconciliation path for uncertain sends#55

Merged
andrei-hasna merged 6 commits into
mainfrom
fix/send-semantics-and-provider-creds
Jul 25, 2026
Merged

fix(self-hosted): honest send outcomes, real per-provider SES credentials, and a reconciliation path for uncertain sends#55
andrei-hasna merged 6 commits into
mainfrom
fix/send-semantics-and-provider-creds

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Supersedes #53 (its two commits are included verbatim as the first two commits here) and adds the two causes that PR left untouched, plus the reconciliation path.

1. Error semantics, up to date with origin/main (criterion 3)

PR #53's commits a4ae6d9 + 88c8381 are cherry-picked unchanged; their merge base was already current origin/main (1ac2d15), so there was nothing to rebase.

PR #53's verify job was red and is fixed here. scripts/generate-selfhost-sdk.ts anchored its nullable-message patches on whole interface literals, so the new reason/provider_error/sent fields on SendMessageError stopped the needle matching and the generator threw generated SDK shape changed; nullable send failure message was not injected. The patches are now anchored on the interface name plus the property, and src/selfhost.ts is regenerated and committed.

2. Genuine per-provider credentials (cause 2)

buildSelfHostedSender hardcoded access_key: null, secret_key: null, so the SES client always fell through to the AWS default chain — the deployment IAM role — no matter what was configured.

  • Server: signs with EMAILS_SES_ACCESS_KEY_ID + EMAILS_SES_SECRET_ACCESS_KEY when both are present, deployment IAM role otherwise (unchanged default for OSS self-hosters). Half a pair is a hard startup error. The names are scoped on purpose — injecting the generic AWS_ACCESS_KEY_ID would re-point the entire AWS SDK default chain in the container (S3 inbound, SQS ingest), not just SES.
  • A secret-free boot line now reports which identity outbound mail is signed with (provider=ses credentials=environment|ambient_aws_env|deployment_role), resolved through the same function the adapter signs with, so the reported identity cannot drift from the real one.
  • Adapter: SESAdapter used to pair provider.access_key with the ambient AWS_SECRET_ACCESS_KEY when only one half was present — signing with an identity belonging to neither source. Resolution is now one exported rule (resolveSesCredentials): a provider pair is used exclusively, a partial pair is rejected, only a complete ambient pair (with session token) is a fallback.
  • provider add/update validate WITH the supplied credentials, before anything is persisted — the old order created the row, validated the stored (credential-free) row against the CLI machine's ambient chain, and printed Provider credentials are invalid: Could not load credentials from any providers. Reproduced on main and asserted gone.
  • Against a self-hosted server the credential flags are now refused with an error naming the server-side variables to set instead, and no row is created — because the self-hosted providers resource has no credential columns and the values were being dropped silently on the wire.
  • Optional EMAILS_SES_CONFIGURATION_SET is threaded into SendEmailCommand, so SES metrics are attributable in a shared account.
  • emails send --provider was parsed and then discarded in both modes. Now honoured locally, explicitly rejected in self_hosted mode.
  • Corrected the comment in auth/mailer.ts claiming the sender "already targets" a specific SES account via the deployment role. It does not, and there is no cross-account assume-role anywhere in this codebase.

This PR does not put any credential value in the repo, in a task definition environment block, or in a log. Where the values go (Secrets Manager → ECS secrets by ARN) is deployment work, not code.

3. Both directions of honesty, as tests

src/server/self-hosted/send-honesty-and-reconciliation.integration.test.ts asserts each direction over the whole response — status class, sent, presence of an error key, retry_safe, the ledger row, and what the CLI's own serializer renders — not one field:

  • A. provider accepted ⇒ nothing may read as failure: fresh success, provider-accepted-then-ledger-write-failed, idempotent replay; plus emails log rendering it as sent.
  • B. provider rejected ⇒ nothing may read as sent: SES sandbox MessageRejected (the literal incident error), indeterminate network failure, policy block; plus emails log/--json rendering failed, never blank.
  • Client seam: SelfHostedMailDataSource.send() resolves for every accepted path and throws for reject / indeterminate.

4. Reconciliation for uncertain (criterion 6)

Seven rows sat in send_state = 'uncertain' with no command able to list them and none able to resolve them.

  • GET /v1/messages/send-intents/uncertain — exactly the rows whose outcome was never established.
  • POST /v1/messages/send-intents/reconcile — close out one row against evidence. Guarded on send_state = 'uncertain' (a proven outcome is never overwritten, a live send is never raced); asserting sent requires the provider message id that proves it; the evidence note is mandatory; outcome + evidence + timestamp + resolving principal are persisted on the row; ids resolve through the same prefix/404/409 path as every other message route; tenant-scoped.
  • CLI: emails send-intent uncertain, emails send-intent reconcile <id> --outcome sent|not-sent [--provider-message-id …] --evidence "…".

Nothing infers an outcome and nothing is bulk-applied.

5. Real test results

Environment: Bun 1.3.14, Postgres 16.4 in a dedicated container.

check result
bunx tsc --noEmit clean
local-mode suite (CI env-scrubbing replicated) 2088 pass / 0 fail / 108 skip (2196 across 210 files). Baseline on origin/main: 2059 pass / 0 fail
bun run build ok
regenerate SDK + git diff --exit-code -- src/selfhost.ts in sync
canonical package identity assertion ok
no-cloud:source / no-cloud:pack 6 pass / 0 fail, no hosted markers
git diff --check clean
postgres.integration 15 pass / 0 fail
message-id-resolution.integration 5 pass / 0 fail
rls.integration 10 pass / 0 fail
send-failure-semantics.integration 6 pass / 0 fail
send-honesty-and-reconciliation.integration (new) 17 pass / 0 fail
multi-tenancy.integration 32 pass / 1 failpre-existing

Pre-existing failures, reported honestly and NOT caused by this PR:

  • multi-tenancy.integration.test.ts › tenant-scoped key issuance (WI-2e) › an owner mints a tenant key that resolves back to the same tenant fails identically on origin/main (1ac2d15) — verified by running it in a detached worktree at that commit against the same database.
  • CI's container-runtime job (trivy HIGH/CRITICAL on the patched Bun base) is red on main too and is not touched here.

6. Fails-before / passes-after evidence

Verified by copying the new tests onto a detached worktree at origin/main and at PR #53's branch:

  • src/providers/ses-credentials.test.ts — on main: SyntaxError: Export named 'resolveSesCredentials' not found. Here: 7 pass.
  • sender.test.ts credential block — on main: SyntaxError: Export named 'classifyProviderSendError' not found. Here: 13 pass.
  • db/providers.test.ts credential refusals — on main: 3 fail (expect(received).toThrow(expected) — credentials were silently accepted and dropped).
  • cli/commands/provider.test.ts › refuses SES credentials — on main the received message is literally Provider credentials are invalid: Could not load credentials from any providers. Provider was not saved. Here it names EMAILS_SES_ACCESS_KEY_ID / EMAILS_SES_SECRET_ACCESS_KEY and echoes no credential value.
  • cli/commands/provider.test.ts › registers credential-free metadata — on main this hangs, because provider add validated the stored credential-free row against a live AWS call. That hang is the defect.
  • send-honesty-and-reconciliation.integration.test.ts — on main: fails at import. On PR fix(self-hosted): 502 la trimiteri externe deși mesajul pleacă — semantică corectă de eșec + idempotență la retry #53's branch: 8 pass / 8 fail, and every one of the 8 failures is a C: reconciliation case, i.e. exactly this PR's delta beyond fix(self-hosted): 502 la trimiteri externe deși mesajul pleacă — semantică corectă de eșec + idempotență la retry #53.

7. What this PR deliberately does not do

  • No AWS/Secrets Manager/ECS change, no publish, no deploy. Cause 1 (wrong AWS account) is unblocked by the code here but is closed by putting the alumia key pair into Secrets Manager and referencing it from the task definition secrets block by ARN.
  • No reconciliation of the seven live production rows — this PR ships the mechanism; running it against production with per-message evidence is a separate, evidence-recorded step.
  • The @hasna/mailery vs @hasna/emails package-identity mismatch is untouched (ci.yml still enforces @hasna/mailery); it must be resolved before any npm publish.

8. Adversarial self-review (no second reviewer available in this session)

Findings raised and fixed during the pass, not after:

  1. Reported vs real identity could drift. The boot label was first derived from "did we set EMAILS_SES_*?", while SESAdapter independently falls back to a static AWS_* pair from process.env. That is the same category of defect as the incident. Fixed by resolving the label through resolveSesCredentials — the function the adapter signs with — which also surfaces a malformed pair at boot instead of at first send (getAdapter builds lazily).
  2. reconcile could 500 on a malformed id. getMessage hits a uuid column directly. Routed through resolveMessageIdOrError, matching every other message route: short prefixes resolve, unknown ids 404, ambiguous ids 409. Test added.
  3. Cross-tenant reconciliation. Explicitly tested: another tenant sees count: 0 and gets 404, and the row is unchanged.
  4. Quota semantics. not_sentfailed (excluded from the outbound quota window), sentsent (included). Matches markSendFailed / completeSendIntent.
  5. Secret leakage. Staged diff scanned for credential patterns before commit; only test placeholders (AKIA-AMBIENT, provider-secret) matched. The refusal error names variables, never values, and tests assert the supplied secret is not echoed. No Co-Authored-By trailer.
  6. Behaviour change flagged: a local SQLite provider row carrying only one half of a key pair used to work by borrowing the ambient secret; it now fails with an explicit error. That is intentional — the old behaviour signed with a mixed identity — and is called out in the CHANGELOG.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…them as generic 502

Incident 2026-07-25: SES sandbox MessageRejected (any external recipient) was
swallowed by a bare catch, marked 'uncertain', and answered with a generic 502
'send outcome is uncertain'. Operators retried and duplicated real client mail;
the CLI log rendered the never-sent rows identically to delivered ones.

Server (/v1/messages/send):
- classify the thrown provider error (classifyProviderSendError): a received
  4xx means the provider REFUSED the request, so nothing was sent
- definitive reject -> 422 + real provider error + reason=provider_rejected +
  sent:false + retry_safe:true; ledger row -> new send_state 'failed'
  (terminal, no reconciliation, excluded from quota usage, cancellable)
- retrying the same idempotency_key after a definitive reject re-arms the SAME
  ledger row (rearmFailedSendIntent) — never a duplicate message
- provider success + ledger finalization failure -> 202 with sent:true,
  provider_message_id, and an explicit do-NOT-retry warning: a successful send
  must never present as an error (that is what caused the triple-send)
- indeterminate failure (network/5xx) stays 502 but names the provider error,
  sets sent:null, and states the outcome is unknown; every provider send error
  is now logged instead of discarded
- OpenAPI: 422 response, sent/provider_message_id/warning fields, failed state

CLI:
- emails log --json serializes to, cc, cc_addresses, status, send_state
  (to/cc read as null during the incident); table gains a highlighted Status
  column so uncertain/failed can never render as delivered
- emails send relays the server's error detail and prints the
  sent-but-finalization-failed warning

Tests: send-failure-semantics.integration.test.ts (real Postgres, full request
pipeline) locks all five incident criteria; sender.test.ts covers the error
classifier; email-log.remote.test.ts + data-source tests cover the CLI
serialization; the pre-existing inbound finalization-failure test updated to
the corrected contract.
…r-accepted response; CLI shows the real provider id and per-message status

Iteration 2 on the 2026-07-25 incident branch. The core invariant — a
successful send must NEVER present as a failure — is now checkable in ONE
place on every response where the provider accepted the message:

Server (/v1/messages/send):
- fresh success (202), idempotent replay of a sent intent (200), and the
  post-send finalization-failure path (202 + warning) all carry top-level
  sent: true and provider_message_id
- a rejected intent whose retry cannot be re-armed answers 503 with
  sent: false, reason: rearm_failed, retry_safe: true (and logs the error)
  instead of falling through to a generic 500 that hides the fact that
  nothing was sent — the exact ambiguity that caused the incident retries
- OpenAPI: 503 response documented; sent/provider_message_id descriptions
  updated to cover all provider-accepted paths

CLI:
- emails send resolves Message ID as top-level provider_message_id, then
  the record's provider_message_id, then message_id, then the ledger id —
  it printed the ledger row id for self-hosted sends before
- emails show (single-message view) now prints the ledger Status line,
  highlighted when not delivered, so an uncertain/failed row can no longer
  read as delivered anywhere in the CLI (list already showed it)

Tests (fail on the previous commit, pass here): integration — top-level
sent/provider_message_id on fresh success and replay, 503 rearm-outage
contract with same-row retry afterwards; unit — provider-id preference in
the data source, Status line in the detail view, 503 in the OpenAPI
contract. Full suite: 2147 pass / 2 skip / 1 fail — the single failure
(multi-tenancy key issuance) reproduced unchanged on the origin/main base
commit 1ac2d15 in a pristine worktree (pre-existing, unrelated).
…about them

Cause 2 of the 2026-07-25 incident. `buildSelfHostedSender` hardcoded
`access_key: null, secret_key: null`, so the SES client ALWAYS fell through to
the AWS default chain — the deployment IAM role — regardless of any configured
provider. `emails provider add --type ses --access-key … --secret-key …` then
reported "Provider credentials are invalid: Could not load credentials from any
providers", which was false twice over: the client had stripped the credentials
before the request, and the server has no credential columns to store them in.

Server side
- the self-hosted sender signs with EMAILS_SES_ACCESS_KEY_ID +
  EMAILS_SES_SECRET_ACCESS_KEY when both are present, and with the deployment
  IAM role otherwise (unchanged default for existing self-hosters);
- half a pair is a hard startup error, never completed from the ambient chain;
- the names are scoped on purpose: the generic AWS_* names would re-point every
  AWS client in the process (S3 inbound, SQS ingest), not just SES;
- a secret-free boot line reports WHICH identity outbound mail is signed with,
  resolved through the same function the adapter signs with so the reported
  identity cannot drift from the real one;
- optional EMAILS_SES_CONFIGURATION_SET is threaded into SendEmailCommand so SES
  metrics are attributable in a shared account.

Provider adapter
- SESAdapter paired `provider.access_key` with the AMBIENT
  AWS_SECRET_ACCESS_KEY when only one half was present, signing with an identity
  belonging to neither source. Credential resolution is now one exported rule:
  a provider pair is used exclusively, a partial pair is rejected, and only a
  complete ambient pair (with session token) is used as a fallback.

Client / CLI
- `provider add`/`update` refuse credential flags against a self-hosted server
  with an error naming the server-side variables, and never create a row;
- validation runs against the credentials the operator actually supplied, before
  anything is persisted, instead of validating the stored credential-free row
  against this machine's ambient AWS chain;
- when there is nothing to validate the command says so instead of implying a
  check happened;
- `emails send --provider` was parsed and discarded in both modes: it is now
  honoured locally and rejected explicitly in self_hosted mode.

Also corrects the comment in auth/mailer.ts asserting the sender "already
targets" a specific SES account via the deployment role — it does not, and there
is no cross-account assume-role anywhere in this codebase.

Also unbreaks `scripts/generate-selfhost-sdk.ts`, whose nullable-`message`
patches were anchored on whole interface literals, so adding a sibling field to
a response schema broke SDK generation and the `verify` CI job.
Criterion 6 of the 2026-07-25 incident. Seven messages sat in
`send_state = 'uncertain'` — the state that means "this may or may not have
left" — with no command able to list them and no command able to resolve them,
so they stayed visually identical to delivered mail.

- GET  /v1/messages/send-intents/uncertain — enumerate exactly the rows whose
  outcome was never established (a proven send or a proven reject is excluded).
- POST /v1/messages/send-intents/reconcile — close out ONE row against evidence
  the operator gathered from the provider. Guarded on `send_state = 'uncertain'`
  so a proven outcome is never overwritten and a live send is never raced;
  asserting `sent` requires the provider message id that proves it; the evidence
  note is mandatory; outcome, evidence, timestamp and the resolving principal
  are persisted on the row. Ids resolve through the same prefix/404/409 path as
  every other message route.
- CLI: `emails send-intent uncertain` and
  `emails send-intent reconcile <id> --outcome sent|not-sent --evidence …`,
  refusing to run in local mode (which has no send-intent ledger).

Nothing here infers an outcome and nothing is bulk-applied: reconciling a batch
of unknown outcomes in one stroke is how they became indistinguishable in the
first place.

Tests: a new Postgres integration suite pins BOTH honesty directions as
whole-response invariants — a provider-accepted message may not read as a
failure on any path (fresh success, broken ledger finalization, idempotent
replay), and a provider-rejected or indeterminate one may not read as sent —
plus what `emails log` and `emails log --json` render for each, and the full
reconciliation contract including tenant scoping.
The selfhost-postgres job ran four sequential `bun test` calls under `set -e`,
so it aborted at the first red suite. The send-failure-semantics suite added
alongside the 2026-07-25 fix was therefore never executed in CI at all, and the
new send-honesty/reconciliation suite would have been invisible too. All six now
run in one invocation: the job still fails on any failure, but reports every
suite instead of stopping at the first.
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

CI on this PR head (c527d08) vs main (1ac2d15)

job this PR main verdict
verify success success PR #53's verify regression (SDK generator) is fixed
selfhost-postgres failure failure pre-existing, identical single test
container-runtime failure failure pre-existing (trivy HIGH/CRITICAL on the patched Bun base)
validate (terraform-aws-validate) failure failure pre-existing — red on main on 2026-07-21 and 2026-07-22; no terraform touched here

selfhost-postgres detail, now that all six suites actually run in one invocation:

(fail) tenant-scoped key issuance (WI-2e) > an owner mints a tenant key that resolves back to the same tenant
 85 pass
 1 fail
Ran 87 tests across 6 files.

Byte-identical to the local run against a dedicated Postgres 16.4 container, and to main when that one test is run at 1ac2d15. Before the ci.yml change the job aborted at that test under set -e, so the send-semantics suites (including the one PR #53 added) had never executed in CI.

This PR introduces no new CI failure.

…ccess-key-only SES rows

Review findings from the adversarial pass on this PR.

1. A message that provably left was parked as `send_state = 'uncertain'`
   with `provider_message_id = NULL`. `emails log` renders that row in the
   not-delivered style, and `reconcile --outcome sent` REQUIRES the provider
   message id — so the only outcome an operator could record was `not_sent`,
   filing a delivered message as failed. That is criterion 3 inverted on the
   exact surface the 2026-07-25 incident was about. `markSendUncertain` now
   persists the provider id and a `send_uncertain_reason` note. Both tests
   covering this path asserted the HTTP response only and could not see it;
   they now assert the row and the full reconcile-as-sent round-trip.

2. An access-key-only SES provider row threw `ProviderConfigError` even when
   the ambient pair belonged to the SAME access key id. Rows in that shape
   predate the both-or-neither rule and used to send fine, so refusing them is
   an outage, not a safety win: the identity is named twice, not mixed. Every
   other partial pair is still refused, now with the remediation command in
   the message.

3. `send-failure-semantics.integration.test.ts` embedded a real third party's
   mailbox in a fixture, in a public repo. Replaced with a reserved
   `.example` address; the error string stays shape-faithful.

Verified: fixes are non-tautological (reverting 1 fails the two row
assertions, reverting 2 fails the new ambient-match case). Full suite
2091 pass / 0 fail / 108 skip; six Postgres suites 85 pass / 1 fail, that
one failure reproducing identically at 1ac2d15 on the same database.
@andrei-hasna
andrei-hasna merged commit 175953e into main Jul 25, 2026
1 of 4 checks passed
andrei-hasna added a commit that referenced this pull request Jul 25, 2026
Only CHANGELOG.md conflicted. Resolved by keeping BOTH sides:

- every send-path entry from main (#55, #54, #56) is preserved verbatim and
  kept first — part one wins on the send path;
- this branch's rename-revert and dead-scaffolding entries follow;
- the "BREAKING (aliased): rename @hasna/emails -> @hasna/mailery" entry is
  dropped rather than carried alongside its own revert. That rename never
  shipped — it exists only under [Unreleased] — so recording it and undoing it
  in the same unreleased section would describe a change no consumer can
  observe.

Verified on the merge result: tsc --noEmit clean, bun run build exit 0,
bun test 2101 pass / 0 fail / 108 skip, and the six Postgres suites against a
real PG 16 at 86 pass / 0 fail.
andrei-hasna added a commit that referenced this pull request Jul 25, 2026
Only CHANGELOG.md conflicted. Kept both sides: main's entries (part one #55/#54,
#56, and #57's rename revert) first, then this branch's dead-code removals. The
never-shipped 'BREAKING (aliased): rename' entry is dropped, as it was on #57 —
it is undone in the same unreleased section, so no consumer can observe it.

Verified on the merge result: tsc --noEmit clean, bun run build exit 0,
bun test 1994 pass / 0 fail / 108 skip, six Postgres suites 86 pass / 0 fail.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant