@q32/core 0.1.28 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ses.js ADDED
@@ -0,0 +1,125 @@
1
+ import { awsFetch, createAwsConfigFromEnv } from "./aws.js";
2
+ import { buildRawMimeMessage, formatEmailAddress } from "./email.js";
3
+ export class NoopSesMailer {
4
+ enabled = false;
5
+ async send() {
6
+ throw new Error("SES mailer is not configured.");
7
+ }
8
+ }
9
+ export class WorkerSesMailer {
10
+ config;
11
+ defaults;
12
+ enabled = true;
13
+ constructor(config, defaults) {
14
+ this.config = config;
15
+ this.defaults = defaults;
16
+ }
17
+ send(input) {
18
+ return sendSesEmail(this.config, {
19
+ ...input,
20
+ from: input.from ?? this.defaults.from,
21
+ replyTo: input.replyTo ?? this.defaults.replyTo,
22
+ }, { fetcher: this.defaults.fetcher });
23
+ }
24
+ }
25
+ export function createSesMailerFromEnv(env, options = {}) {
26
+ const config = createAwsConfigFromEnv(env);
27
+ const fromEmail = options.fromEmail ?? env.FROM_EMAIL ?? env.EMAIL_FROM ?? env.NOTIFICATION_FROM_EMAIL ?? env.POSTMARK_FROM_EMAIL ?? env.SES_FROM_EMAIL;
28
+ if (!config || !fromEmail)
29
+ return new NoopSesMailer();
30
+ const replyTo = options.replyTo ?? env.NOTIFICATION_REPLY_TO ?? env.EMAIL_REPLY_TO;
31
+ return new WorkerSesMailer(config, {
32
+ from: { email: fromEmail, name: options.fromName },
33
+ replyTo: typeof replyTo === "string"
34
+ ? [{ email: replyTo }]
35
+ : Array.isArray(replyTo)
36
+ ? replyTo.map((email) => ({ email }))
37
+ : undefined,
38
+ fetcher: options.fetcher,
39
+ });
40
+ }
41
+ export async function sendSesEmail(config, input, options = {}) {
42
+ const hasRawFeatures = Boolean(input.headers && Object.keys(input.headers).length > 0) || Boolean(input.attachments?.length);
43
+ const body = hasRawFeatures ? rawSesBody(input) : simpleSesBody(input);
44
+ const response = await awsFetch(config, `https://email.${config.region}.amazonaws.com/v2/email/outbound-emails`, {
45
+ method: "POST",
46
+ headers: { "content-type": "application/json" },
47
+ body: JSON.stringify(body),
48
+ }, { service: "ses", fetcher: options.fetcher });
49
+ const text = await response.text();
50
+ if (!response.ok)
51
+ throw new Error(`SES send failed: ${response.status} ${text.slice(0, 300)}`);
52
+ const payload = parseJsonObject(text);
53
+ const messageId = stringValue(payload.MessageId) ?? stringValue(payload.messageId) ?? "";
54
+ return { provider: "ses", messageId };
55
+ }
56
+ export async function sesQuery(config, params, options = {}) {
57
+ const body = new URLSearchParams({ Version: "2010-12-01", ...params }).toString();
58
+ const response = await awsFetch(config, `https://email.${config.region}.amazonaws.com/`, {
59
+ method: "POST",
60
+ headers: { "content-type": "application/x-www-form-urlencoded" },
61
+ body,
62
+ }, { service: "ses", fetcher: options.fetcher });
63
+ const text = await response.text();
64
+ if (!response.ok)
65
+ throw new Error(`SES ${params.Action ?? "query"} failed: ${response.status} ${text.slice(0, 300)}`);
66
+ return text;
67
+ }
68
+ export function inboundSesMxHost(region = "us-east-1") {
69
+ return `inbound-smtp.${region}.amazonaws.com`;
70
+ }
71
+ function simpleSesBody(input) {
72
+ return {
73
+ FromEmailAddress: formatEmailAddress(input.from),
74
+ Destination: {
75
+ ToAddresses: input.to.map(formatEmailAddress),
76
+ ...(input.cc?.length ? { CcAddresses: input.cc.map(formatEmailAddress) } : {}),
77
+ ...(input.bcc?.length ? { BccAddresses: input.bcc.map(formatEmailAddress) } : {}),
78
+ },
79
+ ...(input.replyTo?.length ? { ReplyToAddresses: input.replyTo.map(formatEmailAddress) } : {}),
80
+ Content: {
81
+ Simple: {
82
+ Subject: { Data: input.subject, Charset: "UTF-8" },
83
+ Body: {
84
+ ...(input.text ? { Text: { Data: input.text, Charset: "UTF-8" } } : {}),
85
+ ...(input.html ? { Html: { Data: input.html, Charset: "UTF-8" } } : {}),
86
+ },
87
+ },
88
+ },
89
+ };
90
+ }
91
+ function rawSesBody(input) {
92
+ return {
93
+ FromEmailAddress: formatEmailAddress(input.from),
94
+ Destination: {
95
+ ToAddresses: input.to.map(formatEmailAddress),
96
+ ...(input.cc?.length ? { CcAddresses: input.cc.map(formatEmailAddress) } : {}),
97
+ ...(input.bcc?.length ? { BccAddresses: input.bcc.map(formatEmailAddress) } : {}),
98
+ },
99
+ Content: {
100
+ Raw: {
101
+ Data: textToBase64(buildRawMimeMessage(input)),
102
+ },
103
+ },
104
+ };
105
+ }
106
+ function textToBase64(value) {
107
+ const bytes = new TextEncoder().encode(value);
108
+ let binary = "";
109
+ for (const byte of bytes)
110
+ binary += String.fromCharCode(byte);
111
+ return btoa(binary);
112
+ }
113
+ function parseJsonObject(value) {
114
+ try {
115
+ const parsed = JSON.parse(value);
116
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
117
+ }
118
+ catch {
119
+ return {};
120
+ }
121
+ }
122
+ function stringValue(value) {
123
+ return typeof value === "string" ? value : null;
124
+ }
125
+ //# sourceMappingURL=ses.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ses.js","sourceRoot":"","sources":["../src/ses.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,235 @@
1
+ # SES And SNS Helper Survey
2
+
3
+ Survey date: 2026-06-12.
4
+
5
+ This survey covers the current hand-rolled SES/SNS shapes across the maintained
6
+ Worker sites that were explicitly named or surfaced by source search:
7
+
8
+ - `/home/erik/ipogrid`
9
+ - `/home/erik/relin`
10
+ - `/home/erik/domains/bce.email`
11
+ - `/home/erik/domains/theinvestornet.com`
12
+ - `/home/erik/dirtsignal`
13
+ - `/home/erik/getflight`
14
+ - `/home/erik/onwardtravel`
15
+ - `/home/erik/adgiro`
16
+
17
+ `onwardtravel` and `adgiro` did not currently expose SES source usage in the
18
+ searched app code, despite likely operational SES use or future need.
19
+
20
+ ## Current Shapes
21
+
22
+ ### Simple Outbound SES
23
+
24
+ `ipogrid`, `relin`, and `getflight` each have a small SESv2 simple-email sender:
25
+
26
+ - build a client from `AWS_ACCESS_KEY` / `AWS_SECRET_KEY` / `AWS_REGION`
27
+ - require a configured from address
28
+ - accept `to`, `subject`, `text`, `html`, and optional reply-to
29
+ - call `SendEmailCommand` using `Content.Simple`
30
+ - either throw on SES failure or return `{ sent: false, reason }`
31
+
32
+ `ipogrid` and `relin` also wrap this in `NotificationMailer` classes with
33
+ `enabled`, `NoopNotificationMailer`, and `SesNotificationMailer`.
34
+
35
+ ### Rich Outbound SES
36
+
37
+ `dirtsignal` has the most complete outbound helper:
38
+
39
+ - supports simple SESv2 send when no special envelope behavior is needed
40
+ - switches to raw MIME for attachments or custom RFC 5322 headers
41
+ - supports one-click unsubscribe headers
42
+ - base64 encodes UTF-8 body parts and attachments
43
+ - sanitizes custom header values by removing CR/LF
44
+ - returns `{ sent, reason, message_id }` instead of throwing
45
+
46
+ This is the best starting point for the outbound helper because it covers the
47
+ behavior the simpler sites can use and the richer newsletter use case that SES
48
+ Simple mode cannot handle.
49
+
50
+ ### Worker-Native AWS Fetch
51
+
52
+ `bce.email` and `theinvestornet.com` avoid the AWS SDK for some Worker paths:
53
+
54
+ - `bce.email` signs SES Query API and SESv2 HTTP calls directly through a local
55
+ SigV4 `awsFetch`.
56
+ - `theinvestornet.com` uses `aws4fetch` to send SESv2 email and fetch inbound
57
+ S3 objects.
58
+
59
+ This points toward a `q32-core` helper that uses `fetch` plus SigV4 rather than
60
+ depending on `@aws-sdk/client-sesv2`, or offers the SDK client only as an
61
+ adapter. Keeping the core helper Worker-native avoids pulling the AWS SDK into
62
+ small Workers.
63
+
64
+ ### SNS Feedback Webhooks
65
+
66
+ `ipogrid` and `dirtsignal` duplicate the same SES feedback flow:
67
+
68
+ - authenticate a URL token
69
+ - parse an SNS envelope
70
+ - confirm `SubscriptionConfirmation` by fetching a trusted `SubscribeURL`
71
+ - ignore unsupported SNS types
72
+ - parse `Notification.Message`
73
+ - normalize `eventType` or `notificationType`
74
+ - extract bounce or complaint recipient emails
75
+ - call app-specific suppression/unsubscribe logic
76
+
77
+ `dirtsignal` is stricter and more reusable here because it validates the SNS
78
+ hostname before confirming subscriptions and falls back to `mail.destination`
79
+ when bounce/complaint recipient arrays are missing.
80
+
81
+ ### Inbound SES Receipt/SNS
82
+
83
+ `bce.email` and `theinvestornet.com` handle inbound SES receipt notifications:
84
+
85
+ - confirm SNS subscription
86
+ - parse receipt notifications from `Message`
87
+ - read `mail.messageId`, `mail.source`, common headers, and `receipt.recipients`
88
+ - fetch raw email from S3 or forward based on the message id
89
+ - route recipients through app-specific domain/workspace rules
90
+
91
+ The shared helper should parse and validate the envelope, but should not own
92
+ route resolution, raw email parsing, forwarding, R2 storage, D1 writes, or app
93
+ workflow creation.
94
+
95
+ ### SES Domain And Receipt Rule Management
96
+
97
+ `bce.email` has the only substantial SES management surface:
98
+
99
+ - `VerifyDomainIdentity`
100
+ - `GetIdentityVerificationAttributes`
101
+ - `GetIdentityDkimAttributes`
102
+ - `DeleteIdentity`
103
+ - `DescribeActiveReceiptRuleSet`
104
+ - `UpdateReceiptRule` with S3 action and optional SNS action
105
+ - DNS record generation for SES TXT, DKIM CNAMEs, and inbound MX
106
+
107
+ This deserves a separate helper area from outbound mail. It is useful, but it is
108
+ not the first extraction target because it is more product-specific and bound to
109
+ customer-domain provisioning workflows.
110
+
111
+ ## Recommended `q32-core` Shape
112
+
113
+ Add three layers, keeping app policy outside the package.
114
+
115
+ ### `@q32/core/email`
116
+
117
+ Extend the existing generic email types:
118
+
119
+ - normalize and format addresses
120
+ - build MIME-safe headers
121
+ - build simple text/html messages
122
+ - build raw MIME messages with:
123
+ - multipart alternative text/html
124
+ - attachments
125
+ - custom headers
126
+ - CR/LF header-value sanitization
127
+ - UTF-8/base64 transfer encoding
128
+ - generate `List-Unsubscribe` and `List-Unsubscribe-Post` header pairs from a
129
+ caller-provided URL
130
+
131
+ ### `@q32/core/aws`
132
+
133
+ Add Worker-native AWS/SigV4 helpers:
134
+
135
+ - `awsFetch(config, url, init, { service, region })`
136
+ - `createAwsConfigFromEnv(env, aliases?)`
137
+ - support both env naming families:
138
+ - `AWS_ACCESS_KEY` / `AWS_SECRET_KEY`
139
+ - `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`
140
+ - default region to `us-east-1`
141
+
142
+ This keeps SES and S3 helpers small without importing the AWS SDK.
143
+
144
+ ### `@q32/core/ses`
145
+
146
+ Add SES-specific helpers on top of `awsFetch`:
147
+
148
+ - `sendSesEmail(config, input)` using SESv2 HTTP API
149
+ - `sendSesRawEmail(config, input)` using SESv2 raw content
150
+ - `createSesMailerFromEnv(env, options)` returning a small provider with
151
+ `enabled` and `send`
152
+ - `sesQuery(config, params)` for legacy SES Query API calls
153
+ - `inboundSesMxHost(region)`
154
+ - `sesDomainDnsRecords(identityAttributes, region)` for verification, DKIM, and
155
+ inbound MX record description
156
+ - receipt-rule update helpers only after `bce.email` is ready to migrate
157
+
158
+ The first extraction should include outbound send plus raw MIME. Domain
159
+ provisioning can come after that so the API is not shaped by one app too early.
160
+
161
+ ### `@q32/core/ses-sns`
162
+
163
+ Add SNS envelope and SES notification parsing:
164
+
165
+ - `parseSnsEnvelope(body)`
166
+ - `isTrustedSnsSubscribeUrl(url)`
167
+ - `confirmSnsSubscription(envelope, fetcher?)`
168
+ - `parseSesFeedbackNotification(envelope)`
169
+ - `extractSesFeedbackRecipients(message)`
170
+ - `handleSesFeedbackWebhook(input)` that returns structured action data but
171
+ accepts an app callback for suppression/unsubscribe side effects
172
+ - `parseSesReceiptNotification(envelope)` for inbound receipt flows
173
+
174
+ The helper should return data like:
175
+
176
+ ```ts
177
+ type SesFeedbackEvent = {
178
+ eventType: "Bounce" | "Complaint" | string;
179
+ emails: string[];
180
+ messageId?: string;
181
+ topicArn?: string;
182
+ };
183
+
184
+ type SesReceiptEvent = {
185
+ messageId: string;
186
+ source?: string;
187
+ subject?: string;
188
+ recipients: string[];
189
+ };
190
+ ```
191
+
192
+ Apps still decide whether a bounce disables newsletters, login emails,
193
+ workspace senders, or all notification surfaces.
194
+
195
+ ## Migration Order
196
+
197
+ 1. Extract pure utilities first: address normalization, raw MIME builder,
198
+ trusted SNS URL check, SNS envelope parsing, and SES feedback recipient
199
+ extraction.
200
+ 2. Migrate `dirtsignal` and `ipogrid` SNS feedback handlers to shared parsing
201
+ while keeping their app-specific unsubscribe callbacks local.
202
+ 3. Migrate `getflight`, `relin`, and `ipogrid` simple outbound senders to a
203
+ shared SES mailer.
204
+ 4. Migrate `dirtsignal` outbound mail once raw MIME behavior is covered by
205
+ focused tests for custom headers and attachments.
206
+ 5. Consider `theinvestornet.com` AWS client extraction for shared SES/S3 fetch.
207
+ 6. Only then extract `bce.email` domain identity and receipt-rule management.
208
+
209
+ ## Test Coverage To Carry Into Core
210
+
211
+ Core should have focused tests for:
212
+
213
+ - simple text/html SESv2 request shape
214
+ - raw MIME text/html alternative body
215
+ - raw MIME attachments
216
+ - raw MIME custom headers with CR/LF stripped
217
+ - UTF-8 subject encoding
218
+ - trusted and untrusted SNS subscription URLs
219
+ - SNS `SubscriptionConfirmation`
220
+ - SNS unsupported type handling
221
+ - SES feedback `Bounce` and `Complaint` extraction
222
+ - fallback extraction from `mail.destination`
223
+ - SES receipt notification extraction with missing-message-data rejection
224
+
225
+ ## What Should Stay App-Local
226
+
227
+ - D1 repository updates
228
+ - unsubscribe and suppression policy
229
+ - notification preference semantics
230
+ - workspace sender selection
231
+ - per-product audit/ops event recording
232
+ - R2/S3 object retention policy
233
+ - raw inbound email parsing and attachment handling
234
+ - customer-domain provisioning job orchestration
235
+ - Domain Connect and DNS-provider UX
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@q32/core",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "description": "Shared TypeScript primitives for Q32 Cloudflare Worker projects.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,6 +32,10 @@
32
32
  "types": "./dist/auth.d.ts",
