@ultimat3/mail 24.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.
@@ -0,0 +1,103 @@
1
+ // Single responsibility: the ONE shape a provider's delivery notification becomes, and the reads
2
+ // both receivers share — the capped body, the JSON parse, the ISO instant. What a provider calls a
3
+ // bounce or a complaint is translated in its own file; what the APP does with one (suppress the
4
+ // address, flag the account) is the app's, and nothing here decides it.
5
+
6
+ import { finiteCount, readWithinLimit } from '@ultimat3/core';
7
+ import { isIsoDateTime } from '@ultimat3/schema';
8
+ import { type DeliveryProvider, deliveryEventInvalid } from './delivery-event-errors';
9
+
10
+ /** SNS caps a message at 256 KiB and JSON-escapes it once more inside its envelope. */
11
+ export const DEFAULT_DELIVERY_BODY_LIMIT = 1_048_576;
12
+
13
+ interface DeliveryEventBase {
14
+ readonly provider: DeliveryProvider;
15
+ /**
16
+ * The provider's id for the MESSAGE — `SendResult.id` from the driver that sent it (the SES
17
+ * `MessageId`, the Resend email id), so a notification joins the send it is about.
18
+ */
19
+ readonly messageId: string;
20
+ /** One recipient per event: a bounce naming three addresses is three events. */
21
+ readonly recipient: string;
22
+ /** When the provider says it happened — never when it reached this route. */
23
+ readonly at: Date;
24
+ /**
25
+ * The provider's id for the NOTIFICATION (SNS `MessageId`, `svix-id`), signed. With
26
+ * `recipient` it is the dedupe key: a provider redelivers, and a replay inside the window
27
+ * verifies again by design.
28
+ */
29
+ readonly eventId: string;
30
+ /** The verified notification as the provider sent it, parsed — every field this type drops. */
31
+ readonly raw: unknown;
32
+ }
33
+
34
+ export type DeliveryEvent =
35
+ | (DeliveryEventBase & { readonly kind: 'delivered' })
36
+ | (DeliveryEventBase & {
37
+ readonly kind: 'bounced';
38
+ /** `hard`: the address does not exist or refuses — stop sending. `soft`: it may clear. */
39
+ readonly bounce: 'hard' | 'soft';
40
+ })
41
+ | (DeliveryEventBase & { readonly kind: 'complained' })
42
+ | (DeliveryEventBase & { readonly kind: 'delayed' });
43
+
44
+ export type DeliveryEventKind = DeliveryEvent['kind'];
45
+
46
+ /** What a receiver answers. Every variant is a VERIFIED notification; a forgery is a throw. */
47
+ export type DeliveryOutcome =
48
+ | { readonly type: 'events'; readonly events: readonly DeliveryEvent[] }
49
+ /** Authentic, of a type this normaliser does not model (`email.opened`, an SES `Send`). */
50
+ | { readonly type: 'ignored'; readonly eventType: string }
51
+ /**
52
+ * An SNS `SubscriptionConfirmation`. `confirmed` is true only when the receiver was built with
53
+ * `confirmSubscriptions: true` and the confirm request succeeded; otherwise visit `confirmUrl`.
54
+ */
55
+ | {
56
+ readonly type: 'subscription';
57
+ readonly topicArn: string;
58
+ readonly confirmUrl: string;
59
+ readonly confirmed: boolean;
60
+ };
61
+
62
+ export interface DeliveryReceiver {
63
+ /** Verify, then normalise. Read the request once — its body is spent afterwards. */
64
+ receive(request: Request): Promise<DeliveryOutcome>;
65
+ }
66
+
67
+ const decoder = new TextDecoder('utf-8', { fatal: true });
68
+
69
+ /** The body as text, through core's counting reader so an oversized one is never held whole. */
70
+ export async function readDeliveryBody(
71
+ provider: DeliveryProvider,
72
+ request: Request,
73
+ maxBytes: number,
74
+ ): Promise<string> {
75
+ const read = await readWithinLimit(request.body, maxBytes);
76
+ if ('over' in read) throw deliveryEventInvalid(provider, 'too-large');
77
+ try {
78
+ return decoder.decode(read.bytes);
79
+ } catch {
80
+ throw deliveryEventInvalid(provider, 'not-utf8');
81
+ }
82
+ }
83
+
84
+ export function parseDeliveryJson(provider: DeliveryProvider, text: string): unknown {
85
+ try {
86
+ return JSON.parse(text);
87
+ } catch {
88
+ throw deliveryEventInvalid(provider, 'not-json');
89
+ }
90
+ }
91
+
92
+ /** An ISO-8601 instant with its offset, or a refusal — `new Date('garbage')` is not one. */
93
+ export function deliveryInstant(provider: DeliveryProvider, value: string): Date {
94
+ const at = isIsoDateTime(value) ? new Date(value) : undefined;
95
+ if (at === undefined || Number.isNaN(at.getTime())) {
96
+ throw deliveryEventInvalid(provider, 'timestamp');
97
+ }
98
+ return at;
99
+ }
100
+
101
+ export function resolveBodyLimit(subject: string, maxBytes: number | undefined): number {
102
+ return finiteCount(subject, 'maxBytes', maxBytes ?? DEFAULT_DELIVERY_BODY_LIMIT, 1);
103
+ }
package/src/driver-env.ts CHANGED
@@ -5,16 +5,46 @@
5
5
  // deploys to every environment.
