Skip to content

Commit 3873798

Browse files
nicklaunchesclaude
andcommitted
Sign SES calls with fetch instead of the AWS SDK
The SDK cannot send from workerd. Every send() threw "[unenv] fs.readFile is not implemented yet!" out of loadSharedConfigFiles reaching for ~/.aws/config. Explicit region and credentials do not avoid it: the client's node runtime config registers file-backed lazy providers for defaultsMode, retryMode, useDualstackEndpoint and more, and the first send() resolves all of them. Pinning today's list would break again the next time the SDK adds one, silently, because a failed send is caught and logged rather than raised. aws4fetch signs a plain fetch with SubtleCrypto and touches no Node API, so it works by construction rather than by configuration. SES v2 SendEmail takes the same JSON body the SDK command did, so the payload is unchanged and so is every caller. Non-2xx now throws with the response body, because SES puts the useful part (MessageRejected, AccessDeniedException and the ARN it wanted) there. Drops the largest dependency in the bundle: 20k fewer lines, 19.4MB upload against 4.4MB gzipped, and no shared-config loader left in the output. This was the second half of the silence. The first half, fixed in 5316698, is why nobody ever saw this error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TytxW7wT52Miy394hDeh9V
1 parent a4a01e8 commit 3873798

3 files changed

Lines changed: 72 additions & 55 deletions

File tree