33
33
  "default": "./dist/auth.js"
34
34
  },
35
+ "./aws": {
36
+ "types": "./dist/aws.d.ts",
37
+ "default": "./dist/aws.js"
38
+ },
35
39
  "./billing": {
36
40
  "types": "./dist/billing.d.ts",
37
41
  "default": "./dist/billing.js"
@@ -120,6 +124,14 @@
120
124
  "types": "./dist/session.d.ts",
121
125
  "default": "./dist/session.js"
122
126
  },
127
+ "./ses": {
128
+ "types": "./dist/ses.d.ts",
129
+ "default": "./dist/ses.js"
130
+ },
131
+ "./ses-sns": {
132
+ "types": "./dist/ses-sns.d.ts",
133
+ "default": "./dist/ses-sns.js"
134
+ },
123
135
  "./testing": {
124
136
  "types": "./dist/testing.d.ts",
125
137
  "default": "./dist/testing.js"
package/src/aws.ts ADDED
@@ -0,0 +1,152 @@
1
+ export type AwsCredentials = {
2
+ accessKeyId: string;
3
+ secretAccessKey: string;
4
+ sessionToken?: string;
5
+ };
6
+
7
+ export type AwsConfig = AwsCredentials & {
8
+ region: string;
9
+ };
10
+
11
+ export type AwsEnv = {
12
+ AWS_ACCESS_KEY?: string;
13
+ AWS_SECRET_KEY?: string;
14
+ AWS_ACCESS_KEY_ID?: string;
15
+ AWS_SECRET_ACCESS_KEY?: string;
16
+ AWS_SESSION_TOKEN?: string;
17
+ AWS_REGION?: string;
18
+ };
19
+
20
+ export function createAwsConfigFromEnv(env: AwsEnv, defaultRegion = "us-east-1"): AwsConfig | null {
21
+ const accessKeyId = env.AWS_ACCESS_KEY_ID ?? env.AWS_ACCESS_KEY;
22
+ const secretAccessKey = env.AWS_SECRET_ACCESS_KEY ?? env.AWS_SECRET_KEY;
23
+ if (!accessKeyId || !secretAccessKey) return null;
24
+ return {
25
+ accessKeyId,
26
+ secretAccessKey,
27
+ sessionToken: env.AWS_SESSION_TOKEN,
28
+ region: env.AWS_REGION ?? defaultRegion,
29
+ };
30
+ }
31
+
32
+ export type AwsFetchOptions = {
33
+ service: string;
34
+ region?: string;
35
+ fetcher?: typeof fetch;
36
+ };
37
+
38
+ export async function awsFetch(
39
+ config: AwsConfig,
40
+ input: string | URL,
41
+ init: RequestInit,
42
+ options: AwsFetchOptions,
43
+ ): Promise<Response> {
44
+ const url = new URL(input.toString());
45
+ const region = options.region ?? config.region;
46
+ const method = (init.method ?? "GET").toUpperCase();
47
+ const body = bodyToString(init.body);
48
+ const payloadHash = await sha256Hex(body);
49
+ const headers = new Headers(init.headers);
50
+ headers.set("host", url.host);
51
+ headers.set("x-amz-content-sha256", payloadHash);
52
+ const now = new Date();
53
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
54
+ const dateStamp = amzDate.slice(0, 8);
55
+ headers.set("x-amz-date", amzDate);
56
+ if (config.sessionToken) headers.set("x-amz-security-token", config.sessionToken);
57
+
58
+ const signedHeaderNames = headerNames(headers).map((key) => key.toLowerCase()).sort();
59
+ const canonicalHeaders = signedHeaderNames.map((key) => `${key}:${normalizeHeaderValue(headers.get(key) ?? "")}\n`).join("");
60
+ const canonicalRequest = [
61
+ method,
62
+ canonicalPath(url),
63
+ canonicalQuery(url),
64
+ canonicalHeaders,
65
+ signedHeaderNames.join(";"),
66
+ payloadHash,
67
+ ].join("\n");
68
+ const credentialScope = `${dateStamp}/${region}/${options.service}/aws4_request`;
69
+ const stringToSign = [
70
+ "AWS4-HMAC-SHA256",
71
+ amzDate,
72
+ credentialScope,
73
+ await sha256Hex(canonicalRequest),
74
+ ].join("\n");
75
+ const signature = await hmacHex(await signingKey(config.secretAccessKey, dateStamp, region, options.service), stringToSign);
76
+ headers.set(
77
+ "authorization",
78
+ `AWS4-HMAC-SHA256 Credential=${config.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaderNames.join(";")}, Signature=${signature}`,
79
+ );
80
+
81
+ return (options.fetcher ?? fetch)(url, {
82
+ ...init,
83
+ method,
84
+ headers,
85
+ body: init.body,
86
+ });
87
+ }
88
+
89
+ function bodyToString(body: BodyInit | null | undefined): string {
90
+ if (!body) return "";
91
+ if (typeof body === "string") return body;
92
+ throw new Error("awsFetch currently supports string request bodies.");
93
+ }
94
+
95
+ function canonicalPath(url: URL): string {
96
+ return url.pathname
97
+ .split("/")
98
+ .map((part) => encodeURIComponent(decodeURIComponent(part)).replace(/[!'()*]/g, pctEncode))
99
+ .join("/");
100
+ }
101
+
102
+ function canonicalQuery(url: URL): string {
103
+ const entries: Array<[string, string]> = [];
104
+ url.searchParams.forEach((value, key) => entries.push([key, value]));
105
+ return entries
106
+ .map(([key, value]) => [encodeRfc3986(key), encodeRfc3986(value)] as const)
107
+ .sort(([leftKey, leftValue], [rightKey, rightValue]) => leftKey.localeCompare(rightKey) || leftValue.localeCompare(rightValue))
108
+ .map(([key, value]) => `${key}=${value}`)
109
+ .join("&");
110
+ }
111
+
112
+ function headerNames(headers: Headers): string[] {
113
+ const names: string[] = [];
114
+ headers.forEach((_value, key) => names.push(key));
115
+ return names;
116
+ }
117
+
118
+ function normalizeHeaderValue(value: string): string {
119
+ return value.trim().replace(/\s+/g, " ");
120
+ }
121
+
122
+ function encodeRfc3986(value: string): string {
123
+ return encodeURIComponent(value).replace(/[!'()*]/g, pctEncode);
124
+ }
125
+
126
+ function pctEncode(char: string): string {
127
+ return `%${char.charCodeAt(0).toString(16).toUpperCase()}`;
128
+ }
129
+
130
+ async function signingKey(secret: string, date: string, region: string, service: string): Promise<ArrayBuffer> {
131
+ const kDate = await hmacRaw(new TextEncoder().encode(`AWS4${secret}`), date);
132
+ const kRegion = await hmacRaw(kDate, region);
133
+ const kService = await hmacRaw(kRegion, service);
134
+ return hmacRaw(kService, "aws4_request");
135
+ }
136
+
137
+ async function hmacRaw(key: BufferSource, value: string): Promise<ArrayBuffer> {
138
+ const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
139
+ return crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(value));
140
+ }
141
+
142
+ async function hmacHex(key: BufferSource, value: string): Promise<string> {
143
+ return bytesToHex(new Uint8Array(await hmacRaw(key, value)));
144
+ }
145
+
146
+ async function sha256Hex(value: string): Promise<string> {
147
+ return bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))));
148
+ }
149
+
150
+ function bytesToHex(bytes: Uint8Array): string {
151
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
152
+ }
package/src/email.ts CHANGED
@@ -47,3 +47,102 @@ export function normalizeEmailAddress(email: string): string {
47
47
  export function appendUnsubscribeFooter(text: string, unsubscribeUrl: string): string {
48
48
  return `${text.trim()}\n\nUnsubscribe: ${unsubscribeUrl}\n`;
49
49
  }
50
+
51
+ export function sanitizeEmailHeaderValue(value: string): string {
52
+ return value.replace(/[\r\n]+/g, " ").trim();
53
+ }
54
+
55
+ export function encodeMimeHeader(value: string): string {
56
+ if (/^[\x20-\x7e]*$/.test(value)) return sanitizeEmailHeaderValue(value);
57
+ return `=?UTF-8?B?${base64Utf8(value)}?=`;
58
+ }
59
+
60
+ export function buildListUnsubscribeHeaders(url: string): Record<string, string> {
61
+ return {
62
+ "List-Unsubscribe": `<${sanitizeEmailHeaderValue(url)}>`,
63
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
64
+ };
65
+ }
66
+
67
+ export type RawMimeMessageInput = Omit<SendEmailInput, "tags"> & {
68
+ headers?: Record<string, string>;
69
+ boundaryPrefix?: string;
70
+ };
71
+
72
+ export function buildRawMimeMessage(input: RawMimeMessageInput): string {
73
+ const mixedBoundary = `${input.boundaryPrefix ?? "q32"}-mixed-${crypto.randomUUID()}`;
74
+ const bodyBoundary = `${input.boundaryPrefix ?? "q32"}-body-${crypto.randomUUID()}`;
75
+ const attachments = input.attachments ?? [];
76
+ const headers = [
77
+ `From: ${formatEmailAddress(input.from)}`,
78
+ `To: ${input.to.map(formatEmailAddress).join(", ")}`,
79
+ ...(input.cc?.length ? [`Cc: ${input.cc.map(formatEmailAddress).join(", ")}`] : []),
80
+ `Subject: ${encodeMimeHeader(input.subject)}`,
81
+ "MIME-Version: 1.0",
82
+ ...(input.replyTo?.length ? [`Reply-To: ${input.replyTo.map(formatEmailAddress).join(", ")}`] : []),
83
+ ];
84
+
85
+ for (const [name, value] of Object.entries(input.headers ?? {})) {
86
+ const headerName = sanitizeHeaderName(name);
87
+ const headerValue = sanitizeEmailHeaderValue(value);
88
+ if (headerName && headerValue) headers.push(`${headerName}: ${headerValue}`);
89
+ }
90
+
91
+ const parts = [...headers, `Content-Type: multipart/mixed; boundary="${mixedBoundary}"`, ""];
92
+ parts.push(`--${mixedBoundary}`);
93
+
94
+ if (input.text && input.html) {
95
+ parts.push(`Content-Type: multipart/alternative; boundary="${bodyBoundary}"`, "");
96
+ parts.push(`--${bodyBoundary}`);
97
+ parts.push("Content-Type: text/plain; charset=UTF-8");
98
+ parts.push("Content-Transfer-Encoding: base64", "", wrapBase64(base64Utf8(input.text)), "");
99
+ parts.push(`--${bodyBoundary}`);
100
+ parts.push("Content-Type: text/html; charset=UTF-8");
101
+ parts.push("Content-Transfer-Encoding: base64", "", wrapBase64(base64Utf8(input.html)), "");
102
+ parts.push(`--${bodyBoundary}--`, "");
103
+ } else if (input.html) {
104
+ parts.push("Content-Type: text/html; charset=UTF-8");
105
+ parts.push("Content-Transfer-Encoding: base64", "", wrapBase64(base64Utf8(input.html)), "");
106
+ } else {
107
+ parts.push("Content-Type: text/plain; charset=UTF-8");
108
+ parts.push("Content-Transfer-Encoding: base64", "", wrapBase64(base64Utf8(input.text ?? "")), "");
109
+ }
110
+
111
+ for (const attachment of attachments) {
112
+ parts.push(`--${mixedBoundary}`);
113
+ parts.push(`Content-Type: ${sanitizeEmailHeaderValue(attachment.contentType)}; name=${quoteHeaderParam(attachment.filename)}`);
114
+ parts.push("Content-Transfer-Encoding: base64");
115
+ parts.push(`Content-Disposition: attachment; filename=${quoteHeaderParam(attachment.filename)}`);
116
+ parts.push("", wrapBase64(attachmentDataToBase64(attachment.data)), "");
117
+ }
118
+
119
+ parts.push(`--${mixedBoundary}--`, "");
120
+ return parts.join("\r\n");
121
+ }
122
+
123
+ function sanitizeHeaderName(value: string): string {
124
+ const name = value.trim();
125
+ return /^[A-Za-z0-9-]+$/.test(name) ? name : "";
126
+ }
127
+
128
+ function quoteHeaderParam(value: string): string {
129
+ return `"${sanitizeEmailHeaderValue(value).replace(/(["\\])/g, "\\$1")}"`;
130
+ }
131
+
132
+ function attachmentDataToBase64(value: Uint8Array | string): string {
133
+ if (typeof value === "string") return base64Utf8(value);
134
+ let binary = "";
135
+ for (const byte of value) binary += String.fromCharCode(byte);
136
+ return btoa(binary);
137
+ }
138
+
139
+ function base64Utf8(value: string): string {
140
+ const bytes = new TextEncoder().encode(value);
141
+ let binary = "";
142
+ for (const byte of bytes) binary += String.fromCharCode(byte);
143
+ return btoa(binary);
144
+ }
145
+
146
+ function wrapBase64(value: string): string {
147
+ return value.replace(/.{1,76}/g, "$&\r\n").trimEnd();
148
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./api.js";
2
2
  export * from "./ai.js";
3
3
  export * from "./auth.js";
4
+ export * from "./aws.js";
4
5
  export * from "./billing.js";
5
6
  export * from "./cloudflare.js";
6
7
  export * from "./crypto.js";
@@ -21,5 +22,7 @@ export * from "./rate-limit.js";
21
22
  export * from "./react-router.js";
22
23
  export * from "./seo.js";
23
24
  export * from "./session.js";
25
+ export * from "./ses.js";
26
+ export * from "./ses-sns.js";
24
27
  export * from "./testing.js";
25
28
  export * from "./time.js";