@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/aws.d.ts +24 -0
- package/dist/aws.d.ts.map +1 -0
- package/dist/aws.js +109 -0
- package/dist/aws.js.map +1 -0
- package/dist/email.d.ts +8 -0
- package/dist/email.d.ts.map +1 -1
- package/dist/email.js +87 -0
- package/dist/email.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/ses-sns.d.ts +60 -0
- package/dist/ses-sns.d.ts.map +1 -0
- package/dist/ses-sns.js +96 -0
- package/dist/ses-sns.js.map +1 -0
- package/dist/ses.d.ts +53 -0
- package/dist/ses.d.ts.map +1 -0
- package/dist/ses.js +125 -0
- package/dist/ses.js.map +1 -0
- package/docs/ses-sns-helper-survey.md +235 -0
- package/package.json +13 -1
- package/src/aws.ts +152 -0
- package/src/email.ts +99 -0
- package/src/index.ts +3 -0
- package/src/ses-sns.ts +144 -0
- package/src/ses.ts +180 -0
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
|
package/dist/ses.js.map
ADDED
|
@@ -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.
|
|
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";
|