6
6
 
7
7
  import { ConfigInvalidError, isLocal, resolveEnvironment } from '@ultimat3/core';
8
- import { createMemoryDriver, createUnconfiguredDriver, type MailDriver } from './driver';
9
- import { createResendDriver } from './driver-resend';
10
- import { createSmtpDriver } from './driver-smtp';
8
+ import { type MailDriver, memoryMailDriver, unconfiguredMailDriver } from './driver';
9
+ import { resendMailDriver } from './driver-resend';
10
+ import { sesMailDriver } from './driver-ses';
11
+ import { smtpMailDriver } from './driver-smtp';
12
+ import { type RetainMimeOptions, resolveRetainMime } from './retain-mime';
11
13
 
12
14
  /**
13
15
  * The MAIL keys read here, and nothing else. Named once so docs and tests cannot drift from the
14
16
  * code. `ULTIMATE_ENV`/`NODE_ENV` are deliberately absent: which deploy this is belongs to core's
15
17
  * one resolver, and restating it as a mail key would make it two settings with one meaning.
16
18
  */
17
- export const MAIL_ENV_KEYS = ['SMTP_URL', 'RESEND_API_KEY', 'MAIL_FROM', 'MAIL_POOL_SIZE'] as const;
19
+ export const MAIL_ENV_KEYS = [
20
+ 'SMTP_URL',
21
+ 'RESEND_API_KEY',
22
+ 'MAIL_FROM',
23
+ 'MAIL_POOL_SIZE',
24
+ 'SES_REGION',
25
+ 'SES_ACCESS_KEY_ID',
26
+ 'SES_SECRET_ACCESS_KEY',
27
+ 'SES_SESSION_TOKEN',
28
+ 'SES_ENDPOINT',
29
+ 'SES_CONFIGURATION_SET',
30
+ ] as const;
31
+
32
+ /** The key that SELECTS each transport. More than one set at once is refused, never resolved. */
33
+ const SELECTING_KEYS = ['SMTP_URL', 'RESEND_API_KEY', 'SES_REGION'] as const;
34
+
35
+ /**
36
+ * What a boot passes that an environment cannot: a function. `retainMime` reaches the transports
37
+ * that build the MIME (SMTP, SES); Resend builds it on its own side, so asking for it there is
38
+ * refused rather than silently keeping nothing.
39
+ */
40
+ export interface MailSelectOptions {
41
+ readonly retainMime?: RetainMimeOptions | undefined;
42
+ /**
43
+ * Where `retainMime` was written, named in a refusal so it points at the line to edit. Default
44
+ * `retainMime`; `@ultimat3/cli`'s boot passes `mail.retainMime`, the `app.config.ts` key.
45
+ */
46
+ readonly retainMimeKey?: string | undefined;
47
+ }
18
48
 
19
49
  export type MailEnvironment = Readonly<Record<string, string | undefined>>;
20
50
 
