@ultimat3/mail 24.0.0 → 25.1.0

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.
@@ -0,0 +1,141 @@
1
+ // Single responsibility: the `api/` route half of SES delivery notifications. Read the capped
2
+ // body, prove SNS signed it for one of this app's topics (`sns-signature.ts`), then hand the
3
+ // notification to `delivery-event-ses.ts`. A SubscriptionConfirmation is answered with its confirm
4
+ // URL, and that URL is fetched only when the receiver was built with `confirmSubscriptions: true`.
5
+
6
+ import {
7
+ type Clock,
8
+ ConfigInvalidError,
9
+ finiteCount,
10
+ readWithinLimit,
11
+ renderThrowable,
12
+ } from '@ultimat3/core';
13
+ import {
14
+ type DeliveryOutcome,
15
+ type DeliveryReceiver,
16
+ parseDeliveryJson,
17
+ readDeliveryBody,
18
+ resolveBodyLimit,
19
+ } from './delivery-event';
20
+ import { deliveryEventInvalid, deliveryProviderUnreachable } from './delivery-event-errors';
21
+ import { sesDeliveryEvents } from './delivery-event-ses';
22
+ import type { MailFetch } from './driver-resend';
23
+ import { createSnsCertificateCache } from './sns-certificate-cache';
24
+ import {
25
+ isPinnedSubscribeUrl,
26
+ type SnsCertificateFetch,
27
+ type SnsMessage,
28
+ snsTopicRegion,
29
+ verifySnsMessage,
30
+ } from './sns-signature';
31
+
32
+ /**
33
+ * One hour, either way. SNS's default HTTP retry policy redelivers within a minute; a custom
34
+ * delivery policy can stretch that, and `toleranceMs` is the knob for a topic that does.
35
+ */
36
+ export const DEFAULT_SNS_TOLERANCE_MS = 3_600_000;
37
+
38
+ const PROVIDER_TIMEOUT_MS = 5_000;
39
+ const CERTIFICATE_MAX_BYTES = 16_384;
40
+
41
+ export interface SesEventReceiverOptions {
42
+ /** The topic ARNs this route is subscribed to. Required: SNS signs for ANY account's topic. */
43
+ readonly topicArns: readonly string[];
44
+ /** GET the SubscribeURL of a verified SubscriptionConfirmation. Off: the URL is returned. */
45
+ readonly confirmSubscriptions?: boolean | undefined;
46
+ /** Default `DEFAULT_SNS_TOLERANCE_MS`. */
47
+ readonly toleranceMs?: number | undefined;
48
+ /** Default `DEFAULT_DELIVERY_BODY_LIMIT`. */
49
+ readonly maxBytes?: number | undefined;
50
+ readonly clock?: Clock | undefined;
51
+ /** Certificate download. Default: a GET over pinned https, no redirects, 5 s, 16 KiB. */
52
+ readonly fetchCertificate?: SnsCertificateFetch | undefined;
53
+ /** The subscription-confirm GET, and the default certificate download. Default: global fetch. */
54
+ readonly fetch?: MailFetch | undefined;
55
+ }
56
+
57
+ /** The default download: pinned https, no redirect (a redirect would leave the pinned host). */
58
+ function defaultCertificateFetch(fetch: MailFetch): SnsCertificateFetch {
59
+ return async (url) => {
60
+ const response = await fetch(url, {
61
+ method: 'GET',
62
+ redirect: 'error',
63
+ signal: AbortSignal.timeout(PROVIDER_TIMEOUT_MS),
64
+ });
65
+ if (!response.ok) {
66
+ await response.body?.cancel();
67
+ throw deliveryProviderUnreachable('certificate', `HTTP ${response.status}`);
68
+ }
69
+ const read = await readWithinLimit(response.body, CERTIFICATE_MAX_BYTES);
70
+ if ('over' in read)
71
+ throw deliveryProviderUnreachable('certificate', 'the answer was too large');
72
+ return new TextDecoder().decode(read.bytes);
73
+ };
74
+ }
75
+
76
+ export function sesEventReceiver(options: SesEventReceiverOptions): DeliveryReceiver {
77
+ const topicArns = new Set(options.topicArns);
78
+ if (topicArns.size === 0 || [...topicArns].some((arn) => snsTopicRegion(arn) === undefined)) {
79
+ throw deliveryEventConfig();
80
+ }
81
+ const maxBytes = resolveBodyLimit('sesEventReceiver', options.maxBytes);
82
+ const toleranceMs = finiteCount(
83
+ 'sesEventReceiver',
84
+ 'toleranceMs',
85
+ options.toleranceMs ?? DEFAULT_SNS_TOLERANCE_MS,
86
+ 1,
87
+ );
88
+ const doFetch: MailFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
89
+ const verify = {
90
+ topicArns,
91
+ toleranceMs,
92
+ clock: options.clock,
93
+ certificates: createSnsCertificateCache({
94
+ fetchCertificate: options.fetchCertificate ?? defaultCertificateFetch(doFetch),
95
+ clock: options.clock,
96
+ }),
97
+ };
98
+
99
+ return {
100
+ async receive(request) {
101
+ const body = await readDeliveryBody('ses', request, maxBytes);
102
+ const message = await verifySnsMessage(parseDeliveryJson('ses', body), verify);
103
+ if (message.Type === 'Notification')
104
+ return sesDeliveryEvents(message.Message, message.MessageId);
105
+ if (message.Type === 'UnsubscribeConfirmation') {
106
+ return { type: 'ignored', eventType: 'UnsubscribeConfirmation' };
107
+ }
108
+ return await subscription(message);
109
+ },
110
+ };
111
+
112
+ async function subscription(message: SnsMessage): Promise<DeliveryOutcome> {
113
+ const confirmUrl = message.SubscribeURL ?? '';
114
+ const region = snsTopicRegion(message.TopicArn) ?? '';
115
+ // Signed, and pinned anyway: the URL is fetched by THIS host when confirming is on.
116
+ if (!isPinnedSubscribeUrl(confirmUrl, region)) throw deliveryEventInvalid('ses', 'envelope');
117
+ if (options.confirmSubscriptions !== true) {
118
+ return { type: 'subscription', topicArn: message.TopicArn, confirmUrl, confirmed: false };
119
+ }
120
+ let response: Response;
121
+ try {
122
+ response = await doFetch(confirmUrl, {
123
+ method: 'GET',
124
+ redirect: 'error',
125
+ signal: AbortSignal.timeout(PROVIDER_TIMEOUT_MS),
126
+ });
127
+ } catch (error) {
128
+ throw deliveryProviderUnreachable('subscription', renderThrowable(error));
129
+ }
130
+ await response.body?.cancel();
131
+ if (!response.ok) throw deliveryProviderUnreachable('subscription', `HTTP ${response.status}`);
132
+ return { type: 'subscription', topicArn: message.TopicArn, confirmUrl, confirmed: true };
133
+ }
134
+ }
135
+
136
+ function deliveryEventConfig(): ConfigInvalidError {
137
+ return new ConfigInvalidError({
138
+ cause: 'sesEventReceiver needs at least one topic ARN, and every one must be an SNS topic ARN',
139
+ fix: "sesEventReceiver({ topicArns: ['arn:aws:sns:us-east-1:123456789012:ses-events'] }) — the topics this route is subscribed to (aws sns list-topics)",
140
+ });
141
+ }
@@ -0,0 +1,114 @@
1
+ // Single responsibility: an SES v2 error response → the retry verdict and the `fix:` line. SES
2
+ // names its failure in a TYPE (`x-amzn-ErrorType`, or `__type` in the body) far more precisely than
3
+ // its status does — `MessageRejected`, `SendingPausedException` and a bad request are all 400 — so
4
+ // the type decides, and core's status table answers only for a type this table does not know.
5
+
6
+ import { isRetryableStatus } from '@ultimat3/core';
7
+
8
+ /** What the driver needs to build `sendFailed(...)`: the verdict, the fix, and the text to show. */
9
+ export interface SesFailure {
10
+ readonly type: string | undefined;
11
+ readonly retryable: boolean;
12
+ readonly fix: string;
13
+ readonly detail: string;
14
+ }
15
+
16
+ type SesClass =
17
+ | 'throttled'
18
+ | 'rejected'
19
+ | 'invalid'
20
+ | 'paused'
21
+ | 'unverified'
22
+ | 'credentials'
23
+ | 'missing';
24
+
25
+ /** Every SES (v1 and v2 spellings) and AWS-auth type this driver distinguishes. */
26
+ const CLASS_OF: ReadonlyMap<string, SesClass> = new Map([
27
+ ['TooManyRequestsException', 'throttled'],
28
+ ['ThrottlingException', 'throttled'],
29
+ ['Throttling', 'throttled'],
30
+ ['LimitExceededException', 'throttled'],
31
+ ['MessageRejected', 'rejected'],
32
+ ['BadRequestException', 'invalid'],
33
+ ['SendingPausedException', 'paused'],
34
+ ['AccountSendingPausedException', 'paused'],
35
+ ['AccountSuspendedException', 'paused'],
36
+ ['MailFromDomainNotVerifiedException', 'unverified'],
37
+ ['UnrecognizedClientException', 'credentials'],
38
+ ['InvalidClientTokenId', 'credentials'],
39
+ ['InvalidSignatureException', 'credentials'],
40
+ ['SignatureDoesNotMatch', 'credentials'],
41
+ ['IncompleteSignature', 'credentials'],
42
+ ['MissingAuthenticationToken', 'credentials'],
43
+ ['ExpiredTokenException', 'credentials'],
44
+ ['ExpiredToken', 'credentials'],
45
+ ['AccessDeniedException', 'credentials'],
46
+ ['AccessDenied', 'credentials'],
47
+ ['NotFoundException', 'missing'],
48
+ ]);
49
+
50
+ /** Only throttling clears by waiting; every other class is the same answer on the next attempt. */
51
+ const RETRYABLE: ReadonlySet<SesClass> = new Set<SesClass>(['throttled']);
52
+
53
+ const MAX_DETAIL_LENGTH = 200;
54
+
55
+ function fixFor(kind: SesClass | undefined, region: string): string {
56
+ switch (kind) {
57
+ case 'throttled':
58
+ return `aws sesv2 get-account --region ${region} — the job already retries with backoff; raise the quota if this repeats`;
59
+ case 'rejected':
60
+ return `aws sesv2 get-email-identity --region ${region} --email-identity <your sending domain> — verify the From identity, and read SES's reason in the cause`;
61
+ case 'paused':
62
+ return `aws sesv2 put-account-sending-attributes --region ${region} --sending-enabled — after fixing why SES paused sending (aws sesv2 get-account)`;
63
+ case 'unverified':
64
+ return `aws sesv2 get-email-identity --region ${region} --email-identity <your sending domain> — publish the custom MAIL FROM domain's MX and SPF records it lists`;
65
+ case 'credentials':
66
+ return 'aws sts get-caller-identity — then set SES_ACCESS_KEY_ID and SES_SECRET_ACCESS_KEY to a key allowed ses:SendEmail, and sync this host clock if SES said the signature expired';
67
+ case 'missing':
68
+ return `aws sesv2 list-configuration-sets --region ${region} — create the set SES_CONFIGURATION_SET names, or unset it`;
69
+ default:
70
+ return 'curl -sS https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html — check the request against the SendEmail reference';
71
+ }
72
+ }
73
+
74
+ function isRecord(value: unknown): value is Record<string, unknown> {
75
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
76
+ }
77
+
78
+ /** `MessageRejected:http://internal.amazon.com/…` or `com.amazonaws.ses#MessageRejected` → the name. */
79
+ function typeName(raw: string): string | undefined {
80
+ const name = (raw.split(':')[0] ?? '').split('#').pop()?.trim() ?? '';
81
+ return /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(name) ? name : undefined;
82
+ }
83
+
84
+ function bodyField(body: unknown, ...keys: readonly string[]): string | undefined {
85
+ if (!isRecord(body)) return undefined;
86
+ for (const key of keys) {
87
+ const value = body[key];
88
+ if (typeof value === 'string' && value !== '') return value;
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ /** Reads the response once; a body that is not JSON still yields a capped, printable detail. */
94
+ export async function sesFailure(response: Response, region: string): Promise<SesFailure> {
95
+ const text = await response.text().catch(() => '');
96
+ let body: unknown;
97
+ try {
98
+ body = JSON.parse(text);
99
+ } catch {
100
+ body = undefined;
101
+ }
102
+ const header = response.headers.get('x-amzn-errortype');
103
+ const raw = header ?? bodyField(body, '__type', 'code', 'Code');
104
+ const type = raw === undefined ? undefined : typeName(raw);
105
+ const kind = type === undefined ? undefined : CLASS_OF.get(type);
106
+ const message = bodyField(body, 'message', 'Message') ?? (text === '' ? 'an empty body' : text);
107
+ const said = `${type ?? `HTTP ${response.status}`}: ${message}`;
108
+ return {
109
+ type,
110
+ retryable: kind === undefined ? isRetryableStatus(response.status) : RETRYABLE.has(kind),
111
+ fix: fixFor(kind, region),
112
+ detail: said.length > MAX_DETAIL_LENGTH ? `${said.slice(0, MAX_DETAIL_LENGTH)}…` : said,
113
+ };
114
+ }
@@ -161,7 +161,7 @@ class Conversation {
161
161
  stage,
162
162
  detail: `the server sent nothing for ${timeoutMs}ms, so the read deadline expired`,
163
163
  retryable: true,
164
- fix: 'pass timeoutMs: 60_000 to createSmtpDriver() in app.config.ts',
164
+ fix: 'pass timeoutMs: 60_000 to smtpMailDriver() in app.config.ts',
165
165
  }),
166
166
  );
