@ultimat3/mail 23.0.0 → 25.0.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.
package/src/render.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  // out of the HTML, so it never rots. Styling is inlined from the layout's tokens because most
4
4
  // clients drop <style>; the dark-mode block is the one exception, for clients that honour it.
5
5
 
6
- import { directionOf, type TranslateVars, type Translator, translatorFor } from '@ultimat3/i18n';
6
+ import { directionOf, escapeHtml } from '@ultimat3/core';
7
+ import { type TranslateVars, type Translator, translatorFor } from '@ultimat3/i18n';
7
8
  import type { CalloutTone, MailBlock, MailTemplate } from './blocks';
8
9
  // The renderer depends on the strings it renders. This is the only module that resolves a `mail.*`
9
10
  // key, and `catalog.ts` installs them at ITS module scope — so importing it here is what makes
@@ -13,7 +14,7 @@ import type { CalloutTone, MailBlock, MailTemplate } from './blocks';
13
14
  // `registerMailCatalog()` themselves — the exact shape of issue #249.
14
15
  import './catalog';
15
16
  import { layoutUnknown, textMissing } from './errors';
16
- import { escapeHtml, safeUrl, styleAttr } from './html';
17
+ import { safeMailHref, styleAttr } from './html';
17
18
  import { layoutFor, registeredLayouts, token } from './layout';
18
19
 
19
20
  /** What the renderer needs from a mail. `MailDefinition` adds the input schema on top. */
@@ -75,7 +76,8 @@ export function renderMail<I>(
75
76
  });
76
77
 
77
78
  const tail = [...footer];
78
- if (unsubscribe !== undefined) tail.push(`${unsubscribe.label}: ${safeUrl(unsubscribe.url)}`);
79
+ if (unsubscribe !== undefined)
80
+ tail.push(`${unsubscribe.label}: ${safeMailHref(unsubscribe.url)}`);
79
81
  const text = tail.length === 0 ? bodyText : `${bodyText}\n\n--\n${tail.join('\n')}`;
80
82
 
81
83
  return { subject, preheader, html, text };
