fix(SES): create missing template instead of failing the send#126
Conversation
The bulk path registers a content-hashed template and, on the first send of any new content, must detect the "template missing" error, create the template, and retry. That recovery never fired: SES API v2 (REST-JSON) returns the exception type in the x-amzn-ErrorType response header, but the base HTTP layer discarded response headers, so errorType() found no type, isTemplateMissing() returned false, and the raw "Template utopia-<hash> does not exist." surfaced to every recipient. Capture response headers in Adapter::request() and read the SES error type from x-amzn-ErrorType, keeping the body __type/code as a fallback. Tighten missing-template detection so a generic BadRequestException only matches when the message signals non-existence. The existing routing test mocked a missing template as a 200 with a per-entry TEMPLATE_NOT_FOUND status — a shape SES never returns for a missing default template (it is a top-level 400), which is why CI stayed green while production failed. Add regression tests for the real shape, covering create-and-retry and the concurrent AlreadyExists race. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greptile SummaryThis PR fixes a 100%-reproducible failure in the SES adapter where the first send of any new message content would error because the template-missing recovery path never fired. The root cause was that SES API v2 signals the exception type in the
Confidence Score: 5/5Safe to merge — the change is additive (a new The fix is narrowly scoped: header capture is additive and does not change existing keys, the No files require special attention. Important Files Changed
Reviews (3): Last reviewed commit: "test(SES): wire SES_* credentials into C..." | Re-trigger Greptile |
requestMulti() now returns a headers key like request(), so callers and static analysis see one consistent response shape across both transport paths. Headers are left empty on the multi path because it builds requests with curl_copy_handle(), which segfaults when the copied handle carries a CURLOPT_HEADERFUNCTION closure; a comment records why, so per-handle capture is not naively reintroduced. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SESTest is gated on SES_ACCESS_KEY/SES_SECRET_KEY/SES_REGION/ SES_TEST_EMAIL, but those were never passed into docker-compose or the Tests workflow, so the live SES test silently skipped everywhere. Pass them through both paths: credentials and the optional session token as repo secrets, region and the verified test identity as repo variables (mirroring RESEND_TEST_EMAIL). The test stays skipped until a maintainer adds the secrets/vars in repo settings; nothing breaks meanwhile, since unset values resolve to empty strings and SESTest::setUp marks the test skipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Problem
Sending email through the SES adapter fails on the first send of any new message content with:
even with a fully-permissioned IAM user (
ses:*). Reported from a real Cloud project; the error reproduces 100% of the time for first-time content.Root cause
The no-attachment path is template-based (
SES.php): it sends viaSendBulkEmailreferencing a content-hashed template (utopia-<hash>). On the first send that template does not exist yet, so the adapter is designed to detect the "template missing" error →CreateEmailTemplate→ retry.That recovery never fired. SES API v2 uses the AWS REST-JSON protocol, which returns the exception type (
BadRequestException) in thex-amzn-ErrorTyperesponse header — the JSON body is only{"message":"Template ... does not exist."}. But the baseAdapter::request()usedCURLOPT_RETURNTRANSFERonly and discarded every response header. So:errorType()read the body, found no__type, and returnednullisTemplateMissing()returnedfalseVerified against the live endpoint — an unsigned SES request returns the type in the header (
x-amzn-errortype: MissingAuthenticationTokenException), confirming the body never carries it.Why CI stayed green
The routing test mocked a missing template as a
200with a per-entryTEMPLATE_NOT_FOUNDstatus — a shape SES never returns for a missing default template (that case is always a top-level400). The test and the code shared the same wrong assumption, so they agreed with each other and disagreed with SES.Fix
Adapter::request()— capture response headers viaCURLOPT_HEADERFUNCTION(lowercased keys) and expose them under a new additiveheaderskey. No other adapter is affected.SES::errorType()— read the type fromx-amzn-ErrorTypefirst, keeping the body__type/codeas a fallback for AWS JSON-protocol responses.SES::isTemplateMissing()— require the message to signal non-existence ("does not exist" / "not found") so an unrelatedBadRequestExceptionmentioning a template is not misclassified.Tests
400+x-amzn-ErrorTypeheader): the create-and-retry recovery and the concurrentAlreadyExistsExceptionrace. Both fail without the fix (1 request, recipient failure) and pass with it (3 requests, delivered).headerskey.Verification
request-catcher)composer lint(Pint, PSR-12): passcomposer analyse(PHPStan level 6):[OK] No errors🤖 Generated with Claude Code