167
167
  }, timeoutMs);
@@ -176,7 +176,7 @@ class Conversation {
176
176
  'the server closed the connection mid-conversation, which is usually it ' +
177
177
  'rate-limiting the sessions it keeps open at once',
178
178
  retryable: true,
179
- fix: 'pass poolSize: 1 to createSmtpDriver() in app.config.ts',
179
+ fix: 'pass poolSize: 1 to smtpMailDriver() in app.config.ts',
180
180
  });
181
181
  }
182
182
  return chunk;
@@ -230,7 +230,7 @@ export async function smtpDeliver(
230
230
  stage: 'starttls',
231
231
  detail:
232
232
  'the server does not advertise STARTTLS and the connection is not already TLS; ' +
233
- 'allowInsecure: true on createSmtpDriver() would send this in the clear instead',
233
+ 'allowInsecure: true on smtpMailDriver() would send this in the clear instead',
234
234
  retryable: false,
235
235
  fix: FIXES['starttls'] ?? 'set SMTP_URL in .env to smtps://host:465',
236
236
  });
@@ -204,7 +204,7 @@ export function smtpStreamOver(runtime: BunConnect, target: SmtpTarget): Promise
204
204
  stage: 'data',
205
205
  detail: `the socket stopped accepting bytes for ${target.timeoutMs}ms`,
