@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.
@@ -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
  }
@@ -84,11 +88,29 @@ function parseSmtpUrl(raw: string): SmtpUrl {
84
88
  port: url.port === '' ? (tls ? IMPLICIT_TLS_PORT : SUBMISSION_PORT) : Number(url.port),
85
89
  tls,
86
90
  // Credentials are percent-encoded in a URL; a password with `@` or `/` is otherwise unusable.
87
- ...(url.username === '' ? {} : { user: decodeURIComponent(url.username) }),
88
- ...(url.password === '' ? {} : { password: decodeURIComponent(url.password) }),
91
+ ...(url.username === '' ? {} : { user: decodedCredential('user', url.username) }),
92
+ ...(url.password === '' ? {} : { password: decodedCredential('password', url.password) }),
89
93
  };
90
94
  }
91
95
 
96
+ /**
97
+ * `decodeURIComponent` throws a bare `URIError` for a `%` that starts no escape, which escaped
98
+ * `smtpMailDriver` as the one SMTP_URL mistake that was not a config error. The value is a
99
+ * credential, so neither the cause nor the fix ever carries it.
100
+ */
101
+ function decodedCredential(part: 'user' | 'password', raw: string): string {
102
+ try {
103
+ return decodeURIComponent(raw);
104
+ } catch {
105
+ throw new ConfigInvalidError({
106
+ cause: `the SMTP_URL ${part} holds a "%" that starts no percent-escape, so it cannot be decoded`,
107
+ // `prompt`, so the credential reaches neither shell history nor this message.
108
+ fix: `percent-encode the ${part} and put the output in SMTP_URL: bun -e 'console.log(encodeURIComponent(prompt("${renderFixShellArg(part, 'value')}:") ?? ""))'`,
109
+ meta: { part },
110
+ });
111
+ }
112
+ }
113
+
92
114
  /**
93
115
  * `poolSize: 0` is the one config mistake this driver could not report: the limiter would park
94
116
  * every send on a slot that is never handed out, with no deadline on the wait, so the job would
@@ -130,7 +152,7 @@ function createLimiter(size: number): (run: () => Promise<SendResult>) => Promis
130
152
  };
131
153
  }
132
154
 
133
- export function createSmtpDriver(options: SmtpDriverOptions): MailDriver {
155
+ export function smtpMailDriver(options: SmtpDriverOptions): MailDriver {
134
156
  const target = parseSmtpUrl(options.url);
135
157
  if (options.from.trim() === '') {
136
158
  throw new ConfigInvalidError({
@@ -145,12 +167,13 @@ export function createSmtpDriver(options: SmtpDriverOptions): MailDriver {
145
167
  // non-finite deadline does not disable itself, it fires on the next tick and every send fails
146
168
  // "the server sent nothing for NaNms". `0` is the same failure spelled deliberately.
147
169
  const timeoutMs = finiteCount(
148
- 'createSmtpDriver',
170
+ 'smtpMailDriver',
149
171
  'timeoutMs',
150
172
  options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
151
173
  1,
152
174
  );
153
175
  const limit = createLimiter(resolvePoolSize(options.poolSize));
176
+ const retain = resolveRetainMime('smtpMailDriver', options.retainMime);
154
177
  const session: SmtpSessionOptions = {
155
178
  clientName: options.clientName ?? addressDomain(options.from),
156
179
  secure: target.tls,
@@ -205,7 +228,6 @@ export function createSmtpDriver(options: SmtpDriverOptions): MailDriver {
205
228
  { from: options.from, recipients: envelopeRecipients(message), data },
206
229
  session,
207
230
  );
208
- return resultFor('smtp', message, messageId);
209
231
  } catch (error) {
210
232
  // A framework error already names its stage; anything else (a socket reset mid-DATA) would
211
233
  // otherwise escape as a bare Error and lose the code the queue's dead-letter view reads.
@@ -220,6 +242,9 @@ export function createSmtpDriver(options: SmtpDriverOptions): MailDriver {
220
242
  } finally {
221
243
  stream.close();
222
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);
223
248
  }
224
249
  }
225
250