next-app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,12 @@
2626
},
2727
"dependencies": {
2828
"@auth/drizzle-adapter": "^1.11.3",
29-
"@aws-sdk/client-sesv2": "^3.1041.0",
3029
"@modelcontextprotocol/sdk": "1.29.0",
3130
"@modelcontextprotocol/server": "2.0.0-beta.5",
3231
"@react-email/components": "^1.0.12",
3332
"@react-email/render": "^2.0.8",
3433
"agents": "^0.20.0",
34+
"aws4fetch": "^1.0.20",
3535
"clsx": "^2.1.1",
3636
"drizzle-orm": "^0.45.2",
3737
"lucide-react": "^1.11.0",

next-app/pnpm-lock.yaml

Lines changed: 3 additions & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

next-app/src/lib/email/ses.ts

Lines changed: 68 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
1+
import { AwsClient } from "aws4fetch";
22

33
/**
44
* @file The only place this app talks to AWS SES.
@@ -9,41 +9,40 @@ import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
99
* `AWS_SES_*` aliases used there. This app has no other AWS integration, so a
1010
* SES-specific namespace would be ceremony around a single consumer.
1111
*
12-
* The client is lazy and cached because SESv2Client resolves credentials on
13-
* construction. Building it at module load would make importing this file
14-
* (which every email template path does, transitively) fail in any environment
15-
* without AWS configured, including `pnpm emails:render`.
12+
* ## Why this is SigV4 over `fetch` and not `@aws-sdk/client-sesv2`
1613
*
17-
* Nothing above this module speaks AWS casing: `SesHeader` is lowercase and is
18-
* mapped to the SDK's `MessageHeader` at the call site.
19-
*/
20-
21-
let _client: SESv2Client | null = null;
22-
23-
/**
24-
* Lazily creates and caches an SESv2 client.
14+
* The SDK cannot send from workerd. Every `send()` throws
15+
* `[unenv] fs.readFile is not implemented yet!`, from
16+
* `loadSharedConfigFiles` trying to read `~/.aws/config`. Passing explicit
17+
* `region` and `credentials` does not avoid it: the client's node runtime
18+
* config registers file-backed lazy providers for `defaultsMode`, `retryMode`,
19+
* `useDualstackEndpoint` and others, and the first `send()` resolves all of
20+
* them. There is no combination of constructor options that reliably turns
21+
* that off, and pinning today's list would break again the next time the SDK
22+
* adds a provider — silently, because a failed send is caught and logged.
2523
*
26-
* Explicit credentials are used when both `AWS_ACCESS_KEY_ID` and
27-
* `AWS_SECRET_ACCESS_KEY` are present; otherwise the SDK falls back to its
28-
* default provider chain (instance role, SSO profile, and so on).
24+
* That is not a hypothetical. It is what shipped: the app sent nothing in
25+
* production for its entire life, and the error was invisible because the
26+
* notification promise was cancelled before it could throw. See rule 1 in
27+
* `notify.ts` for that half of the story.
2928
*
30-
* @returns A process-local SESv2 client singleton.
31-
* @throws When no region is configured.
29+
* `aws4fetch` signs a plain `fetch` with SubtleCrypto and touches no Node API,
30+
* so it works on workerd by construction rather than by configuration. It also
31+
* removes the largest dependency in the bundle. The cost is that this file now
32+
* owns the request shape: SES v2 `SendEmail` takes the same JSON body the SDK
33+
* command did, so the payload below is the SDK's input verbatim.
34+
*
35+
* The client is built per call. It holds no I/O object, only strings and a
36+
* derived-key cache, so a module-level singleton would be safe here in a way
37+
* the database handle is not — but it would also be the same shape this repo
38+
* has been bitten by twice, for a saving of nothing on a few sends a day.
39+
* Building it per call also means credentials are read at call time, which is
40+
* what makes `pnpm emails:render` and the unit tests importable without AWS
41+
* configured at all.
42+
*
43+
* Nothing above this module speaks AWS casing: `SesHeader` is lowercase and is
44+
* mapped to the wire format at the call site.
3245
*/
33-
export function getSesClient(): SESv2Client {
34-
if (_client) return _client;
35-
const region = process.env.AWS_REGION;
36-
if (!region) {
37-
throw new Error("AWS_REGION is not set");
38-
}
39-
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
40-
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
41-
_client = new SESv2Client({
42-
region,
43-
...(accessKeyId && secretAccessKey ? { credentials: { accessKeyId, secretAccessKey } } : {}),
44-
});
45-
return _client;
46-
}
4746

4847
/**
4948
* A single custom header to attach to an outgoing message.
@@ -94,16 +93,45 @@ export type SesEmail = {
9493
*/
9594
const CONFIGURATION_SET = "builders-backlinks";
9695

96+
/**
97+
* Builds a SigV4-signing `fetch` for SES in the configured region.
98+
*
99+
* @throws When the region or either credential is missing. Unlike the SDK,
100+
* there is no ambient provider chain to fall back to, so an incomplete
101+
* configuration has to be an error rather than a slow discovery.
102+
*/
103+
function getSesClient(): { client: AwsClient; region: string } {
104+
const region = process.env.AWS_REGION;
105+
if (!region) {
106+
throw new Error("AWS_REGION is not set");
107+
}
108+
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
109+
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
110+
if (!accessKeyId || !secretAccessKey) {
111+
throw new Error("AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must both be set");
112+
}
113+
return { client: new AwsClient({ accessKeyId, secretAccessKey, region, service: "ses" }), region };
114+
}
115+
97116
/**
98117
* Sends one email through SES.
99118
*
100119
* Deliberately dumb: no rendering, no preference logic, no error swallowing.
101120
* `sendEmail` owns all of that, and keeping this function boring is what makes
102121
* it safe to call from a script or a test.
122+
*
123+
* @throws When SES answers with a non-2xx. The body is included in the message:
124+
* SES puts the useful part (`MessageRejected`, `AccessDeniedException`
125+
* and which ARN it wanted) there, and a bare status code would send the
126+
* next person reading a log straight back to the AWS console.
103127
*/
104128
export async function sendSesEmail({ to, from, subject, text, html, headers, emailType }: SesEmail): Promise<void> {
105-
await getSesClient().send(
106-
new SendEmailCommand({
129+
const { client, region } = getSesClient();
130+
131+
const response = await client.fetch(`https://email.${region}.amazonaws.com/v2/email/outbound-emails`, {
132+
method: "POST",
133+
headers: { "content-type": "application/json" },
134+
body: JSON.stringify({
107135
FromEmailAddress: from,
108136
Destination: { ToAddresses: [to] },
109137
ConfigurationSetName: CONFIGURATION_SET,
@@ -126,5 +154,10 @@ export async function sendSesEmail({ to, from, subject, text, html, headers, ema
126154
},
127155
},
128156
}),
129-
);
157+
});
158+
159+
if (!response.ok) {
160+
const body = await response.text().catch(() => "");
161+
throw new Error(`SES returned ${response.status} ${response.statusText}: ${body.slice(0, 500)}`);
162+
}
130163
}

0 commit comments

Comments
 (0)