@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/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(createMemoryDriver()) in dev, createSmtpDriver({ url: env.SMTP_URL }) live',
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
- `neither SMTP_URL nor RESEND_API_KEY is set in ${environment}, so this process has no ` +
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_...) and ' +
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 safeUrl(value: string): string {
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, MAIL_CATALOG_LOCALE } from './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 { createResendDriver } from './driver-resend';
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 { createSmtpDriver } from './driver-smtp';
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 { safeUrl } from './html';
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 { safeUrl, styleAttr } from './html';
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(safeUrl(slot.url));
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 { 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';
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 { safeUrl, styleAttr } from './html';
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) tail.push(`${unsubscribe.label}: ${safeUrl(unsubscribe.url)}`);
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)}: ${safeUrl(block.href)}`);
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(safeUrl(block.href));
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
+ }