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 */
9594const 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 */
104128export 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