206
206
  retryable: true,
207
- fix: 'pass timeoutMs: 60_000 to createSmtpDriver() in app.config.ts',
207
+ fix: 'pass timeoutMs: 60_000 to smtpMailDriver() in app.config.ts',
208
208
  }),
209
209
  );
210
210
  }, target.timeoutMs);
@@ -0,0 +1,111 @@
1
+ // Single responsibility: the SNS signing certificates a receiver has downloaded, bounded against a
2
+ // sender who forges messages. Any pinned URL reaches the download BEFORE a signature is checked,
3
+ // and an allowed topic ARN is not a secret, so a forger can name a fresh path per request. Four
4
+ // bounds hold that to a fixed cost: LRU positives, a negative TTL, an in-flight cap, coalescing.
5
+
6
+ import { type Clock, isUltimateError, renderThrowable, systemClock } from '@ultimat3/core';
7
+ import { deliveryProviderUnreachable } from './delivery-event-errors';
8
+ import { certificateSpki, pemCertificateDer } from './x509-spki';
9
+
10
+ /** PEM text for a certificate URL. Injected in tests; production GETs it over pinned https. */
11
+ export type SnsCertificateFetch = (url: string) => Promise<string>;
12
+
13
+ /** SNS uses a handful of certificates per region; eight keeps every live one with room to rotate. */
14
+ const MAX_CERTIFICATES = 8;
15
+ /** Downloads at once. A flood waits on nothing: past this it is refused with a 503 SNS retries. */
16
+ const MAX_IN_FLIGHT = 2;
17
+ /** How long a URL that failed to download is answered from memory instead of fetched again. */
18
+ const NEGATIVE_TTL_MS = 300_000;
19
+ /** Remembered failures. Bounded too, or the negative cache is the next thing to flood. */
20
+ const MAX_FAILURES = 256;
21
+
22
+ export interface SnsCertificateCache {
23
+ /** The SPKI for a pinned certificate URL — cached, coalesced, or refused. */
24
+ spkiFor(url: string): Promise<Uint8Array<ArrayBuffer>>;
25
+ }
26
+
27
+ export interface SnsCertificateCacheOptions {
28
+ readonly fetchCertificate: SnsCertificateFetch;
29
+ readonly clock?: Clock | undefined;
30
+ }
31
+
32
+ export function createSnsCertificateCache(
33
+ options: SnsCertificateCacheOptions,
34
+ ): SnsCertificateCache {
35
+ const clock = options.clock ?? systemClock;
36
+ // Insertion order is recency order: a hit is deleted and re-set, so the first key is the LRU.
37
+ const verified = new Map<string, Uint8Array<ArrayBuffer>>();
38
+ const failedUntil = new Map<string, number>();
39
+ const inFlight = new Map<string, Promise<Uint8Array<ArrayBuffer>>>();
40
+
41
+ const remember = <V>(map: Map<string, V>, key: string, value: V, max: number): void => {
42
+ map.delete(key);
43
+ map.set(key, value);
44
+ if (map.size > max) {
45
+ const oldest = map.keys().next().value;
46
+ if (oldest !== undefined) map.delete(oldest);
47
+ }
48
+ };
49
+
50
+ async function download(url: string): Promise<Uint8Array<ArrayBuffer>> {
51
+ let pem: string;
52
+ try {
53
+ pem = await options.fetchCertificate(url);
54
+ } catch (error) {
55
+ // The default download already names its failure; an injected one may throw anything.
56
+ if (isUltimateError(error)) throw error;
57
+ throw deliveryProviderUnreachable('certificate', renderThrowable(error));
58
+ }
59
+ const der = pemCertificateDer(pem);
60
+ const spki = der === undefined ? undefined : certificateSpki(der);
61
+ if (spki === undefined) {
62
+ throw deliveryProviderUnreachable(
63
+ 'certificate',
64
+ 'the answer was not a PEM X.509 certificate',
65
+ );
66
+ }
67
+ return new Uint8Array(spki);
68
+ }
69
+
70
+ return {
71
+ spkiFor(url) {
72
+ const hit = verified.get(url);
73
+ if (hit !== undefined) {
74
+ remember(verified, url, hit, MAX_CERTIFICATES);
75
+ return Promise.resolve(hit);
76
+ }
77
+ const pending = inFlight.get(url);
78
+ if (pending !== undefined) return pending;
79
+ const now = clock.monotonic();
80
+ const until = failedUntil.get(url);
81
+ if (until !== undefined && now < until) {
82
+ return Promise.reject(
83
+ deliveryProviderUnreachable('certificate', 'this URL failed to download moments ago'),
84
+ );
85
+ }
86
+ if (inFlight.size >= MAX_IN_FLIGHT) {
87
+ return Promise.reject(
88
+ deliveryProviderUnreachable('certificate', 'too many certificate downloads in flight'),
89
+ );
90
+ }
91
+ const started = download(url).then(
92
+ (spki) => {
93
+ failedUntil.delete(url);
94
+ remember(verified, url, spki, MAX_CERTIFICATES);
95
+ return spki;
96
+ },
97
+ (error: unknown) => {
98
+ remember(failedUntil, url, clock.monotonic() + NEGATIVE_TTL_MS, MAX_FAILURES);
99
+ throw error;
100
+ },
101
+ );
102
+ inFlight.set(url, started);
103
+ // `finally` on a branch whose rejection is observed by the caller, never left unhandled.
104
+ started.then(
105
+ () => inFlight.delete(url),
106
+ () => inFlight.delete(url),
107
+ );
108
+ return started;
109
+ },
110
+ };
111
+ }
@@ -0,0 +1,187 @@
1
+ // Single responsibility: prove an Amazon SNS HTTP(S) message was signed by SNS for a topic this
2
+ // app owns. The AWS-documented procedure: the topic first (any AWS account can make SNS sign for
3
+ // ITS topic), the certificate URL pinned to `https://sns.<topic region>.amazonaws.com`, the
4
+ // canonical string per message type, RSA-SHA1 (v1) or RSA-SHA256 (v2) through Web Crypto.
5
+
6
+ import { type Clock, systemClock } from '@ultimat3/core';
7
+ import { t, validate } from '@ultimat3/schema';
8
+ import { deliveryInstant } from './delivery-event';
9
+ import { deliveryEventInvalid, deliveryEventUnverified } from './delivery-event-errors';
10
+ import type { SnsCertificateCache } from './sns-certificate-cache';
11
+
12
+ /** The envelope fields the signature covers, plus the three that say how it was signed. */
13
+ export interface SnsMessage {
14
+ readonly Type: 'Notification' | 'SubscriptionConfirmation' | 'UnsubscribeConfirmation';
15
+ readonly MessageId: string;
16
+ readonly TopicArn: string;
17
+ readonly Message: string;
18
+ readonly Timestamp: string;
19
+ readonly Subject?: string | undefined;
20
+ readonly SubscribeURL?: string | undefined;
21
+ readonly Token?: string | undefined;
22
+ readonly SignatureVersion: string;
23
+ readonly Signature: string;
24
+ readonly SigningCertURL: string;
25
+ }
26
+
27
+ export type { SnsCertificateFetch } from './sns-certificate-cache';
28
+
29
+ const envelope = t.object({
30
+ Type: t.enum(['Notification', 'SubscriptionConfirmation', 'UnsubscribeConfirmation']),
31
+ MessageId: t.string,
32
+ TopicArn: t.string,
33
+ Message: t.string,
34
+ Timestamp: t.string,
35
+ Subject: t.string.optional(),
36
+ SubscribeURL: t.string.optional(),
37
+ Token: t.string.optional(),
38
+ SignatureVersion: t.string.optional(),
39
+ Signature: t.string.optional(),
40
+ SigningCertURL: t.string.optional(),
41
+ });
42
+
43
+ /** `arn:aws:sns:<region>:<account>:<name>` — the region is what the certificate host is pinned to. */
44
+ const TOPIC_ARN =
45
+ /^arn:aws(?:-us-gov)?:sns:([a-z]{2}(?:-[a-z]+)+-\d{1,2}):\d{12}:[A-Za-z0-9_-]{1,256}(?:\.fifo)?$/;
46
+
47
+ /** AWS's documented key order, per message type. `Subject` is signed only when present. */
48
+ const CONFIRMATION_KEYS: readonly (keyof SnsMessage)[] = [
49
+ 'Message',
50
+ 'MessageId',
51
+ 'SubscribeURL',
52
+ 'Timestamp',
53
+ 'Token',
54
+ 'TopicArn',
55
+ 'Type',
56
+ ];
57
+ /** A Map: `Type` came off the wire, and an object table answers `constructor` from its prototype. */
58
+ const SIGNED_KEYS: ReadonlyMap<SnsMessage['Type'], readonly (keyof SnsMessage)[]> = new Map([
59
+ ['Notification', ['Message', 'MessageId', 'Subject', 'Timestamp', 'TopicArn', 'Type']],
60
+ ['SubscriptionConfirmation', CONFIRMATION_KEYS],
61
+ ['UnsubscribeConfirmation', CONFIRMATION_KEYS],
62
+ ]);
63
+
64
+ /** `SignatureVersion` is a wire value: `"constructor"` must find nothing here. */
65
+ const HASH_OF: ReadonlyMap<string, 'SHA-1' | 'SHA-256'> = new Map([
66
+ ['1', 'SHA-1'],
67
+ ['2', 'SHA-256'],
68
+ ]);
69
+
70
+ /** `"Key\nValue\n"` for each signed key present, in the documented order. */
71
+ export function snsCanonicalString(message: SnsMessage): string {
72
+ let canonical = '';
73
+ for (const key of SIGNED_KEYS.get(message.Type) ?? []) {
74
+ const value = message[key];
75
+ if (value !== undefined) canonical += `${key}\n${value}\n`;
76
+ }
77
+ return canonical;
78
+ }
79
+
80
+ /** The region of an ARN this module accepts, or `undefined` for one it does not. */
81
+ export function snsTopicRegion(topicArn: string): string | undefined {
82
+ return TOPIC_ARN.exec(topicArn)?.[1];
83
+ }
84
+
85
+ /** The only path SNS serves a signing certificate from. */
86
+ /** `SimpleNotificationService-<32 lower-case hex>.pem` — the form every published SigningCertURL takes. */
87
+ const CERT_PATH = /^\/SimpleNotificationService-[0-9a-f]{32}\.pem$/;
88
+
89
+ /**
90
+ * A signing-certificate URL in its ONE spelling: `https://sns.<region>.amazonaws.com/SimpleNotificationService-<hex>.pem`,
91
+ * compared as a string against what the parse normalises it to. Every alias of one URL — a
92
+ * fragment, `:443`, a bare `?`, `\` for `/`, an upper-case or full-width host — is refused rather
93
+ * than normalised, so one certificate has one cache key. That does NOT stop a forger who knows an
94
+ * allowed topic from naming a new `<hex>` per request — every one passes here and reaches the
95
+ * download before any signature is checked. `sns-certificate-cache.ts` is what bounds that cost.
96
+ */
97
+ export function isPinnedCertificateUrl(raw: string, region: string): boolean {
98
+ const url = URL.parse(raw);
99
+ if (url === null || !CERT_PATH.test(url.pathname)) return false;
100
+ return raw === `https://sns.${region}.amazonaws.com${url.pathname}`;
101
+ }
102
+
103
+ /** A SubscribeURL: the pinned origin, path `/`, a query, no fragment, no userinfo, no port. */
104
+ export function isPinnedSubscribeUrl(raw: string, region: string): boolean {
105
+ const url = URL.parse(raw);
106
+ if (url === null || url.hash !== '' || url.pathname !== '/' || url.search === '') return false;
107
+ return raw.startsWith(`https://sns.${region}.amazonaws.com/?`);
108
+ }
109
+
110
+ function base64Bytes(text: string): Uint8Array<ArrayBuffer> | undefined {
111
+ try {
112
+ return Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ }
117
+
118
+ export interface SnsVerifyOptions {
119
+ /** Every topic this app subscribed this route to. Anything else is refused before any fetch. */
120
+ readonly topicArns: ReadonlySet<string>;
121
+ readonly toleranceMs: number;
122
+ readonly clock?: Clock | undefined;
123
+ /** Downloaded certificates, bounded against forgery floods. The receiver owns one. */
124
+ readonly certificates: SnsCertificateCache;
125
+ }
126
+
127
+ /** Parse, pin, fetch, verify, then check the clock — and answer the message that was signed. */
128
+ export async function verifySnsMessage(
129
+ parsed: unknown,
130
+ options: SnsVerifyOptions,
131
+ ): Promise<SnsMessage> {
132
+ const result = validate(envelope, parsed);
133
+ if (result.issues !== undefined) throw deliveryEventInvalid('ses', 'envelope');
134
+ const fields = result.value;
135
+ const { Signature: signature, SigningCertURL: certUrl, SignatureVersion: version } = fields;
136
+ if (signature === undefined || certUrl === undefined || version === undefined) {
137
+ throw deliveryEventUnverified('ses', 'unsigned');
138
+ }
139
+ if (!options.topicArns.has(fields.TopicArn)) throw deliveryEventUnverified('ses', 'topic');
140
+ const region = snsTopicRegion(fields.TopicArn);
141
+ if (region === undefined || !isPinnedCertificateUrl(certUrl, region)) {
142
+ throw deliveryEventUnverified('ses', 'certificate-url');
143
+ }
144
+ const hash = HASH_OF.get(version);
145
+ if (hash === undefined) throw deliveryEventUnverified('ses', 'signature-version');
146
+ const message: SnsMessage = {
147
+ ...fields,
148
+ SignatureVersion: version,
149
+ Signature: signature,
150
+ SigningCertURL: certUrl,
151
+ };
152
+
153
+ const signatureBytes = base64Bytes(signature);
154
+ const spki = await options.certificates.spkiFor(certUrl);
155
+ if (signatureBytes === undefined || !(await rsaVerifies(spki, hash, signatureBytes, message))) {
156
+ throw deliveryEventUnverified('ses', 'signature');
157
+ }
158
+ // After the signature, like `@ultimat3/http`'s receiver: "stale" then always means authentic.
159
+ const sentAt = deliveryInstant('ses', fields.Timestamp).getTime();
160
+ const now = (options.clock ?? systemClock).now().getTime();
161
+ if (Math.abs(now - sentAt) > options.toleranceMs) {
162
+ throw deliveryEventUnverified('ses', 'stale');
163
+ }
164
+ return message;
165
+ }
166
+
167
+ async function rsaVerifies(
168
+ spki: Uint8Array<ArrayBuffer>,
169
+ hash: 'SHA-1' | 'SHA-256',
170
+ signature: Uint8Array<ArrayBuffer>,
171
+ message: SnsMessage,
172
+ ): Promise<boolean> {
173
+ try {
174
+ const key = await crypto.subtle.importKey(
175
+ 'spki',
176
+ spki,
177
+ { name: 'RSASSA-PKCS1-v1_5', hash },
178
+ false,
179
+ ['verify'],
180
+ );
181
+ const canonical = new TextEncoder().encode(snsCanonicalString(message));
182
+ return await crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, signature, canonical);
183
+ } catch {
184
+ // A key Web Crypto will not import (not RSA) verifies nothing.
185
+ return false;
186
+ }
187
+ }
package/src/transform.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // every retry all carry the transformed bytes. An app adds an open pixel or rewrites its own links
4
4
  // here; the framework only guarantees WHEN it runs, what it sees, and that a failure never ships.
