@ultimat3/mail 23.0.0 → 25.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +77 -12
- package/README.md +85 -14
- 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 +30 -5
- package/src/driver.ts +28 -6
- package/src/envelope-address.ts +34 -14
- package/src/errors.ts +52 -11
- package/src/events.ts +28 -0
- package/src/html.ts +5 -16
- package/src/index.ts +20 -10
- package/src/job.ts +16 -4
- package/src/layout.ts +3 -2
- package/src/mail.ts +25 -14
- package/src/mime.ts +28 -7
- package/src/render.ts +7 -5
- 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
|
@@ -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 {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
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 = [
|
|
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: '
|
|
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: '
|
|
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(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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: '
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
116
|
-
detail: 'caught in memory — set SMTP_URL or
|
|
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:
|
|
123
|
-
detail: `no transport configured for ${environment} — set SMTP_URL or
|
|
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
|
+
}
|
package/src/driver-resend.ts
CHANGED
|
@@ -127,7 +127,7 @@ function bodyFor(message: MailMessage, from: string): ResendRequestBody {
|
|
|
127
127
|
};
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
export function
|
|
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
|
-
'
|
|
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
|
+
}
|
package/src/driver-smtp.ts
CHANGED
|
@@ -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:
|
|
88
|
-
...(url.password === '' ? {} : { 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
|
|
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
|
-
'
|
|
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
|
|