@@ -40,7 +70,7 @@ function requireFrom(env: MailEnvironment, selectedBy: string): string {
40
70
  if (from === undefined) {
41
71
  throw new ConfigInvalidError({
42
72
  cause: `${selectedBy} selects a mail transport, but MAIL_FROM is unset — no envelope sender`,
43
- fix: 'set MAIL_FROM="App <no-reply@yourdomain.test>" in .env.production',
73
+ fix: 'MAIL_FROM="App <no-reply@yourdomain.test>" — in .env.production',
44
74
  meta: { selectedBy, missing: 'MAIL_FROM' },
45
75
  });
46
76
  }
@@ -59,7 +89,7 @@ function poolSizeFrom(env: MailEnvironment): number | undefined {
59
89
  if (!Number.isInteger(parsed) || parsed < 1) {
60
90
  throw new ConfigInvalidError({
61
91
  cause: `MAIL_POOL_SIZE is "${raw}", which is not a whole number of connections >= 1`,
62
- fix: 'set MAIL_POOL_SIZE=4 in .env.production, or unset it to keep the default',
92
+ fix: 'MAIL_POOL_SIZE=4 — in .env.production, or unset it to keep the default',
63
93
  meta: { MAIL_POOL_SIZE: raw },
64
94
  });
65
95
  }
@@ -79,47 +109,90 @@ function poolSizeFrom(env: MailEnvironment): number | undefined {
79
109
  * and no error anywhere to find it by. `isLocal` is core's one reader of that question, so mail
80
110
  * cannot disagree with storage about which deploy this is.
81
111
  */
82
- export function selectMailDriver(env: MailEnvironment): MailSelection {
83
- const smtpUrl = nonEmpty(env['SMTP_URL']);
84
- const resendKey = nonEmpty(env['RESEND_API_KEY']);
85
-
86
- if (smtpUrl !== undefined && resendKey !== undefined) {
112
+ export function selectMailDriver(
113
+ env: MailEnvironment,
114
+ options: MailSelectOptions = {},
115
+ ): MailSelection {
116
+ // The cap first, before any branch: only SMTP and SES build a retaining transport, so a cap
117
+ // above the ceiling booted on memory, unconfigured and Resend, and the same options refused the
118
+ // boot the day `SMTP_URL` was set. One check, `resolveRetainMime`, the transports' own.
119
+ resolveRetainMime('selectMailDriver', options.retainMime, options.retainMimeKey);
120
+ const selected = SELECTING_KEYS.filter((key) => nonEmpty(env[key]) !== undefined);
121
+ if (selected.length > 1) {
87
122
  throw new ConfigInvalidError({
88
- cause: 'SMTP_URL and RESEND_API_KEY are both set — two transports claim the same mail',
123
+ cause: `${selected.join(' and ')} are ${selected.length === 2 ? 'both' : 'all'} set — ${selected.length} transports claim the same mail`,
89
124
  fix: 'unset one of them in .env.production: a process delivers through exactly one transport',
90
- meta: { selected: ['SMTP_URL', 'RESEND_API_KEY'] },
125
+ meta: { selected },
91
126
  });
92
127
  }
93
128
 
129
+ const smtpUrl = nonEmpty(env['SMTP_URL']);
94
130
  if (smtpUrl !== undefined) {
95
131
  const poolSize = poolSizeFrom(env);
96
132
  return {
97
- driver: createSmtpDriver({
133
+ driver: smtpMailDriver({
98
134
  url: smtpUrl,
99
135
  from: requireFrom(env, 'SMTP_URL'),
100
136
  ...(poolSize === undefined ? {} : { poolSize }),
137
+ retainMime: options.retainMime,
101
138
  }),
102
139
  detail: 'SMTP_URL',
103
140
  };
104
141
  }
105
142
 
143
+ const resendKey = nonEmpty(env['RESEND_API_KEY']);
106
144
  if (resendKey !== undefined) {
145
+ if (options.retainMime !== undefined) throw retainOnResend();
107
146
  return {
108
- driver: createResendDriver({ apiKey: resendKey, from: requireFrom(env, 'RESEND_API_KEY') }),
147
+ driver: resendMailDriver({ apiKey: resendKey, from: requireFrom(env, 'RESEND_API_KEY') }),
109
148
  detail: 'RESEND_API_KEY',
110
149
  };
111
150
  }
112
151
 
152
+ const sesRegion = nonEmpty(env['SES_REGION']);
153
+ if (sesRegion !== undefined) {
154
+ return { driver: sesFrom(env, sesRegion, options), detail: 'SES_REGION' };
155
+ }
156
+
113
157
  if (isLocal({ env })) {
114
158
  return {
115
- driver: createMemoryDriver(),
116
- detail: 'caught in memory — set SMTP_URL or RESEND_API_KEY to deliver',
159
+ driver: memoryMailDriver(),
160
+ detail: 'caught in memory — set SMTP_URL, RESEND_API_KEY or SES_REGION to deliver',
117
161
  };
118
162
  }
119
163
 
120
164
  const environment = resolveEnvironment({ env });
121
165
  return {
122
- driver: createUnconfiguredDriver(environment),
123
- detail: `no transport configured for ${environment} — set SMTP_URL or RESEND_API_KEY`,
166
+ driver: unconfiguredMailDriver(environment),
167
+ detail: `no transport configured for ${environment} — set SMTP_URL, RESEND_API_KEY or SES_REGION`,
124
168
  };
125
169
  }
170
+
171
+ /** The credential pair is required by `sesMailDriver`, which names whichever half is missing. */
172
+ function sesFrom(env: MailEnvironment, region: string, options: MailSelectOptions): MailDriver {
173
+ const sessionToken = nonEmpty(env['SES_SESSION_TOKEN']);
174
+ const endpoint = nonEmpty(env['SES_ENDPOINT']);
175
+ const configurationSet = nonEmpty(env['SES_CONFIGURATION_SET']);
176
+ return sesMailDriver({
177
+ region,
178
+ credentials: {
179
+ accessKeyId: nonEmpty(env['SES_ACCESS_KEY_ID']) ?? '',
180
+ secretAccessKey: nonEmpty(env['SES_SECRET_ACCESS_KEY']) ?? '',
181
+ ...(sessionToken === undefined ? {} : { sessionToken }),
182
+ },
183
+ from: requireFrom(env, 'SES_REGION'),
184
+ ...(endpoint === undefined ? {} : { endpoint }),
185
+ ...(configurationSet === undefined ? {} : { configurationSet }),
186
+ retainMime: options.retainMime,
187
+ });
188
+ }
189
+
190
+ function retainOnResend(): ConfigInvalidError {
191
+ return new ConfigInvalidError({
192
+ cause:
193
+ 'retainMime was asked for, and RESEND_API_KEY selects resend — which builds the MIME on ' +
194
+ "Resend's side, so this process never holds the bytes it would keep",
195
+ fix: 'selectMailDriver(env) — drop retainMime for resend, or select SMTP_URL or SES_REGION instead',
196
+ meta: { selected: 'RESEND_API_KEY' },
197
+ });
198
+ }
@@ -127,7 +127,7 @@ function bodyFor(message: MailMessage, from: string): ResendRequestBody {
127
127
  };
128
128
  }
129
129
 
130
- export function createResendDriver(options: ResendDriverOptions): MailDriver {
130
+ export function resendMailDriver(options: ResendDriverOptions): MailDriver {
131
131
  const apiKey = requireApiKey(options.apiKey);
132
132
  const from = requireFrom(options.from);
133
133
  const baseUrl = options.baseUrl ?? RESEND_BASE_URL;
@@ -137,7 +137,7 @@ export function createResendDriver(options: ResendDriverOptions): MailDriver {
137
137
  // the queue retried it to the dead-letter table for a value no network could change. `0` is not
138
138
  // "no deadline" either: the signal aborts on the next tick, before a byte leaves the host.
139
139
  const timeoutMs = finiteCount(
140
- 'createResendDriver',
140
+ 'resendMailDriver',
141
141
  'timeoutMs',
142
142
  options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
143
143
  1,
@@ -0,0 +1,205 @@
1
+ // Single responsibility: the Amazon SES transport. One SES v2 `SendEmail` per message, the body
2
+ // the raw MIME `mime.ts` builds, signed with core's one SigV4 signer and sent over `fetch` — no
3
+ // SDK. Construction refuses a missing credential, an unusable region or a bad endpoint, so a
4
+ // misconfiguration never waits for the first send.
5
+
6
+ import {
7
+ type AwsCredentials,
8
+ type Clock,
9
+ ConfigInvalidError,
10
+ EnvMissingError,
11
+ finiteCount,
12
+ isUltimateError,
13
+ nanoid,
14
+ renderFixShellArg,
15
+ renderThrowable,
16
+ signAwsRequest,
17
+ systemClock,
18
+ } from '@ultimat3/core';
19
+ import { base64Utf8 } from './base64';
20
+ import type { MailDriver, MailMessage, SendResult } from './driver';
21
+ import { resultFor } from './driver';
22
+ import type { MailFetch } from './driver-resend';
23
+ import { sendFailed } from './errors';
24
+ import { mailMessageIdToken } from './idempotency';
25
+ import { addressDomain, buildMimeMessage } from './mime';
26
+ import { type RetainMimeOptions, resolveRetainMime, withRetainedMime } from './retain-mime';
27
+ import { sesFailure } from './ses-failure';
28
+
29
+ const DEFAULT_TIMEOUT_MS = 15_000;
30
+ const SEND_PATH = '/v2/email/outbound-emails';
31
+ /** `us-east-1`, `eu-west-2`, `us-gov-west-1` — what AWS calls a region, and nothing a URL could hide in. */
32
+ const REGION = /^[a-z]{2}(-[a-z]+)+-\d{1,2}$/;
33
+
34
+ export interface SesDriverOptions {
35
+ /** Read from `SES_REGION`. The endpoint is `https://email.<region>.amazonaws.com`. */
36
+ readonly region: string;
37
+ /** Read from `SES_ACCESS_KEY_ID` / `SES_SECRET_ACCESS_KEY` (/ `SES_SESSION_TOKEN`). */
38
+ readonly credentials: AwsCredentials;
39
+ /** `Postly <no-reply@postly.test>`. Must be an identity verified in this region. */
40
+ readonly from: string;
41
+ /** A VPC endpoint or a local stand-in. Defaults to the region's public endpoint. */
42
+ readonly endpoint?: string | undefined;
43
+ /** `ConfigurationSetName` — the set whose event destination publishes Bounce/Complaint/Delivery. */
44
+ readonly configurationSet?: string | undefined;
45
+ readonly timeoutMs?: number | undefined;
46
+ readonly clock?: Clock | undefined;
47
+ /** Keep the exact MIME of every accepted send on `SendResult.mime` and hand it to `onRetained`. */
48
+ readonly retainMime?: RetainMimeOptions | undefined;
49
+ /** Injected in tests; production uses the global. */
50
+ readonly fetch?: MailFetch | undefined;
51
+ }
52
+
53
+ export const sesEndpoint = (region: string): string => `https://email.${region}.amazonaws.com`;
54
+
55
+ function requireRegion(region: string): string {
56
+ if (REGION.test(region)) return region;
57
+ throw new ConfigInvalidError({
58
+ cause:
59
+ 'SES_REGION is not an AWS region name, so no SES endpoint or signing scope exists for it',
60
+ fix: 'SES_REGION=us-east-1 — the region your sending identity is verified in',
61
+ meta: { key: 'SES_REGION' },
62
+ });
63
+ }
64
+
65
+ function requireCredentials(credentials: AwsCredentials): AwsCredentials {
66
+ const missing = [
67
+ ...(credentials.accessKeyId.trim() === '' ? ['SES_ACCESS_KEY_ID'] : []),
68
+ ...(credentials.secretAccessKey.trim() === '' ? ['SES_SECRET_ACCESS_KEY'] : []),
69
+ ];
70
+ if (missing.length === 0) return credentials;
71
+ throw new EnvMissingError({
72
+ cause: `${missing.join(' and ')} ${missing.length === 1 ? 'is' : 'are'} not set, so the ses driver cannot sign a request`,
73
+ fix: "SES_ACCESS_KEY_ID=<key id> SES_SECRET_ACCESS_KEY=<secret> — in .env or the container's secret store, then re-run",
74
+ meta: { missing },
75
+ });
76
+ }
77
+
78
+ function requireEndpoint(endpoint: string): string {
79
+ const url = URL.parse(endpoint);
80
+ if (
81
+ url !== null &&
82
+ (url.protocol === 'https:' || url.protocol === 'http:') &&
83
+ url.search === ''
84
+ ) {
85
+ return url.origin;
86
+ }
87
+ throw new ConfigInvalidError({
88
+ cause: 'SES_ENDPOINT is not an http(s) origin, so no SendEmail URL can be built from it',
89
+ fix: 'SES_ENDPOINT=https://email.us-east-1.amazonaws.com — or unset it for the region default',
90
+ meta: { key: 'SES_ENDPOINT' },
91
+ });
92
+ }
93
+
94
+ function requireFrom(from: string): string {
95
+ if (from.trim() !== '') return from;
96
+ throw new ConfigInvalidError({
97
+ cause: 'the ses driver was configured without a from address',
98
+ fix: 'aws sesv2 list-email-identities — then set MAIL_FROM to one verified in this region',
99
+ });
100
+ }
101
+
102
+ function isRecord(value: unknown): value is Record<string, unknown> {
103
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
104
+ }
105
+
106
+ /**
107
+ * `Destination` is the envelope, and it is sent even though the raw message holds `To:` and `Cc:`
108
+ * — `Bcc` is never a header (`mime.ts`), so without it the blind list would receive nothing.
109
+ */
110
+ function bodyFor(message: MailMessage, from: string, raw: string, set: string | undefined): string {
111
+ return JSON.stringify({
112
+ FromEmailAddress: from,
113
+ Destination: {
114
+ ToAddresses: message.to,
115
+ ...(message.cc === undefined ? {} : { CcAddresses: message.cc }),
116
+ ...(message.bcc === undefined ? {} : { BccAddresses: message.bcc }),
117
+ },
118
+ Content: { Raw: { Data: base64Utf8(raw) } },
119
+ ...(set === undefined ? {} : { ConfigurationSetName: set }),
120
+ });
121
+ }
122
+
123
+ export function sesMailDriver(options: SesDriverOptions): MailDriver {
124
+ const region = requireRegion(options.region);
125
+ const credentials = requireCredentials(options.credentials);
126
+ const from = requireFrom(options.from);
127
+ const origin = requireEndpoint(options.endpoint ?? sesEndpoint(region));
128
+ const timeoutMs = finiteCount(
129
+ 'sesMailDriver',
130
+ 'timeoutMs',
131
+ options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
132
+ 1,
133
+ );
134
+ const retain = resolveRetainMime('sesMailDriver', options.retainMime);
135
+ const clock = options.clock ?? systemClock;
136
+ const doFetch: MailFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
137
+
138
+ return { name: 'ses', send };
139
+
140
+ async function send(message: MailMessage): Promise<SendResult> {
141
+ // Content-derived like SMTP's, so every attempt presents one id. SES rewrites `Message-ID` to
142
+ // its own on the way out, and SES has no idempotency header: a retry after a timeout that SES
143
+ // had in fact accepted is a second email. That is SMTP's gap, not Resend's guarantee.
144
+ const raw = buildMimeMessage(message, {
145
+ from,
146
+ messageId: `<${message.mailId}.${mailMessageIdToken(message)}@${addressDomain(from)}>`,
147
+ date: clock.now(),
148
+ boundary: `x-ultimate-${nanoid(20)}`,
149
+ });
150
+ const body = bodyFor(message, from, raw, options.configurationSet);
151
+
152
+ let response: Response;
153
+ try {
154
+ const signed = await signAwsRequest({
155
+ method: 'POST',
156
+ url: `${origin}${SEND_PATH}`,
157
+ headers: { 'content-type': 'application/json' },
158
+ payload: { body },
159
+ credentials,
160
+ region,
161
+ service: 'ses',
162
+ clock,
163
+ });
164
+ response = await doFetch(signed.url, {
165
+ method: 'POST',
166
+ headers: signed.headers,
167
+ body,
168
+ signal: AbortSignal.timeout(timeoutMs),
169
+ });
170
+ } catch (error) {
171
+ // The signer's own refusals are coded and say which input to fix; only the network is left.
172
+ if (isUltimateError(error)) throw error;
173
+ throw sendFailed({
174
+ driver: 'ses',
175
+ stage: 'request',
176
+ detail: `${renderThrowable(error)} — nothing left this host for ${origin}${SEND_PATH} (egress, DNS or TLS)`,
177
+ retryable: true,
178
+ fix: `curl -sS -m 5 -o /dev/null ${renderFixShellArg(origin, '<SES endpoint>')}/`,
179
+ });
180
+ }
181
+
182
+ if (!response.ok) {
183
+ const failure = await sesFailure(response, region);
184
+ throw sendFailed({
185
+ driver: 'ses',
186
+ stage: 'request',
187
+ detail: failure.detail,
188
+ status: response.status,
189
+ retryable: failure.retryable,
190
+ fix: failure.fix,
191
+ });
192
+ }
193
+
194
+ const parsed: unknown = await response.json().catch(() => undefined);
195
+ const id = isRecord(parsed) ? parsed['MessageId'] : undefined;
196
+ // A 2xx is acceptance. The id is what SNS notifications name the message by, so a local one
197
+ // is a correlation lost — but never a reason to report an accepted send as failed.
198
+ const result = resultFor(
199
+ 'ses',
200
+ message,
201
+ typeof id === 'string' && id !== '' ? id : `ses_${nanoid(12)}`,
202
+ );
203
+ return withRetainedMime(retain, result, message.mailId, raw);
204
+ }
205
+ }
@@ -8,6 +8,7 @@ import {
8
8
  finiteCount,
9
9
  isUltimateError,
10
10
  nanoid,
11
+ renderFixShellArg,
11
12
  renderThrowable,
12
13
  systemClock,
13
14
  } from '@ultimat3/core';
@@ -16,6 +17,7 @@ import { envelopeRecipients, type MailDriver, type MailMessage, resultFor } from
16
17
  import { sendFailed } from './errors';
17
18
  import { mailMessageIdToken } from './idempotency';
18
19
  import { addressDomain, buildMimeMessage } from './mime';
20
+ import { type RetainMimeOptions, resolveRetainMime, withRetainedMime } from './retain-mime';
19
21
  import {
20
22
  type SmtpConnector,
21
23
  type SmtpSessionOptions,
@@ -43,6 +45,8 @@ export interface SmtpDriverOptions {
43
45
  /** Speak to a server that offers no STARTTLS. Off by default: mail and password in the clear. */
44
46
  readonly allowInsecure?: boolean | undefined;
45
47
  readonly clock?: Clock | undefined;
48
+ /** Keep the exact MIME of every accepted send on `SendResult.mime` and hand it to `onRetained`. */
49
+ readonly retainMime?: RetainMimeOptions | undefined;
46
50
  /** Injected in tests; production dials with `Bun.connect`. */
47
51
  readonly connect?: SmtpConnector | undefined;
48
52
  }
@@ -91,7 +95,7 @@ function parseSmtpUrl(raw: string): SmtpUrl {
91
95
 
92
96
  /**
93
97
  * `decodeURIComponent` throws a bare `URIError` for a `%` that starts no escape, which escaped
94
- * `createSmtpDriver` as the one SMTP_URL mistake that was not a config error. The value is a
98
+ * `smtpMailDriver` as the one SMTP_URL mistake that was not a config error. The value is a
95
99
  * credential, so neither the cause nor the fix ever carries it.
96
100
  */
97
101
  function decodedCredential(part: 'user' | 'password', raw: string): string {
@@ -101,7 +105,7 @@ function decodedCredential(part: 'user' | 'password', raw: string): string {
101
105
  throw new ConfigInvalidError({
102
106
  cause: `the SMTP_URL ${part} holds a "%" that starts no percent-escape, so it cannot be decoded`,
103
107
  // `prompt`, so the credential reaches neither shell history nor this message.
104
- fix: `percent-encode the ${part} and put the output in SMTP_URL: bun -e 'console.log(encodeURIComponent(prompt("${part}:") ?? ""))'`,
108
+ fix: `percent-encode the ${part} and put the output in SMTP_URL: bun -e 'console.log(encodeURIComponent(prompt("${renderFixShellArg(part, 'value')}:") ?? ""))'`,
105
109
  meta: { part },
106
110
  });
107
111
  }
@@ -148,7 +152,7 @@ function createLimiter(size: number): (run: () => Promise<SendResult>) => Promis
148
152
  };
149
153
  }
150
154
 
151
- export function createSmtpDriver(options: SmtpDriverOptions): MailDriver {
155
+ export function smtpMailDriver(options: SmtpDriverOptions): MailDriver {
152
156
  const target = parseSmtpUrl(options.url);
153
157
  if (options.from.trim() === '') {
154
158
  throw new ConfigInvalidError({
@@ -163,12 +167,13 @@ export function createSmtpDriver(options: SmtpDriverOptions): MailDriver {
163
167
  // non-finite deadline does not disable itself, it fires on the next tick and every send fails
164
168
  // "the server sent nothing for NaNms". `0` is the same failure spelled deliberately.
165
169
  const timeoutMs = finiteCount(
166
- 'createSmtpDriver',
170
+ 'smtpMailDriver',
167
171
  'timeoutMs',
168
172
  options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
169
173
  1,
170
174
  );
171
175
  const limit = createLimiter(resolvePoolSize(options.poolSize));
176
+ const retain = resolveRetainMime('smtpMailDriver', options.retainMime);
172
177
  const session: SmtpSessionOptions = {
173
178
  clientName: options.clientName ?? addressDomain(options.from),
174
179
  secure: target.tls,
@@ -223,7 +228,6 @@ export function createSmtpDriver(options: SmtpDriverOptions): MailDriver {
223
228
  { from: options.from, recipients: envelopeRecipients(message), data },
224
229
  session,
225
230
  );
226
- return resultFor('smtp', message, messageId);
227
231
  } catch (error) {
228
232
  // A framework error already names its stage; anything else (a socket reset mid-DATA) would
229
233
  // otherwise escape as a bare Error and lose the code the queue's dead-letter view reads.
@@ -238,6 +242,9 @@ export function createSmtpDriver(options: SmtpDriverOptions): MailDriver {
238
242
  } finally {
239
243
  stream.close();
240
244
  }
245
+ // Past the try on purpose: the server took the message, so nothing from here on may be
246
+ // reported as a send failure — a retry of an accepted send is a second email.
247
+ return withRetainedMime(retain, resultFor('smtp', message, messageId), message.mailId, data);
241
248
  }
242
249
  }
243
250
 
package/src/driver.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  } from '@ultimat3/core';
14
14
  import { driverUnavailable, mailCredentialMissing } from './errors';
15
15
  import { mailIdempotencyKey, mailMessageIdToken } from './idempotency';
16
+ import type { RetainedMime } from './retain-mime';
16
17
 
17
18
  /** The rendered envelope. Everything a transport needs; nothing it does not. */
18
19
  export interface MailMessage {
@@ -46,6 +47,11 @@ export interface SendResult {
46
47
  /** True when the message was handed to the queue instead of a transport. */
47
48
  readonly queued: boolean;
48
49
  readonly idempotencyKey: string;
50
+ /**
51
+ * The exact MIME this process handed the provider — present only on a transport that builds it
52
+ * (SMTP, SES) constructed with `retainMime`. Bounded: over the cap only the digest is kept.
53
+ */
54
+ readonly mime?: RetainedMime | undefined;
49
55
  }
50
56
 
51
57
  export interface MailDriver {
@@ -128,7 +134,7 @@ export interface MemoryMailDriver extends MailDriver {
128
134
  }
129
135
 
130
136
  /**
131
- * The same `{ clock }` shape `@ultimat3/jobs`' `createMemoryDriver` takes, deliberately: two
137
+ * The same `{ clock }` shape `@ultimat3/jobs`' `memoryJobDriver` takes, deliberately: two
132
138
  * in-memory drivers named the same thing in one test file may not want two spellings of "freeze
133
139
  * time". An options object rather than a positional argument so the next seam is additive.
134
140
  */
@@ -141,7 +147,7 @@ export interface MemoryMailDriverOptions {
141
147
  * `/_x` panel ORDER on, so a suite asserting which message is newest could only race the wall
142
148
  * clock — two sends inside one millisecond tie, and nothing could state the intended order.
143
149
  */
144
- export function createMemoryDriver(options: MemoryMailDriverOptions = {}): MemoryMailDriver {
150
+ export function memoryMailDriver(options: MemoryMailDriverOptions = {}): MemoryMailDriver {
145
151
  const clock = options.clock ?? systemClock;
146
152
  const sent: SentMail[] = [];
147
153
  return {
@@ -187,7 +193,7 @@ export const UNCONFIGURED_DRIVER_NAME = 'unconfigured';
187
193
  * that does sends nothing silently. The memory driver in that position answered `accepted` for
188
194
  * every message — a password reset reported as delivered, with no error anywhere to find it by.
189
195
  */
190
- export function createUnconfiguredDriver(environment: Environment): MailDriver {
196
+ export function unconfiguredMailDriver(environment: Environment): MailDriver {
191
197
  return {
192
198
  name: UNCONFIGURED_DRIVER_NAME,
193
199
  // Rejected rather than thrown: `send()` and `sendMailJob` both await this, and a synchronous
@@ -213,7 +219,7 @@ export function isUnconfiguredDriver(driver: MailDriver): boolean {
213
219
  * framework's existing spelling for the same key digested — it was written for exactly this
214
220
  * reason and says so — and it correlates just as well, because it is stable per message.
215
221
  */
216
- export function createLogDriver(logger = rootLogger): MailDriver {
222
+ export function logMailDriver(logger = rootLogger): MailDriver {
217
223
  return {
218
224
  name: 'log',
219
225
  send(message: MailMessage): Promise<SendResult> {