5
5
 
6
+ import { stringField } from '@ultimat3/core';
6
7
  import type { MailMessage } from './driver';
7
8
  import { transformFailed } from './errors';
8
9
  import { assertHeaderSafe } from './header-safety';
@@ -98,11 +99,20 @@ function asRendered(value: unknown): MailRendered | undefined {
98
99
 
99
100
  /**
100
101
  * What KIND of value was thrown, from a closed list — never its message and never its `name`,
101
- * which are the app's strings and may carry a recipient address.
102
+ * which are the app's strings and may carry a recipient address. TOTAL, because it runs inside the
103
+ * catch that codes the failure: `instanceof` runs a `Proxy`'s `getPrototypeOf` trap and `.name` is a
104
+ * getter, and either throwing there escaped `send()` uncoded. `typeof` alone cannot throw.
102
105
  */
103
106
  const KNOWN_ERRORS = new Set(['Error', 'TypeError', 'RangeError', 'SyntaxError', 'ReferenceError']);
104
107
 
105
108
  function describe(error: unknown): string {
106
- if (error instanceof Error) return KNOWN_ERRORS.has(error.name) ? error.name : 'an Error';
107
- return typeof error;
109
+ let isError: boolean;
110
+ try {
111
+ isError = error instanceof Error;
112
+ } catch {
113
+ return typeof error;
114
+ }
115
+ if (!isError) return typeof error;
116
+ const name = stringField(error, 'name');
117
+ return name !== undefined && KNOWN_ERRORS.has(name) ? name : 'an Error';
108
118
  }