@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.
- package/CLAUDE.md +57 -7
- package/README.md +78 -11
- package/package.json +9 -8
- package/src/catalog.ts +3 -12
- package/src/delivery-event-errors.ts +148 -0
- package/src/delivery-event-ses.ts +101 -0
- package/src/delivery-event.ts +103 -0
- package/src/driver-env.ts +92 -19
- package/src/driver-resend.ts +2 -2
- package/src/driver-ses.ts +205 -0
- package/src/driver-smtp.ts +12 -5
- package/src/driver.ts +10 -4
- package/src/errors.ts +13 -6
- package/src/events.ts +28 -0
- package/src/html.ts +1 -1
- package/src/index.ts +20 -9
- package/src/layout.ts +2 -2
- package/src/render.ts +7 -6
- package/src/resend-event-receiver.ts +111 -0
- package/src/resend-signature.ts +93 -0
- package/src/retain-mime.ts +142 -0
- package/src/ses-event-receiver.ts +141 -0
- package/src/ses-failure.ts +114 -0
- package/src/smtp-client.ts +3 -3
- package/src/smtp-socket.ts +1 -1
- package/src/sns-certificate-cache.ts +111 -0
- package/src/sns-signature.ts +187 -0
- package/src/transform.ts +13 -3
- package/src/x509-spki.ts +67 -0
package/src/errors.ts
CHANGED
|
@@ -21,6 +21,9 @@ export const MAIL_ERROR_CODES = [
|
|
|
21
21
|
'X_MAIL_ADDRESS_INVALID',
|
|
22
22
|
'X_MAIL_SEND_FAILED',
|
|
23
23
|
'X_MAIL_TRANSFORM_FAILED',
|
|
24
|
+
'X_MAIL_EVENT_UNVERIFIED',
|
|
25
|
+
'X_MAIL_EVENT_INVALID',
|
|
26
|
+
'X_MAIL_EVENT_PROVIDER_UNREACHABLE',
|
|
24
27
|
] as const;
|
|
25
28
|
|
|
26
29
|
export type MailErrorCode = (typeof MAIL_ERROR_CODES)[number];
|
|
@@ -36,6 +39,9 @@ export const MAIL_ERROR_TITLES: Readonly<Record<MailErrorCode, string>> = {
|
|
|
36
39
|
X_MAIL_ADDRESS_INVALID: 'an envelope address could restructure the SMTP command line',
|
|
37
40
|
X_MAIL_SEND_FAILED: 'the mail transport refused the message',
|
|
38
41
|
X_MAIL_TRANSFORM_FAILED: 'the app mail transform threw or returned no message',
|
|
42
|
+
X_MAIL_EVENT_UNVERIFIED: 'a delivery notification is not signed by its provider',
|
|
43
|
+
X_MAIL_EVENT_INVALID: 'a delivery notification is not a shape this receiver reads',
|
|
44
|
+
X_MAIL_EVENT_PROVIDER_UNREACHABLE: 'an SNS endpoint the receiver needed did not answer',
|
|
39
45
|
};
|
|
40
46
|
|
|
41
47
|
// Titles must be registered for `format()` to render the contract's first line. Every code above is
|
|
@@ -159,7 +165,7 @@ export const driverUnavailable = (what: string): MailError =>
|
|
|
159
165
|
new MailError({
|
|
160
166
|
code: 'X_MAIL_DRIVER_UNAVAILABLE',
|
|
161
167
|
cause: `${what} — nothing can deliver the message`,
|
|
162
|
-
fix: 'setMailDriver(
|
|
168
|
+
fix: 'setMailDriver(memoryMailDriver()) in dev, smtpMailDriver({ url: env.SMTP_URL }) live',
|
|
163
169
|
meta: { what },
|
|
164
170
|
});
|
|
165
171
|
|
|
@@ -177,12 +183,13 @@ export const mailCredentialMissing = (environment: Environment): MailError =>
|
|
|
177
183
|
new MailError({
|
|
178
184
|
code: 'X_MAIL_CREDENTIAL_MISSING',
|
|
179
185
|
cause:
|
|
180
|
-
`
|
|
181
|
-
'transport — the message was not delivered and was not queued anywhere',
|
|
186
|
+
`none of SMTP_URL, RESEND_API_KEY or SES_REGION is set in ${environment}, so this process ` +
|
|
187
|
+
'has no transport — the message was not delivered and was not queued anywhere',
|
|
182
188
|
fix:
|
|
183
|
-
'set SMTP_URL="smtps://user:pass@host:465" (or RESEND_API_KEY=re_
|
|
189
|
+
'set SMTP_URL="smtps://user:pass@host:465" (or RESEND_API_KEY=re_..., or SES_REGION with ' +
|
|
190
|
+
'SES_ACCESS_KEY_ID and SES_SECRET_ACCESS_KEY) and ' +
|
|
184
191
|
'MAIL_FROM="App <no-reply@yourdomain.test>" in the deployment environment, then restart',
|
|
185
|
-
meta: { environment, missing: ['SMTP_URL', 'RESEND_API_KEY'] },
|
|
192
|
+
meta: { environment, missing: ['SMTP_URL', 'RESEND_API_KEY', 'SES_REGION'] },
|
|
186
193
|
});
|
|
187
194
|
|
|
188
195
|
/**
|
|
@@ -309,7 +316,7 @@ export type SendStage =
|
|
|
309
316
|
* code or an HTTP status — because "mail failed" without either is not a diagnosis.
|
|
310
317
|
*/
|
|
311
318
|
export interface SendFailure {
|
|
312
|
-
readonly driver: 'smtp' | 'resend';
|
|
319
|
+
readonly driver: 'smtp' | 'resend' | 'ses';
|
|
313
320
|
readonly stage: SendStage;
|
|
314
321
|
readonly detail: string;
|
|
315
322
|
/** SMTP reply code (4xx/5xx) or HTTP status. Absent when the socket never answered. */
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Single responsibility: the public API of `@ultimat3/mail/events` — the delivery-notification
|
|
2
|
+
// receivers and their verification. A subpath and not the barrel because only an app's webhook
|
|
3
|
+
// route uses them, and every serving role (worker, scheduler, migrate) loads the barrel to send.
|
|
4
|
+
|
|
5
|
+
export type {
|
|
6
|
+
DeliveryEvent,
|
|
7
|
+
DeliveryEventKind,
|
|
8
|
+
DeliveryOutcome,
|
|
9
|
+
DeliveryReceiver,
|
|
10
|
+
} from './delivery-event';
|
|
11
|
+
export { DEFAULT_DELIVERY_BODY_LIMIT } from './delivery-event';
|
|
12
|
+
export type {
|
|
13
|
+
DeliveryProvider,
|
|
14
|
+
DeliveryRefusal,
|
|
15
|
+
DeliveryShapeProblem,
|
|
16
|
+
ResendRefusal,
|
|
17
|
+
} from './delivery-event-errors';
|
|
18
|
+
export {
|
|
19
|
+
deliveryEventInvalid,
|
|
20
|
+
deliveryEventUnverified,
|
|
21
|
+
deliveryProviderUnreachable,
|
|
22
|
+
} from './delivery-event-errors';
|
|
23
|
+
export type { ResendEventReceiverOptions } from './resend-event-receiver';
|
|
24
|
+
export { resendEventReceiver } from './resend-event-receiver';
|
|
25
|
+
export { DEFAULT_SVIX_TOLERANCE_MS } from './resend-signature';
|
|
26
|
+
export type { SesEventReceiverOptions } from './ses-event-receiver';
|
|
27
|
+
export { DEFAULT_SNS_TOLERANCE_MS, sesEventReceiver } from './ses-event-receiver';
|
|
28
|
+
export type { SnsCertificateFetch } from './sns-certificate-cache';
|
package/src/html.ts
CHANGED
|
@@ -10,7 +10,7 @@ const SAFE_PROTOCOLS: readonly string[] = ['http:', 'https:', 'mailto:'];
|
|
|
10
10
|
* `javascript:` and `data:` hrefs are stripped rather than escaped: some clients still
|
|
11
11
|
* follow them, and a link the recipient cannot trust is worse than a dead one.
|
|
12
12
|
*/
|
|
13
|
-
export function
|
|
13
|
+
export function safeMailHref(value: string): string {
|
|
14
14
|
let parsed: URL;
|
|
15
15
|
try {
|
|
16
16
|
parsed = new URL(value);
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
// Single responsibility: the public API of @ultimat3/mail. Explicit named exports only —
|
|
2
2
|
// other packages call `defineMail`, `send` and the driver seam, and nothing else.
|
|
3
3
|
|
|
4
|
-
export { escapeHtml } from '@ultimat3/core';
|
|
5
4
|
/** Re-exported so a `defineMail` file needs one import, not two. Same object as schema's. */
|
|
6
5
|
export type { Infer } from '@ultimat3/schema';
|
|
7
6
|
export { t } from '@ultimat3/schema';
|
|
8
7
|
export type { CalloutTone, MailBlock, MailTemplate, TemplateArgs } from './blocks';
|
|
9
8
|
export { blocks } from './blocks';
|
|
10
|
-
export { MAIL_CATALOG
|
|
9
|
+
export { MAIL_CATALOG } from './catalog';
|
|
10
|
+
// Type-only, so a sender can name an event without loading a receiver; the receivers are
|
|
11
|
+
// `@ultimat3/mail/events`, kept off the barrel every serving role evaluates.
|
|
12
|
+
export type {
|
|
13
|
+
DeliveryEvent,
|
|
14
|
+
DeliveryEventKind,
|
|
15
|
+
DeliveryOutcome,
|
|
16
|
+
DeliveryReceiver,
|
|
17
|
+
} from './delivery-event';
|
|
11
18
|
export type {
|
|
12
19
|
MailDriver,
|
|
13
20
|
MailMessage,
|
|
@@ -17,23 +24,25 @@ export type {
|
|
|
17
24
|
SentMail,
|
|
18
25
|
} from './driver';
|
|
19
26
|
export {
|
|
20
|
-
createLogDriver,
|
|
21
|
-
createMemoryDriver,
|
|
22
|
-
createUnconfiguredDriver,
|
|
23
27
|
isMemoryDriver,
|
|
24
28
|
isUnconfiguredDriver,
|
|
29
|
+
logMailDriver,
|
|
25
30
|
mailDriver,
|
|
31
|
+
memoryMailDriver,
|
|
26
32
|
messageHeaders,
|
|
27
33
|
resetMailDriver,
|
|
28
34
|
setMailDriver,
|
|
29
35
|
tryMailDriver,
|
|
36
|
+
unconfiguredMailDriver,
|
|
30
37
|
} from './driver';
|
|
31
|
-
export type { MailEnvironment, MailSelection } from './driver-env';
|
|
38
|
+
export type { MailEnvironment, MailSelection, MailSelectOptions } from './driver-env';
|
|
32
39
|
export { selectMailDriver } from './driver-env';
|
|
33
40
|
export type { MailFetch, ResendDriverOptions } from './driver-resend';
|
|
34
|
-
export {
|
|
41
|
+
export { resendMailDriver } from './driver-resend';
|
|
42
|
+
export type { SesDriverOptions } from './driver-ses';
|
|
43
|
+
export { sesEndpoint, sesMailDriver } from './driver-ses';
|
|
35
44
|
export type { SmtpDriverOptions } from './driver-smtp';
|
|
36
|
-
export {
|
|
45
|
+
export { smtpMailDriver } from './driver-smtp';
|
|
37
46
|
export { assertEnvelopeAddress } from './envelope-address';
|
|
38
47
|
export type {
|
|
39
48
|
AddressRefusal,
|
|
@@ -61,7 +70,7 @@ export {
|
|
|
61
70
|
transformFailed,
|
|
62
71
|
} from './errors';
|
|
63
72
|
export { assertHeaderSafe } from './header-safety';
|
|
64
|
-
export {
|
|
73
|
+
export { safeMailHref } from './html';
|
|
65
74
|
|
|
66
75
|
export { mailIdempotencyKey, mailMessageIdToken } from './idempotency';
|
|
67
76
|
export { mailMessageSchema, sendMailJob } from './job';
|
|
@@ -95,6 +104,8 @@ export {
|
|
|
95
104
|
} from './mail';
|
|
96
105
|
export type { RenderableMail, RenderedMail, RenderOptions } from './render';
|
|
97
106
|
export { renderMail, textOf } from './render';
|
|
107
|
+
export type { RetainedMime, RetainedMimeEntry, RetainMimeOptions } from './retain-mime';
|
|
108
|
+
export { DEFAULT_RETAIN_MIME_MAX_BYTES, RETAIN_MIME_CEILING_BYTES } from './retain-mime';
|
|
98
109
|
export type { SmtpConnector, SmtpStream } from './smtp-client';
|
|
99
110
|
export {
|
|
100
111
|
FRAMEWORK_MAILS,
|
package/src/layout.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { escapeHtml } from '@ultimat3/core';
|
|
7
7
|
import { mailLayoutDuplicate } from './errors';
|
|
8
|
-
import {
|
|
8
|
+
import { safeMailHref, styleAttr } from './html';
|
|
9
9
|
|
|
10
10
|
export type ColorScheme = 'light' | 'dark';
|
|
11
11
|
|
|
@@ -200,7 +200,7 @@ function footerHtml(input: LayoutInput): string {
|
|
|
200
200
|
const lines = input.footer.map((line) => `<div>${escapeHtml(line)}</div>`);
|
|
201
201
|
const slot = input.unsubscribe;
|
|
202
202
|
if (slot !== undefined) {
|
|
203
|
-
const href = escapeHtml(
|
|
203
|
+
const href = escapeHtml(safeMailHref(slot.url));
|
|
204
204
|
const label = escapeHtml(slot.label);
|
|
205
205
|
lines.push(`<div><a data-x="a" href="${href}" ${UNSUBSCRIBE_STYLE}>${label}</a></div>`);
|
|
206
206
|
}
|
package/src/render.ts
CHANGED
|
@@ -3,8 +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 { escapeHtml } from '@ultimat3/core';
|
|
7
|
-
import {
|
|
6
|
+
import { directionOf, escapeHtml } from '@ultimat3/core';
|
|
7
|
+
import { type TranslateVars, type Translator, translatorFor } from '@ultimat3/i18n';
|
|
8
8
|
import type { CalloutTone, MailBlock, MailTemplate } from './blocks';
|
|
9
9
|
// The renderer depends on the strings it renders. This is the only module that resolves a `mail.*`
|
|
10
10
|
// key, and `catalog.ts` installs them at ITS module scope — so importing it here is what makes
|
|
@@ -14,7 +14,7 @@ import type { CalloutTone, MailBlock, MailTemplate } from './blocks';
|
|
|
14
14
|
// `registerMailCatalog()` themselves — the exact shape of issue #249.
|
|
15
15
|
import './catalog';
|
|
16
16
|
import { layoutUnknown, textMissing } from './errors';
|
|
17
|
-
import {
|
|
17
|
+
import { safeMailHref, styleAttr } from './html';
|
|
18
18
|
import { layoutFor, registeredLayouts, token } from './layout';
|
|
19
19
|
|
|
20
20
|
/** What the renderer needs from a mail. `MailDefinition` adds the input schema on top. */
|
|
@@ -76,7 +76,8 @@ export function renderMail<I>(
|
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
const tail = [...footer];
|
|
79
|
-
if (unsubscribe !== undefined)
|
|
79
|
+
if (unsubscribe !== undefined)
|
|
80
|
+
tail.push(`${unsubscribe.label}: ${safeMailHref(unsubscribe.url)}`);
|
|
80
81
|
const text = tail.length === 0 ? bodyText : `${bodyText}\n\n--\n${tail.join('\n')}`;
|
|
81
82
|
|
|
82
83
|
return { subject, preheader, html, text };
|
|
@@ -93,7 +94,7 @@ export function textOf(list: readonly MailBlock[], t: Translator): string {
|
|
|
93
94
|
lines.push(t(block.key, block.vars));
|
|
94
95
|
break;
|
|
95
96
|
case 'button':
|
|
96
|
-
lines.push(`${t(block.key, block.vars)}: ${
|
|
97
|
+
lines.push(`${t(block.key, block.vars)}: ${safeMailHref(block.href)}`);
|
|
97
98
|
break;
|
|
98
99
|
case 'detail':
|
|
99
100
|
lines.push(`${t(block.key)}: ${block.value}`);
|
|
@@ -163,7 +164,7 @@ function htmlOf(block: MailBlock, t: Translator): string {
|
|
|
163
164
|
case 'paragraph':
|
|
164
165
|
return `<p data-x="p" ${PARAGRAPH_STYLE}>${escapeHtml(t(block.key, block.vars))}</p>`;
|
|
165
166
|
case 'button': {
|
|
166
|
-
const href = escapeHtml(
|
|
167
|
+
const href = escapeHtml(safeMailHref(block.href));
|
|
167
168
|
const label = escapeHtml(t(block.key, block.vars));
|
|
168
169
|
const anchor = `<a data-x="btn" href="${href}" ${BUTTON_LINK_STYLE}>${label}</a>`;
|
|
169
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
|
+
}
|