@@ -92,7 +94,7 @@ export function textOf(list: readonly MailBlock[], t: Translator): string {
92
94
  lines.push(t(block.key, block.vars));
93
95
  break;
94
96
  case 'button':
95
- lines.push(`${t(block.key, block.vars)}: ${safeUrl(block.href)}`);
97
+ lines.push(`${t(block.key, block.vars)}: ${safeMailHref(block.href)}`);
96
98
  break;
97
99
  case 'detail':
98
100
  lines.push(`${t(block.key)}: ${block.value}`);
@@ -162,7 +164,7 @@ function htmlOf(block: MailBlock, t: Translator): string {
162
164
  case 'paragraph':
163
165
  return `<p data-x="p" ${PARAGRAPH_STYLE}>${escapeHtml(t(block.key, block.vars))}</p>`;
164
166
  case 'button': {
165
- const href = escapeHtml(safeUrl(block.href));
167
+ const href = escapeHtml(safeMailHref(block.href));
166
168
  const label = escapeHtml(t(block.key, block.vars));
167
169
  const anchor = `<a data-x="btn" href="${href}" ${BUTTON_LINK_STYLE}>${label}</a>`;
168
170
  return `<table ${TABLE_ATTRS}><tr><td>${anchor}</td></tr></table>`;
@@ -0,0 +1,111 @@
1
+ // Single responsibility: a Resend webhook, received by an `api/` route, becomes `DeliveryEvent`s.
2
+ // The Svix signature is proven first (`resend-signature.ts`) over the exact bytes that arrived;
3
+ // only then is the body parsed. `email.delivered`, `email.bounced`, `email.complained` and
4
+ // `email.delivery_delayed` are modelled; every other type is authentic and `ignored`.
5
+
6
+ import { type Clock, finiteCount } from '@ultimat3/core';
7
+ import { t, validate } from '@ultimat3/schema';
8
+ import {
9
+ type DeliveryEvent,
10
+ type DeliveryOutcome,
11
+ type DeliveryReceiver,
12
+ deliveryInstant,
13
+ parseDeliveryJson,
14
+ readDeliveryBody,
15
+ resolveBodyLimit,
16
+ } from './delivery-event';
17
+ import { deliveryEventInvalid } from './delivery-event-errors';
18
+ import {
19
+ DEFAULT_SVIX_TOLERANCE_MS,
20
+ svixKey,
21
+ svixSecretBytes,
22
+ verifySvix,
23
+ } from './resend-signature';
24
+
25
+ export interface ResendEventReceiverOptions {
26
+ /** The endpoint's `whsec_…` signing secret, from `RESEND_WEBHOOK_SECRET`. Never logged. */
27
+ readonly secret: string;
28
+ /** Default `DEFAULT_SVIX_TOLERANCE_MS`. */
29
+ readonly toleranceMs?: number | undefined;
30
+ /** Default `DEFAULT_DELIVERY_BODY_LIMIT`. */
31
+ readonly maxBytes?: number | undefined;
32
+ readonly clock?: Clock | undefined;
33
+ }
34
+
35
+ const payload = t.object({
36
+ type: t.string,
37
+ created_at: t.string,
38
+ data: t.object({
39
+ email_id: t.string,
40
+ to: t.array(t.string),
41
+ bounce: t.object({ type: t.string.optional() }).optional(),
42
+ }),
43
+ });
44
+
45
+ /** A Map: `type` is the webhook body's, and `{ type: 'constructor' }` must be ignored, not mapped. */
46
+ const KINDS: ReadonlyMap<string, DeliveryEvent['kind']> = new Map<string, DeliveryEvent['kind']>([
47
+ ['email.delivered', 'delivered'],
48
+ ['email.bounced', 'bounced'],
49
+ ['email.complained', 'complained'],
50
+ ['email.delivery_delayed', 'delayed'],
51
+ ]);
52
+
53
+ /**
54
+ * A verified Resend payload → events. Resend names no single bounced address, so a multi-recipient
55
+ * email's bounce is one event per `to` — send one recipient per email to know which one it was.
56
+ */
57
+ export function resendDeliveryEvents(raw: unknown, eventId: string): DeliveryOutcome {
58
+ const head = validate(t.object({ type: t.string }), raw);
59
+ if (head.issues !== undefined) throw deliveryEventInvalid('resend', 'envelope');
60
+ const kind = KINDS.get(head.value.type);
61
+ if (kind === undefined) return { type: 'ignored', eventType: head.value.type };
62
+ const parsed = validate(payload, raw);
63
+ if (parsed.issues !== undefined) throw deliveryEventInvalid('resend', 'notification');
64
+ const { data } = parsed.value;
65
+ const at = deliveryInstant('resend', parsed.value.created_at);
66
+ // Resend relays SES's verdict: `Permanent` is hard; `Transient` and `Undetermined` are soft.
67
+ const bounce = data.bounce?.type === 'Permanent' ? 'hard' : 'soft';
68
+ const events = data.to.map((recipient): DeliveryEvent => {
69
+ const base = {
70
+ provider: 'resend' as const,
71
+ messageId: data.email_id,
72
+ recipient,
73
+ at,
74
+ eventId,
75
+ raw,
76
+ };
77
+ return kind === 'bounced' ? { ...base, kind, bounce } : { ...base, kind };
78
+ });
79
+ return { type: 'events', events };
80
+ }
81
+
82
+ export function resendEventReceiver(options: ResendEventReceiverOptions): DeliveryReceiver {
83
+ // The shape is refused HERE, at boot; only the Web Crypto import waits for the first delivery.
84
+ const secret = svixSecretBytes(options.secret);
85
+ let key: Promise<CryptoKey> | undefined;
86
+ const maxBytes = resolveBodyLimit('resendEventReceiver', options.maxBytes);
87
+ const toleranceMs = finiteCount(
88
+ 'resendEventReceiver',
89
+ 'toleranceMs',
90
+ options.toleranceMs ?? DEFAULT_SVIX_TOLERANCE_MS,
91
+ 1,
92
+ );
93
+
94
+ return {
95
+ async receive(request) {
96
+ const body = await readDeliveryBody('resend', request, maxBytes);
97
+ const headers = {
98
+ id: request.headers.get('svix-id'),
99
+ timestamp: request.headers.get('svix-timestamp'),
100
+ signature: request.headers.get('svix-signature'),
101
+ };
102
+ key ??= svixKey(secret);
103
+ const { eventId } = await verifySvix(headers, body, {
104
+ key: await key,
105
+ toleranceMs,
106
+ clock: options.clock,
107
+ });
108
+ return resendDeliveryEvents(parseDeliveryJson('resend', body), eventId);
109
+ },
110
+ };
111
+ }
@@ -0,0 +1,93 @@
1
+ // Single responsibility: prove a Resend webhook came from Resend. Resend signs with Svix: HMAC-SHA256
2
+ // over `<svix-id>.<svix-timestamp>.<body>` under the endpoint's `whsec_` secret, base64, sent as one
3
+ // or more `v1,<mac>` entries in `svix-signature` (more than one while a secret rotates). The mac is
4
+ // compared in constant time, and checked BEFORE the window, so "stale" always means authentic.
5
+
6
+ import { type Clock, ConfigInvalidError, systemClock, timingSafeEqual } from '@ultimat3/core';
7
+ import { deliveryEventUnverified } from './delivery-event-errors';
8
+
9
+ /** Svix's own default, and the replay bound every Svix sender already assumes. */
10
+ export const DEFAULT_SVIX_TOLERANCE_MS = 300_000;
11
+
12
+ /** Svix's `whsec_` prefix. Stripped by pattern: the prefix is public, the rest is the key. */
13
+ const SECRET_PREFIX = /^whsec_/;
14
+ /** Svix ids are `msg_<base62>`; a bound keeps a pathological header out of the mac input. */
15
+ const SVIX_ID = /^[A-Za-z0-9_-]{1,128}$/;
16
+ const SVIX_TIMESTAMP = /^\d{1,12}$/;
17
+
18
+ export interface SvixHeaders {
19
+ readonly id: string | null;
20
+ readonly timestamp: string | null;
21
+ readonly signature: string | null;
22
+ }
23
+
24
+ /** The key bytes of a `whsec_<base64>` secret — synchronous, so a bad one is refused at construction. */
25
+ export function svixSecretBytes(secret: string): Uint8Array<ArrayBuffer> {
26
+ const encoded = secret.replace(SECRET_PREFIX, '');
27
+ let bytes: Uint8Array<ArrayBuffer> | undefined;
28
+ try {
29
+ bytes = Uint8Array.from(atob(encoded), (char) => char.charCodeAt(0));
30
+ } catch {
31
+ bytes = undefined;
32
+ }
33
+ if (bytes === undefined || bytes.byteLength < 16) {
34
+ throw new ConfigInvalidError({
35
+ cause:
36
+ 'the Resend webhook secret is not a whsec_<base64> signing secret of at least 16 bytes',
37
+ fix: "resendEventReceiver({ secret: env.RESEND_WEBHOOK_SECRET }) — with the endpoint's whsec_ signing secret from https://resend.com/webhooks",
38
+ });
39
+ }
40
+ return bytes;
41
+ }
42
+
43
+ export function svixKey(bytes: Uint8Array<ArrayBuffer>): Promise<CryptoKey> {
44
+ return crypto.subtle.importKey('raw', bytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
45
+ }
46
+
47
+ function base64(bytes: ArrayBuffer): string {
48
+ let binary = '';
49
+ for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte);
50
+ return btoa(binary);
51
+ }
52
+
53
+ /** Every `v1` mac in the header. Other versions are skipped, as Svix's own verifier does. */
54
+ function v1Signatures(header: string): readonly string[] {
55
+ return header
56
+ .split(' ')
57
+ .filter((entry) => entry.startsWith('v1,'))
58
+ .map((entry) => entry.slice(3));
59
+ }
60
+
61
+ export interface SvixVerifyOptions {
62
+ readonly key: CryptoKey;
63
+ readonly toleranceMs: number;
64
+ readonly clock?: Clock | undefined;
65
+ }
66
+
67
+ /** Resolves when the delivery is authentic and fresh; rejects `X_MAIL_EVENT_UNVERIFIED` otherwise. */
68
+ export async function verifySvix(
69
+ headers: SvixHeaders,
70
+ body: string,
71
+ options: SvixVerifyOptions,
72
+ ): Promise<{ readonly eventId: string }> {
73
+ const { id, timestamp, signature } = headers;
74
+ if (id === null || timestamp === null || signature === null) {
75
+ throw deliveryEventUnverified('resend', 'unsigned');
76
+ }
77
+ if (!SVIX_ID.test(id) || !SVIX_TIMESTAMP.test(timestamp)) {
78
+ throw deliveryEventUnverified('resend', 'signature');
79
+ }
80
+ const signed = new TextEncoder().encode(`${id}.${timestamp}.${body}`);
81
+ const expected = base64(await crypto.subtle.sign('HMAC', options.key, signed));
82
+ // Every entry is compared, with no early exit, so the position of the match leaks nothing.
83
+ let matched = false;
84
+ for (const candidate of v1Signatures(signature)) {
85
+ if (timingSafeEqual(candidate, expected)) matched = true;
86
+ }
87
+ if (!matched) throw deliveryEventUnverified('resend', 'signature');
88
+ const now = (options.clock ?? systemClock).now().getTime();
89
+ if (Math.abs(now - Number(timestamp) * 1000) > options.toleranceMs) {
90
+ throw deliveryEventUnverified('resend', 'stale');
91
+ }
92
+ return { eventId: id };
93
+ }
@@ -0,0 +1,142 @@
1
+ // Single responsibility: keeping the exact MIME bytes a transport handed its provider, bounded. The
2
+ // two transports that BUILD the message (SMTP, SES) call this once per accepted send; Resend builds
3
+ // the MIME on its own side, so there are no bytes here to keep and its driver takes no such option.
4
+
5
+ import { ConfigInvalidError, finiteCount, logger, renderThrowable } from '@ultimat3/core';
6
+ import type { SendResult } from './driver';
7
+
8
+ /**
9
+ * 256 KiB: a transactional mail with both parts is 5–60 KiB, so this keeps every message this
10
+ * package can render with room to spare, and a process holding a thousand results stays bounded.
11
+ */
12
+ export const DEFAULT_RETAIN_MIME_MAX_BYTES = 262_144;
13
+
14
+ /** The largest cap accepted. Above it a retained message is a memory decision, not an audit one. */
15
+ export const RETAIN_MIME_CEILING_BYTES = 10_485_760;
16
+
17
+ /**
18
+ * What was kept. Over the cap the bytes are DROPPED, never truncated — a truncated message is not
19
+ * the message that was sent, and an audit row claiming otherwise is worse than none. The digest and
20
+ * the length survive either way, so a copy recovered elsewhere can still be proven to be this one.
21
+ */
22
+ export type RetainedMime =
23
+ | {
24
+ readonly kind: 'kept';
25
+ /** The RFC 5322 message before SMTP dot-stuffing and before any transfer encoding. */
26
+ readonly raw: string;
27
+ readonly byteLength: number;
28
+ readonly sha256: string;
29
+ }
30
+ | {
31
+ readonly kind: 'digest-only';
32
+ readonly byteLength: number;
33
+ readonly sha256: string;
34
+ /** The cap it exceeded. */
35
+ readonly maxBytes: number;
36
+ };
37
+
38
+ /** One accepted send's retained bytes, with the ids an audit row is keyed on. */
39
+ export interface RetainedMimeEntry {
40
+ readonly mailId: string;
41
+ /** `SendResult.id` — the provider's id, which is what a `DeliveryEvent.messageId` carries. */
42
+ readonly id: string;
43
+ readonly driver: string;
44
+ readonly idempotencyKey: string;
45
+ readonly mime: RetainedMime;
46
+ }
47
+
48
+ export interface RetainMimeOptions {
49
+ /** Default `DEFAULT_RETAIN_MIME_MAX_BYTES`; 1 to `RETAIN_MIME_CEILING_BYTES`. */
50
+ readonly maxBytes?: number | undefined;
51
+ /**
52
+ * The durable half. `SendResult.mime` reaches an inline caller only — a QUEUED send's result is
53
+ * the job run's return value, which the queue does not persist — so the row an auditor reads is
54
+ * written here. Called and NOT awaited; a throw is logged (`mail.retain_mime.failed`) and does
55
+ * NOT fail the send: the message already left, and failing it would have the job send it again.
56
+ */
57
+ readonly onRetained?: ((entry: RetainedMimeEntry) => void | Promise<void>) | undefined;
58
+ }
59
+
60
+ /**
61
+ * Refused at construction, where every other driver option is, and never on the first send — and
62
+ * by `selectMailDriver` before it picks a transport, so the ceiling is one verdict whatever env
63
+ * selects. `key` is where the option was written, named in the refusal: `retainMime` for a call,
64
+ * `mail.retainMime` when a boot read it from `app.config.ts`.
65
+ */
66
+ export function resolveRetainMime(
67
+ driver: string,
68
+ options: RetainMimeOptions | undefined,
69
+ key = 'retainMime',
70
+ ): RetainMimeOptions | undefined {
71
+ if (options === undefined) return undefined;
72
+ const maxBytes = finiteCount(
73
+ driver,
74
+ `${key}.maxBytes`,
75
+ options.maxBytes ?? DEFAULT_RETAIN_MIME_MAX_BYTES,
76
+ 1,
77
+ );
78
+ if (maxBytes > RETAIN_MIME_CEILING_BYTES) {
79
+ throw new ConfigInvalidError({
80
+ cause: `${driver} ${key}.maxBytes is ${maxBytes}, above the ${RETAIN_MIME_CEILING_BYTES}-byte ceiling a retained message may hold`,
81
+ fix: `${key}: { maxBytes: ${DEFAULT_RETAIN_MIME_MAX_BYTES} } — or drop maxBytes for that default`,
82
+ meta: { driver, key: `${key}.maxBytes` },
83
+ });
84
+ }
85
+ return { maxBytes, onRetained: options.onRetained };
86
+ }
87
+
88
+ const encoder = new TextEncoder();
89
+
90
+ /** The bytes as kept, or their digest alone when they exceed the cap. */
91
+ export function retainedMime(raw: string, maxBytes: number): RetainedMime {
92
+ const bytes = encoder.encode(raw);
93
+ const sha256 = new Bun.CryptoHasher('sha256').update(bytes).digest('hex');
94
+ return bytes.byteLength <= maxBytes
95
+ ? { kind: 'kept', raw, byteLength: bytes.byteLength, sha256 }
96
+ : { kind: 'digest-only', byteLength: bytes.byteLength, sha256, maxBytes };
97
+ }
98
+
99
+ /**
100
+ * Retain, hand the entry to the sink WITHOUT awaiting it, and answer what goes on `SendResult.mime`.
101
+ * Fire-and-forget, as `@ultimat3/mcp`'s audit hook is: the provider already accepted the message,
102
+ * and a sink that never settles would hold the job unsettled until its lease was reclaimed and the
103
+ * mail sent again. A throw, synchronous or rejected, is logged and never reaches the send.
104
+ */
105
+ export function keepMime(
106
+ options: RetainMimeOptions,
107
+ raw: string,
108
+ entry: Omit<RetainedMimeEntry, 'mime'>,
109
+ ): RetainedMime {
110
+ const mime = retainedMime(raw, options.maxBytes ?? DEFAULT_RETAIN_MIME_MAX_BYTES);
111
+ const sink = options.onRetained;
112
+ if (sink !== undefined) {
113
+ Promise.resolve()
114
+ .then(() => sink({ ...entry, mime }))
115
+ .catch((error: unknown) => {
116
+ logger.warn('mail.retain_mime.failed', {
117
+ mailId: entry.mailId,
118
+ driver: entry.driver,
119
+ id: entry.id,
120
+ reason: renderThrowable(error),
121
+ });
122
+ });
123
+ }
124
+ return mime;
125
+ }
126
+
127
+ /** `result` with `mime` attached when retention is on; the same object when it is off. */
128
+ export function withRetainedMime(
129
+ options: RetainMimeOptions | undefined,
130
+ result: SendResult,
131
+ mailId: string,
132
+ raw: string,
133
+ ): SendResult {
134
+ if (options === undefined) return result;
135
+ const mime = keepMime(options, raw, {
136
+ mailId,
137
+ id: result.id,
138
+ driver: result.driver,
139
+ idempotencyKey: result.idempotencyKey,
140
+ });
141
+ return { ...result, mime };
142
+ }
@@ -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
  });