fix(self-hosted): honest send outcomes, real per-provider SES credentials, and a reconciliation path for uncertain sends#55
Merged
Conversation
…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.
Contributor
Author
CI on this PR head (
|
| 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
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.
This was referenced Jul 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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+88c8381are cherry-picked unchanged; their merge base was already currentorigin/main(1ac2d15), so there was nothing to rebase.PR #53's
verifyjob was red and is fixed here.scripts/generate-selfhost-sdk.tsanchored its nullable-messagepatches on whole interface literals, so the newreason/provider_error/sentfields onSendMessageErrorstopped the needle matching and the generator threwgenerated SDK shape changed; nullable send failure message was not injected. The patches are now anchored on the interface name plus the property, andsrc/selfhost.tsis regenerated and committed.2. Genuine per-provider credentials (cause 2)
buildSelfHostedSenderhardcodedaccess_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.EMAILS_SES_ACCESS_KEY_ID+EMAILS_SES_SECRET_ACCESS_KEYwhen 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 genericAWS_ACCESS_KEY_IDwould re-point the entire AWS SDK default chain in the container (S3 inbound, SQS ingest), not just SES.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.SESAdapterused to pairprovider.access_keywith the ambientAWS_SECRET_ACCESS_KEYwhen 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/updatevalidate 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 printedProvider credentials are invalid: Could not load credentials from any providers. Reproduced onmainand asserted gone.providersresource has no credential columns and the values were being dropped silently on the wire.EMAILS_SES_CONFIGURATION_SETis threaded intoSendEmailCommand, so SES metrics are attributable in a shared account.emails send --providerwas parsed and then discarded in both modes. Now honoured locally, explicitly rejected in self_hosted mode.auth/mailer.tsclaiming 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.3. Both directions of honesty, as tests
src/server/self-hosted/send-honesty-and-reconciliation.integration.test.tsasserts each direction over the whole response — status class,sent, presence of anerrorkey,retry_safe, the ledger row, and what the CLI's own serializer renders — not one field:emails logrendering it assent.MessageRejected(the literal incident error), indeterminate network failure, policy block; plusemails log/--jsonrenderingfailed, never blank.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 onsend_state = 'uncertain'(a proven outcome is never overwritten, a live send is never raced); assertingsentrequires 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.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.
bunx tsc --noEmitorigin/main: 2059 pass / 0 failbun run buildgit diff --exit-code -- src/selfhost.tsno-cloud:source/no-cloud:packgit diff --checkpostgres.integrationmessage-id-resolution.integrationrls.integrationsend-failure-semantics.integrationsend-honesty-and-reconciliation.integration(new)multi-tenancy.integrationPre-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 tenantfails identically onorigin/main(1ac2d15) — verified by running it in a detached worktree at that commit against the same database.container-runtimejob (trivy HIGH/CRITICAL on the patched Bun base) is red onmaintoo and is not touched here.6. Fails-before / passes-after evidence
Verified by copying the new tests onto a detached worktree at
origin/mainand at PR #53's branch:src/providers/ses-credentials.test.ts— onmain:SyntaxError: Export named 'resolveSesCredentials' not found. Here: 7 pass.sender.test.tscredential block — onmain:SyntaxError: Export named 'classifyProviderSendError' not found. Here: 13 pass.db/providers.test.tscredential refusals — onmain: 3 fail (expect(received).toThrow(expected)— credentials were silently accepted and dropped).cli/commands/provider.test.ts › refuses SES credentials— onmainthe received message is literallyProvider credentials are invalid: Could not load credentials from any providers. Provider was not saved.Here it namesEMAILS_SES_ACCESS_KEY_ID/EMAILS_SES_SECRET_ACCESS_KEYand echoes no credential value.cli/commands/provider.test.ts › registers credential-free metadata— onmainthis hangs, becauseprovider addvalidated the stored credential-free row against a live AWS call. That hang is the defect.send-honesty-and-reconciliation.integration.test.ts— onmain: 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 aC: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
secretsblock by ARN.@hasna/maileryvs@hasna/emailspackage-identity mismatch is untouched (ci.ymlstill 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:
EMAILS_SES_*?", whileSESAdapterindependently falls back to a staticAWS_*pair fromprocess.env. That is the same category of defect as the incident. Fixed by resolving the label throughresolveSesCredentials— the function the adapter signs with — which also surfaces a malformed pair at boot instead of at first send (getAdapterbuilds lazily).reconcilecould 500 on a malformed id.getMessagehits auuidcolumn directly. Routed throughresolveMessageIdOrError, matching every other message route: short prefixes resolve, unknown ids 404, ambiguous ids 409. Test added.count: 0and gets 404, and the row is unchanged.not_sent→failed(excluded from the outbound quota window),sent→sent(included). MatchesmarkSendFailed/completeSendIntent.AKIA-AMBIENT,provider-secret) matched. The refusal error names variables, never values, and tests assert the supplied secret is not echoed. NoCo-Authored-Bytrailer.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.