@ultimat3/mail 1.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/LICENSE +21 -0
- package/README.md +120 -0
- package/package.json +39 -0
- package/src/base64.ts +9 -0
- package/src/blocks.d.ts +47 -0
- package/src/blocks.d.ts.map +1 -0
- package/src/blocks.js +23 -0
- package/src/blocks.js.map +1 -0
- package/src/blocks.ts +56 -0
- package/src/catalog.d.ts +6 -0
- package/src/catalog.d.ts.map +1 -0
- package/src/catalog.js +78 -0
- package/src/catalog.js.map +1 -0
- package/src/catalog.ts +88 -0
- package/src/driver-env.ts +105 -0
- package/src/driver-resend.ts +184 -0
- package/src/driver-smtp.ts +209 -0
- package/src/driver.d.ts +79 -0
- package/src/driver.d.ts.map +1 -0
- package/src/driver.js +115 -0
- package/src/driver.js.map +1 -0
- package/src/driver.ts +174 -0
- package/src/errors.d.ts +23 -0
- package/src/errors.d.ts.map +1 -0
- package/src/errors.js +82 -0
- package/src/errors.js.map +1 -0
- package/src/errors.ts +177 -0
- package/src/html.d.ts +10 -0
- package/src/html.d.ts.map +1 -0
- package/src/html.js +34 -0
- package/src/html.js.map +1 -0
- package/src/html.ts +37 -0
- package/src/idempotency.ts +61 -0
- package/src/index.d.ts +17 -0
- package/src/index.d.ts.map +1 -0
- package/src/index.js +13 -0
- package/src/index.js.map +1 -0
- package/src/index.ts +110 -0
- package/src/job.d.ts +17 -0
- package/src/job.d.ts.map +1 -0
- package/src/job.js +79 -0
- package/src/job.js.map +1 -0
- package/src/job.ts +36 -0
- package/src/layout.d.ts +41 -0
- package/src/layout.d.ts.map +1 -0
- package/src/layout.js +160 -0
- package/src/layout.js.map +1 -0
- package/src/layout.ts +221 -0
- package/src/mail.d.ts +67 -0
- package/src/mail.d.ts.map +1 -0
- package/src/mail.js +119 -0
- package/src/mail.js.map +1 -0
- package/src/mail.ts +195 -0
- package/src/mime.ts +233 -0
- package/src/render.d.ts +26 -0
- package/src/render.d.ts.map +1 -0
- package/src/render.js +149 -0
- package/src/render.js.map +1 -0
- package/src/render.ts +190 -0
- package/src/smtp-client.ts +246 -0
- package/src/smtp-protocol.ts +184 -0
- package/src/smtp-socket.ts +266 -0
- package/src/templates/index.d.ts +9 -0
- package/src/templates/index.d.ts.map +1 -0
- package/src/templates/index.js +23 -0
- package/src/templates/index.js.map +1 -0
- package/src/templates/index.ts +39 -0
- package/src/templates/invite.d.ts +15 -0
- package/src/templates/invite.d.ts.map +1 -0
- package/src/templates/invite.js +25 -0
- package/src/templates/invite.js.map +1 -0
- package/src/templates/invite.ts +29 -0
- package/src/templates/mfa-enrolled.d.ts +14 -0
- package/src/templates/mfa-enrolled.d.ts.map +1 -0
- package/src/templates/mfa-enrolled.js +26 -0
- package/src/templates/mfa-enrolled.js.map +1 -0
- package/src/templates/mfa-enrolled.ts +37 -0
- package/src/templates/reset-password.d.ts +13 -0
- package/src/templates/reset-password.d.ts.map +1 -0
- package/src/templates/reset-password.js +23 -0
- package/src/templates/reset-password.js.map +1 -0
- package/src/templates/reset-password.ts +27 -0
- package/src/templates/security-alert.d.ts +16 -0
- package/src/templates/security-alert.d.ts.map +1 -0
- package/src/templates/security-alert.js +29 -0
- package/src/templates/security-alert.js.map +1 -0
- package/src/templates/security-alert.ts +36 -0
- package/src/templates/verify-email.d.ts +13 -0
- package/src/templates/verify-email.d.ts.map +1 -0
- package/src/templates/verify-email.js +23 -0
- package/src/templates/verify-email.js.map +1 -0
- package/src/templates/verify-email.ts +27 -0
- package/src/templates/welcome.d.ts +13 -0
- package/src/templates/welcome.d.ts.map +1 -0
- package/src/templates/welcome.js +20 -0
- package/src/templates/welcome.js.map +1 -0
- package/src/templates/welcome.ts +24 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Single responsibility: the Resend HTTPS transport. One POST per message onto the shared
|
|
2
|
+
// MailDriver contract — no SDK, `fetch` is the whole client. Construction fails fast on a
|
|
3
|
+
// missing key or an empty from address, so a misconfiguration never waits for the first send.
|
|
4
|
+
|
|
5
|
+
import { ConfigInvalidError, EnvMissingError, logger, nanoid } from '@ultimat3/core';
|
|
6
|
+
import type { MailDriver, MailMessage, SendResult } from './driver';
|
|
7
|
+
import { messageHeaders, resultFor } from './driver';
|
|
8
|
+
import { sendFailed } from './errors';
|
|
9
|
+
import { mailIdempotencyKey } from './idempotency';
|
|
10
|
+
|
|
11
|
+
export const RESEND_BASE_URL = 'https://api.resend.com';
|
|
12
|
+
|
|
13
|
+
/** Just the call. `typeof fetch` also carries `preconnect`, which no test double should have to. */
|
|
14
|
+
export type MailFetch = (input: string, init: RequestInit) => Promise<Response>;
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
17
|
+
const MAX_DETAIL_LENGTH = 200;
|
|
18
|
+
|
|
19
|
+
// A 4xx here means the same request, unchanged, might land: a throttle or a momentary
|
|
20
|
+
// conflict. Every other 4xx is a config problem retrying cannot fix.
|
|
21
|
+
const RETRYABLE_STATUSES = new Set([408, 409, 425, 429]);
|
|
22
|
+
|
|
23
|
+
export interface ResendDriverOptions {
|
|
24
|
+
/** Read from `RESEND_API_KEY`. A literal key in app.config.ts is a key in git. */
|
|
25
|
+
readonly apiKey: string;
|
|
26
|
+
/** `Postly <no-reply@postly.test>`. Must be a domain verified with Resend. */
|
|
27
|
+
readonly from: string;
|
|
28
|
+
/** Override for a self-hosted proxy or a test double. Defaults to `RESEND_BASE_URL`. */
|
|
29
|
+
readonly baseUrl?: string | undefined;
|
|
30
|
+
readonly timeoutMs?: number | undefined;
|
|
31
|
+
/** Injected in tests; production uses the global. */
|
|
32
|
+
readonly fetch?: MailFetch | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function requireApiKey(apiKey: string): string {
|
|
36
|
+
if (apiKey.trim() !== '') return apiKey;
|
|
37
|
+
throw new EnvMissingError({
|
|
38
|
+
cause: 'RESEND_API_KEY is not set, so the resend driver cannot authenticate',
|
|
39
|
+
fix: "set RESEND_API_KEY in .env (or the container's secret store), then re-run",
|
|
40
|
+
meta: { missing: 'RESEND_API_KEY' },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function requireFrom(from: string): string {
|
|
45
|
+
if (from.trim() !== '') return from;
|
|
46
|
+
throw new ConfigInvalidError({
|
|
47
|
+
cause: 'the resend driver was configured without a from address',
|
|
48
|
+
fix: 'set mail.from in app.config.ts to a domain verified with Resend',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `retryable` for a throttle or a transient conflict; everything else is a config problem. */
|
|
53
|
+
function isRetryableStatus(status: number): boolean {
|
|
54
|
+
return status >= 500 || RETRYABLE_STATUSES.has(status);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fixFor(status: number): string {
|
|
58
|
+
if (status === 401 || status === 403) {
|
|
59
|
+
return 'set RESEND_API_KEY to a current key from https://resend.com/api-keys, then retry';
|
|
60
|
+
}
|
|
61
|
+
if (status === 422) {
|
|
62
|
+
return 'verify the sending domain in the Resend dashboard: https://resend.com/domains';
|
|
63
|
+
}
|
|
64
|
+
if (status === 429) {
|
|
65
|
+
return 'the job already retries with backoff — raise the rate limit on the Resend plan';
|
|
66
|
+
}
|
|
67
|
+
return 'check the request against https://resend.com/docs/api-reference/emails/send-email';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
71
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Prefers Resend's own message; falls back to the raw body, capped so a huge page can't flood logs. */
|
|
75
|
+
async function detailFor(response: Response): Promise<string> {
|
|
76
|
+
const text = await response.text().catch(() => '');
|
|
77
|
+
if (text === '') return `HTTP ${response.status} with an empty body`;
|
|
78
|
+
try {
|
|
79
|
+
const parsed: unknown = JSON.parse(text);
|
|
80
|
+
const message = isRecord(parsed) ? parsed['message'] : undefined;
|
|
81
|
+
if (typeof message === 'string' && message !== '') return message;
|
|
82
|
+
} catch {
|
|
83
|
+
// Not JSON — fall through to the truncated raw text below.
|
|
84
|
+
}
|
|
85
|
+
return text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH)}…` : text;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A 2xx is acceptance regardless of body shape; the id is best-effort, never a reason to fail. */
|
|
89
|
+
async function idFor(response: Response, message: MailMessage): Promise<string> {
|
|
90
|
+
const parsed: unknown = await response.json().catch(() => undefined);
|
|
91
|
+
const id = isRecord(parsed) ? parsed['id'] : undefined;
|
|
92
|
+
if (typeof id === 'string' && id !== '') return id;
|
|
93
|
+
const local = `resend_${nanoid(12)}`;
|
|
94
|
+
logger.warn('mail.resend.no_id', { mailId: message.mailId, id: local });
|
|
95
|
+
return local;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface ResendRequestBody {
|
|
99
|
+
readonly from: string;
|
|
100
|
+
readonly to: readonly string[];
|
|
101
|
+
readonly subject: string;
|
|
102
|
+
readonly html: string;
|
|
103
|
+
readonly text: string;
|
|
104
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
105
|
+
readonly cc?: readonly string[];
|
|
106
|
+
readonly bcc?: readonly string[];
|
|
107
|
+
readonly reply_to?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** `bcc` travels in the body here — Resend builds the envelope, so this is the one place a
|
|
111
|
+
* blind list legitimately leaves the process. Absent fields are omitted, never null: Resend
|
|
112
|
+
* rejects nulls outright. */
|
|
113
|
+
function bodyFor(message: MailMessage, from: string): ResendRequestBody {
|
|
114
|
+
return {
|
|
115
|
+
from,
|
|
116
|
+
to: message.to,
|
|
117
|
+
subject: message.subject,
|
|
118
|
+
html: message.html,
|
|
119
|
+
text: message.text,
|
|
120
|
+
headers: messageHeaders(message),
|
|
121
|
+
...(message.cc === undefined ? {} : { cc: message.cc }),
|
|
122
|
+
...(message.bcc === undefined ? {} : { bcc: message.bcc }),
|
|
123
|
+
...(message.replyTo === undefined ? {} : { reply_to: message.replyTo }),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function createResendDriver(options: ResendDriverOptions): MailDriver {
|
|
128
|
+
const apiKey = requireApiKey(options.apiKey);
|
|
129
|
+
const from = requireFrom(options.from);
|
|
130
|
+
const baseUrl = options.baseUrl ?? RESEND_BASE_URL;
|
|
131
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
132
|
+
// A bare reference to `globalThis.fetch` risks "Illegal invocation" on some hosts; closing
|
|
133
|
+
// over the call keeps it detached from any receiver, in production and in tests alike.
|
|
134
|
+
const doFetch: MailFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
name: 'resend',
|
|
138
|
+
|
|
139
|
+
async send(message: MailMessage): Promise<SendResult> {
|
|
140
|
+
// Always sent, and content-derived rather than taken from the envelope: a job retry after a
|
|
141
|
+
// timeout hands Resend the identical message, and this header is what makes that one email.
|
|
142
|
+
const headers: Record<string, string> = {
|
|
143
|
+
Authorization: `Bearer ${apiKey}`,
|
|
144
|
+
'Content-Type': 'application/json',
|
|
145
|
+
'Idempotency-Key': mailIdempotencyKey(message),
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
let response: Response;
|
|
149
|
+
try {
|
|
150
|
+
response = await doFetch(`${baseUrl}/emails`, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers,
|
|
153
|
+
body: JSON.stringify(bodyFor(message, from)),
|
|
154
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
155
|
+
});
|
|
156
|
+
} catch (error) {
|
|
157
|
+
// DNS/TLS/reset, or the timeout firing — none of them got far enough to have a status,
|
|
158
|
+
// and every one of them can succeed unchanged on the job's next attempt.
|
|
159
|
+
const reason =
|
|
160
|
+
error instanceof Error ? error.message : 'the request failed before a response';
|
|
161
|
+
throw sendFailed({
|
|
162
|
+
driver: 'resend',
|
|
163
|
+
stage: 'request',
|
|
164
|
+
detail: `${reason} — nothing left this host for ${baseUrl}/emails (egress, DNS or TLS)`,
|
|
165
|
+
retryable: true,
|
|
166
|
+
fix: `curl -sS -m 5 -o /dev/null ${baseUrl}/emails`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
throw sendFailed({
|
|
172
|
+
driver: 'resend',
|
|
173
|
+
stage: 'request',
|
|
174
|
+
detail: await detailFor(response),
|
|
175
|
+
status: response.status,
|
|
176
|
+
retryable: isRetryableStatus(response.status),
|
|
177
|
+
fix: fixFor(response.status),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return resultFor('resend', message, await idFor(response, message));
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Single responsibility: the SMTP transport as a `MailDriver` — parse `SMTP_URL` once at boot,
|
|
2
|
+
// render the envelope to MIME, and run one conversation per message over one connection. The
|
|
3
|
+
// protocol lives in `smtp-client.ts` and the socket in `smtp-socket.ts`; this file wires them.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type Clock,
|
|
7
|
+
ConfigInvalidError,
|
|
8
|
+
isUltimateError,
|
|
9
|
+
nanoid,
|
|
10
|
+
systemClock,
|
|
11
|
+
} from '@ultimat3/core';
|
|
12
|
+
import type { SendResult } from './driver';
|
|
13
|
+
import { envelopeRecipients, type MailDriver, type MailMessage, resultFor } from './driver';
|
|
14
|
+
import { sendFailed } from './errors';
|
|
15
|
+
import { addressDomain, addressSpec, buildMimeMessage } from './mime';
|
|
16
|
+
import {
|
|
17
|
+
type SmtpConnector,
|
|
18
|
+
type SmtpSessionOptions,
|
|
19
|
+
type SmtpTarget,
|
|
20
|
+
smtpDeliver,
|
|
21
|
+
} from './smtp-client';
|
|
22
|
+
import { bunSmtpStream } from './smtp-socket';
|
|
23
|
+
|
|
24
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
25
|
+
const DEFAULT_POOL_SIZE = 4;
|
|
26
|
+
const IMPLICIT_TLS_PORT = 465;
|
|
27
|
+
const SUBMISSION_PORT = 587;
|
|
28
|
+
|
|
29
|
+
export interface SmtpDriverOptions {
|
|
30
|
+
/** `smtps://user:pass@host:465`. Read from `SMTP_URL`, never hardcoded. */
|
|
31
|
+
readonly url: string;
|
|
32
|
+
/** `Postly <no-reply@postly.test>`. Also the envelope sender and the Message-ID domain. */
|
|
33
|
+
readonly from: string;
|
|
34
|
+
/** Max connections open at once. A burst of sends queues instead of opening one socket each. */
|
|
35
|
+
readonly poolSize?: number | undefined;
|
|
36
|
+
/** Deadline for any single read or write. Default 30s. */
|
|
37
|
+
readonly timeoutMs?: number | undefined;
|
|
38
|
+
/** The `EHLO` name. Defaults to the from address's domain, which is what SPF checks anyway. */
|
|
39
|
+
readonly clientName?: string | undefined;
|
|
40
|
+
/** Speak to a server that offers no STARTTLS. Off by default: mail and password in the clear. */
|
|
41
|
+
readonly allowInsecure?: boolean | undefined;
|
|
42
|
+
readonly clock?: Clock | undefined;
|
|
43
|
+
/** Injected in tests; production dials with `Bun.connect`. */
|
|
44
|
+
readonly connect?: SmtpConnector | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface SmtpUrl {
|
|
48
|
+
readonly host: string;
|
|
49
|
+
readonly port: number;
|
|
50
|
+
readonly tls: boolean;
|
|
51
|
+
readonly user?: string | undefined;
|
|
52
|
+
readonly password?: string | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `smtps://` is implicit TLS on 465; `smtp://` starts in the clear and upgrades with STARTTLS. */
|
|
56
|
+
function parseSmtpUrl(raw: string): SmtpUrl {
|
|
57
|
+
let url: URL;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(raw);
|
|
60
|
+
} catch {
|
|
61
|
+
throw new ConfigInvalidError({
|
|
62
|
+
cause: 'SMTP_URL is not a URL',
|
|
63
|
+
fix: 'set SMTP_URL to smtps://user:pass@host:465 (implicit TLS) or smtp://host:587',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (url.protocol !== 'smtp:' && url.protocol !== 'smtps:') {
|
|
67
|
+
throw new ConfigInvalidError({
|
|
68
|
+
cause: `SMTP_URL uses the "${url.protocol}" scheme, which is not an SMTP one`,
|
|
69
|
+
fix: 'set SMTP_URL to smtps://user:pass@host:465 (implicit TLS) or smtp://host:587',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (url.hostname === '') {
|
|
73
|
+
throw new ConfigInvalidError({
|
|
74
|
+
cause: 'SMTP_URL names no host',
|
|
75
|
+
fix: 'set SMTP_URL to smtps://user:pass@host:465 (implicit TLS) or smtp://host:587',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const tls = url.protocol === 'smtps:';
|
|
79
|
+
return {
|
|
80
|
+
host: url.hostname,
|
|
81
|
+
port: url.port === '' ? (tls ? IMPLICIT_TLS_PORT : SUBMISSION_PORT) : Number(url.port),
|
|
82
|
+
tls,
|
|
83
|
+
// Credentials are percent-encoded in a URL; a password with `@` or `/` is otherwise unusable.
|
|
84
|
+
...(url.username === '' ? {} : { user: decodeURIComponent(url.username) }),
|
|
85
|
+
...(url.password === '' ? {} : { password: decodeURIComponent(url.password) }),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `poolSize: 0` is the one config mistake this driver could not report: the limiter would park
|
|
91
|
+
* every send on a slot that is never handed out, with no deadline on the wait, so the job would
|
|
92
|
+
* neither fail nor retry and the worker slot would be gone until the process restarted. A silent
|
|
93
|
+
* deadlock is worse than any error, so this fails at construction like every other bad value.
|
|
94
|
+
*/
|
|
95
|
+
function resolvePoolSize(poolSize: number | undefined): number {
|
|
96
|
+
if (poolSize === undefined) return DEFAULT_POOL_SIZE;
|
|
97
|
+
if (!Number.isInteger(poolSize) || poolSize < 1) {
|
|
98
|
+
throw new ConfigInvalidError({
|
|
99
|
+
cause: `the smtp driver was configured with poolSize: ${poolSize}, which opens no connection`,
|
|
100
|
+
fix: `set poolSize to a whole number >= 1 in app.config.ts, or drop it for ${DEFAULT_POOL_SIZE}`,
|
|
101
|
+
meta: { poolSize },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return poolSize;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** At most `size` conversations at a time — the honest meaning of `poolSize` for one-shot sends. */
|
|
108
|
+
function createLimiter(size: number): (run: () => Promise<SendResult>) => Promise<SendResult> {
|
|
109
|
+
const waiting: (() => void)[] = [];
|
|
110
|
+
let active = 0;
|
|
111
|
+
// The slot is handed straight to the next waiter rather than freed and re-taken, so the count
|
|
112
|
+
// is never momentarily wrong: "free it, then let whoever wakes first claim it" only holds while
|
|
113
|
+
// nothing can run between the two, which is a scheduling detail, not an invariant.
|
|
114
|
+
const release = (): void => {
|
|
115
|
+
const next = waiting.shift();
|
|
116
|
+
if (next === undefined) active -= 1;
|
|
117
|
+
else next();
|
|
118
|
+
};
|
|
119
|
+
return async (run) => {
|
|
120
|
+
if (active >= size) await new Promise<void>((resolve) => waiting.push(resolve));
|
|
121
|
+
else active += 1;
|
|
122
|
+
try {
|
|
123
|
+
return await run();
|
|
124
|
+
} finally {
|
|
125
|
+
release();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function createSmtpDriver(options: SmtpDriverOptions): MailDriver {
|
|
131
|
+
const target = parseSmtpUrl(options.url);
|
|
132
|
+
if (options.from.trim() === '') {
|
|
133
|
+
throw new ConfigInvalidError({
|
|
134
|
+
cause: 'the smtp driver was configured without a from address',
|
|
135
|
+
fix: 'set mail.from in app.config.ts to an address the SMTP server will relay for',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const clock = options.clock ?? systemClock;
|
|
139
|
+
const connect = options.connect ?? bunSmtpStream;
|
|
140
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
141
|
+
const limit = createLimiter(resolvePoolSize(options.poolSize));
|
|
142
|
+
const session: SmtpSessionOptions = {
|
|
143
|
+
clientName: options.clientName ?? addressDomain(options.from),
|
|
144
|
+
secure: target.tls,
|
|
145
|
+
allowInsecure: options.allowInsecure ?? false,
|
|
146
|
+
timeoutMs,
|
|
147
|
+
...(target.user === undefined ? {} : { user: target.user }),
|
|
148
|
+
...(target.password === undefined ? {} : { password: target.password }),
|
|
149
|
+
};
|
|
150
|
+
const dial: SmtpTarget = { host: target.host, port: target.port, tls: target.tls, timeoutMs };
|
|
151
|
+
// `smtps://` is encrypted from the first byte; a submission port has to be told to upgrade, or
|
|
152
|
+
// the probe named in the fix lines below opens a socket and then waits forever for a handshake.
|
|
153
|
+
const starttls = target.tls ? '' : ' -starttls smtp';
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
name: 'smtp',
|
|
157
|
+
send: (message: MailMessage): Promise<SendResult> => limit(() => deliver(message)),
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
async function deliver(message: MailMessage): Promise<SendResult> {
|
|
161
|
+
// The Message-ID is the id the recipient's mailbox shows, so it is also the id we report: an
|
|
162
|
+
// SMTP `250` carries no identifier a caller could correlate with anything. It is deliberately
|
|
163
|
+
// NOT derived from the idempotency key — that key holds the recipient list, and a Message-ID
|
|
164
|
+
// is visible to every recipient, blind ones included.
|
|
165
|
+
const messageId = `<${message.mailId}.${nanoid(16)}@${addressDomain(options.from)}>`;
|
|
166
|
+
const data = buildMimeMessage(message, {
|
|
167
|
+
from: options.from,
|
|
168
|
+
messageId,
|
|
169
|
+
date: clock.now(),
|
|
170
|
+
boundary: `x-ultimate-${nanoid(20)}`,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const stream = await connect(dial).catch((error: unknown) => {
|
|
174
|
+
throw sendFailed({
|
|
175
|
+
driver: 'smtp',
|
|
176
|
+
stage: 'connect',
|
|
177
|
+
detail: `${target.host}:${target.port}, from SMTP_URL in .env — ${messageOf(error)}`,
|
|
178
|
+
retryable: true,
|
|
179
|
+
fix: `openssl s_client${starttls} -connect ${target.host}:${target.port}`,
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
await smtpDeliver(
|
|
185
|
+
stream,
|
|
186
|
+
{ from: addressSpec(options.from), recipients: envelopeRecipients(message), data },
|
|
187
|
+
session,
|
|
188
|
+
);
|
|
189
|
+
return resultFor('smtp', message, messageId);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
// A framework error already names its stage; anything else (a socket reset mid-DATA) would
|
|
192
|
+
// otherwise escape as a bare Error and lose the code the queue's dead-letter view reads.
|
|
193
|
+
if (isUltimateError(error)) throw error;
|
|
194
|
+
throw sendFailed({
|
|
195
|
+
driver: 'smtp',
|
|
196
|
+
stage: 'data',
|
|
197
|
+
detail: `${messageOf(error)} — the mail log on ${target.host} records why it dropped this`,
|
|
198
|
+
retryable: true,
|
|
199
|
+
fix: `openssl s_client -crlf${starttls} -connect ${target.host}:${target.port}`,
|
|
200
|
+
});
|
|
201
|
+
} finally {
|
|
202
|
+
stream.close();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function messageOf(error: unknown): string {
|
|
208
|
+
return error instanceof Error ? error.message : String(error);
|
|
209
|
+
}
|
package/src/driver.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** The rendered envelope. Everything a transport needs; nothing it does not. */
|
|
2
|
+
export interface MailMessage {
|
|
3
|
+
readonly mailId: string;
|
|
4
|
+
readonly to: readonly string[];
|
|
5
|
+
readonly subject: string;
|
|
6
|
+
readonly html: string;
|
|
7
|
+
readonly text: string;
|
|
8
|
+
readonly locale: string;
|
|
9
|
+
readonly tz: string;
|
|
10
|
+
readonly replyTo?: string | undefined;
|
|
11
|
+
readonly cc?: readonly string[] | undefined;
|
|
12
|
+
readonly bcc?: readonly string[] | undefined;
|
|
13
|
+
readonly unsubscribeUrl?: string | undefined;
|
|
14
|
+
readonly idempotencyKey?: string | undefined;
|
|
15
|
+
}
|
|
16
|
+
export interface SendResult {
|
|
17
|
+
/** Provider message id, or a local id for the memory/log drivers. */
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly driver: string;
|
|
20
|
+
readonly accepted: readonly string[];
|
|
21
|
+
/** True when the message was handed to the queue instead of a transport. */
|
|
22
|
+
readonly queued: boolean;
|
|
23
|
+
readonly idempotencyKey: string;
|
|
24
|
+
}
|
|
25
|
+
export interface MailDriver {
|
|
26
|
+
readonly name: string;
|
|
27
|
+
send(message: MailMessage): Promise<SendResult>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* RFC 8058 one-click unsubscribe. Gmail and Yahoo require it for bulk senders and
|
|
31
|
+
* reward it for transactional ones, so it is computed here rather than per driver.
|
|
32
|
+
*/
|
|
33
|
+
export declare function messageHeaders(message: MailMessage): Readonly<Record<string, string>>;
|
|
34
|
+
export interface SentMail {
|
|
35
|
+
readonly at: Date;
|
|
36
|
+
readonly message: MailMessage;
|
|
37
|
+
readonly result: SendResult;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Dev + test driver. Retains every message it accepts and exposes them, which is what the
|
|
41
|
+
* `/_x` mail panel reads to show the last mails rendered by the running app — a real inbox
|
|
42
|
+
* is not part of the local loop.
|
|
43
|
+
*/
|
|
44
|
+
export interface MemoryMailDriver extends MailDriver {
|
|
45
|
+
readonly name: 'memory';
|
|
46
|
+
readonly sent: readonly SentMail[];
|
|
47
|
+
/** Newest first, so the panel and assertions do not index backwards. */
|
|
48
|
+
outbox(): readonly SentMail[];
|
|
49
|
+
lastTo(address: string): SentMail | undefined;
|
|
50
|
+
clear(): void;
|
|
51
|
+
}
|
|
52
|
+
export declare function createMemoryDriver(): MemoryMailDriver;
|
|
53
|
+
/**
|
|
54
|
+
* Structured log line per message through core's `logger` — the default for a worker that
|
|
55
|
+
* has no credentials yet. Bodies are never logged; a mail body is user data.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createLogDriver(logger?: import("@ultimat3/core").Logger): MailDriver;
|
|
58
|
+
export interface SmtpDriverOptions {
|
|
59
|
+
/** `smtps://user:pass@host:465`. Read from `SMTP_URL`, never hardcoded. */
|
|
60
|
+
readonly url: string;
|
|
61
|
+
readonly from: string;
|
|
62
|
+
readonly poolSize?: number | undefined;
|
|
63
|
+
}
|
|
64
|
+
/** Interface-complete; the wire protocol is not in this build. */
|
|
65
|
+
export declare function createSmtpDriver(options: SmtpDriverOptions): MailDriver;
|
|
66
|
+
export interface ResendDriverOptions {
|
|
67
|
+
/** Read from `RESEND_API_KEY`. */
|
|
68
|
+
readonly apiKey: string;
|
|
69
|
+
readonly from: string;
|
|
70
|
+
}
|
|
71
|
+
/** Interface-complete; the HTTP call is not in this build. */
|
|
72
|
+
export declare function createResendDriver(options: ResendDriverOptions): MailDriver;
|
|
73
|
+
/** Set once at boot from `app.config.ts`. One driver per process, like the job driver. */
|
|
74
|
+
export declare function setMailDriver(driver: MailDriver): void;
|
|
75
|
+
export declare function tryMailDriver(): MailDriver | undefined;
|
|
76
|
+
/** Throws `X_MAIL_DRIVER_UNAVAILABLE` rather than silently dropping mail on the floor. */
|
|
77
|
+
export declare function mailDriver(): MailDriver;
|
|
78
|
+
export declare function resetMailDriver(): void;
|
|
79
|
+
//# sourceMappingURL=driver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"driver.d.ts","sourceRoot":"","sources":["driver.ts"],"names":[],"mappings":"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"}
|
package/src/driver.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Single responsibility: the transport seam. One `MailDriver` interface, four implementations,
|
|
2
|
+
// and a module-level ambient driver so `send()` never knows which one is installed. Swapping
|
|
3
|
+
// SMTP for Resend is an `app.config.ts` line and zero template changes.
|
|
4
|
+
import { nanoid, logger as rootLogger } from '@ultimat3/core';
|
|
5
|
+
import { driverUnavailable, transportNotImplemented } from './errors';
|
|
6
|
+
/**
|
|
7
|
+
* RFC 8058 one-click unsubscribe. Gmail and Yahoo require it for bulk senders and
|
|
8
|
+
* reward it for transactional ones, so it is computed here rather than per driver.
|
|
9
|
+
*/
|
|
10
|
+
export function messageHeaders(message) {
|
|
11
|
+
const headers = { 'Auto-Submitted': 'auto-generated' };
|
|
12
|
+
if (message.replyTo !== undefined)
|
|
13
|
+
headers['Reply-To'] = message.replyTo;
|
|
14
|
+
if (message.unsubscribeUrl !== undefined) {
|
|
15
|
+
headers['List-Unsubscribe'] = `<${message.unsubscribeUrl}>`;
|
|
16
|
+
headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
|
|
17
|
+
}
|
|
18
|
+
return headers;
|
|
19
|
+
}
|
|
20
|
+
function accepted(message) {
|
|
21
|
+
return [...message.to, ...(message.cc ?? []), ...(message.bcc ?? [])];
|
|
22
|
+
}
|
|
23
|
+
function resultFor(driver, message, id) {
|
|
24
|
+
return {
|
|
25
|
+
id,
|
|
26
|
+
driver,
|
|
27
|
+
accepted: accepted(message),
|
|
28
|
+
queued: false,
|
|
29
|
+
idempotencyKey: message.idempotencyKey ?? id,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function createMemoryDriver() {
|
|
33
|
+
const sent = [];
|
|
34
|
+
return {
|
|
35
|
+
name: 'memory',
|
|
36
|
+
sent,
|
|
37
|
+
send(message) {
|
|
38
|
+
const result = resultFor('memory', message, `mem_${nanoid(12)}`);
|
|
39
|
+
sent.push({ at: new Date(), message, result });
|
|
40
|
+
return Promise.resolve(result);
|
|
41
|
+
},
|
|
42
|
+
outbox: () => [...sent].reverse(),
|
|
43
|
+
lastTo: (address) => [...sent].reverse().find((entry) => entry.message.to.includes(address)),
|
|
44
|
+
clear: () => {
|
|
45
|
+
sent.length = 0;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Structured log line per message through core's `logger` — the default for a worker that
|
|
51
|
+
* has no credentials yet. Bodies are never logged; a mail body is user data.
|
|
52
|
+
*/
|
|
53
|
+
export function createLogDriver(logger = rootLogger) {
|
|
54
|
+
return {
|
|
55
|
+
name: 'log',
|
|
56
|
+
send(message) {
|
|
57
|
+
const result = resultFor('log', message, `log_${nanoid(12)}`);
|
|
58
|
+
logger.info('mail.send', {
|
|
59
|
+
mailId: message.mailId,
|
|
60
|
+
to: message.to.length,
|
|
61
|
+
subject: message.subject,
|
|
62
|
+
locale: message.locale,
|
|
63
|
+
idempotencyKey: result.idempotencyKey,
|
|
64
|
+
});
|
|
65
|
+
return Promise.resolve(result);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Interface-complete; the wire protocol is not in this build. */
|
|
70
|
+
export function createSmtpDriver(options) {
|
|
71
|
+
return {
|
|
72
|
+
name: 'smtp',
|
|
73
|
+
send(_message) {
|
|
74
|
+
throw transportNotImplemented('smtp', `set SMTP_URL (currently "${redactUrl(options.url)}") and run: x mail doctor --json`);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Interface-complete; the HTTP call is not in this build. */
|
|
79
|
+
export function createResendDriver(options) {
|
|
80
|
+
return {
|
|
81
|
+
name: 'resend',
|
|
82
|
+
send(_message) {
|
|
83
|
+
const state = options.apiKey === '' ? 'empty' : 'set';
|
|
84
|
+
throw transportNotImplemented('resend', `set RESEND_API_KEY (currently ${state}), then run: x mail doctor --json`);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function redactUrl(url) {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = new URL(url);
|
|
91
|
+
parsed.password = '';
|
|
92
|
+
return parsed.href;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return 'not a URL';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
let ambient;
|
|
99
|
+
/** Set once at boot from `app.config.ts`. One driver per process, like the job driver. */
|
|
100
|
+
export function setMailDriver(driver) {
|
|
101
|
+
ambient = driver;
|
|
102
|
+
}
|
|
103
|
+
export function tryMailDriver() {
|
|
104
|
+
return ambient;
|
|
105
|
+
}
|
|
106
|
+
/** Throws `X_MAIL_DRIVER_UNAVAILABLE` rather than silently dropping mail on the floor. */
|
|
107
|
+
export function mailDriver() {
|
|
108
|
+
if (ambient === undefined)
|
|
109
|
+
throw driverUnavailable('setMailDriver() was never called');
|
|
110
|
+
return ambient;
|
|
111
|
+
}
|
|
112
|
+
export function resetMailDriver() {
|
|
113
|
+
ambient = undefined;
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=driver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"driver.js","sourceRoot":"","sources":["driver.ts"],"names":[],"mappings":"AAAA,+FAA+F;AAC/F,6FAA6F;AAC7F,wEAAwE;AAExE,OAAO,EAAE,MAAM,EAAE,MAAM,IAAI,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAC9D,OAAO,EAAE,iBAAiB,EAAE,uBAAuB,EAAE,MAAM,UAAU,CAAC;AAiCtE;;;GAGG;AACH,MAAM,UAAU,cAAc,CAAC,OAAoB;IACjD,MAAM,OAAO,GAA2B,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,CAAC;IAC/E,IAAI,OAAO,CAAC,OAAO,KAAK,SAAS;QAAE,OAAO,CAAC,UAAU,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC;IACzE,IAAI,OAAO,CAAC,cAAc,KAAK,SAAS,EAAE,CAAC;QACzC,OAAO,CAAC,kBAAkB,CAAC,GAAG,IAAI,OAAO,CAAC,cAAc,GAAG,CAAC;QAC5D,OAAO,CAAC,uBAAuB,CAAC,GAAG,4BAA4B,CAAC;IAClE,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,SAAS,QAAQ,CAAC,OAAoB;IACpC,OAAO,CAAC,GAAG,OAAO,CAAC,EAAE,EAAE,GAAG,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC,CAAC;AACxE,CAAC;AAED,SAAS,SAAS,CAAC,MAAc,EAAE,OAAoB,EAAE,EAAU;IACjE,OAAO;QACL,EAAE;QACF,MAAM;QACN,QAAQ,EAAE,QAAQ,CAAC,OAAO,CAAC;QAC3B,MAAM,EAAE,KAAK;QACb,cAAc,EAAE,OAAO,CAAC,cAAc,IAAI,EAAE;KAC7C,CAAC;AACJ,CAAC;AAsBD,MAAM,UAAU,kBAAkB;IAChC,MAAM,IAAI,GAAe,EAAE,CAAC;IAC5B,OAAO;QACL,IAAI,EAAE,QAAQ;QACd,IAAI;QACJ,IAAI,CAAC,OAAoB;YACvB,MAAM,MAAM,GAAG,SAAS,CAAC,QAAQ,EAAE,OAAO,EAAE,OAAO,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;YACjE,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,IAAI,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC;YAC/C,OAAO,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QACjC,CAAC;QACD,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,OAAO,EAAE;QACjC,MAAM,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;QAC5F,KAAK,EAAE,GAAG,EAAE;YACV,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC;QAClB,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,eAAe,CAAC,MAAM,GAAG,UAAU;IACjD,OAAO;QACL,IAAI,EAAE,KAAK;QACX,IAAI,CAAC,OAAoB;YACvB,MAAM,MAAM,GAAG,SAAS,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;YAC9D,MAAM,CAAC,IAAI,CAAC,WAAW,EAAE;gBACvB,MAAM,EAAE,OAAO,CAAC,MAAM;gBACtB,EAAE,EAAE,OAAO,CAAC,EAAE,CAAC,MAAM;gBACrB,OAAO,EAAE,OAAO,CAAC,OAAO;gBACxB,MAAM,EAAE,OAAO,CAAC,MAAM;gBACtB,cAAc,EAAE,MAAM,CAAC,cAAc;aACtC,CAAC,CAAC;YACH,OAAO,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QACjC,CAAC;KACF,CAAC;AACJ,CAAC;AASD,kEAAkE;AAClE,MAAM,UAAU,gBAAgB,CAAC,OAA0B;IACzD,OAAO;QACL,IAAI,EAAE,MAAM;QACZ,IAAI,CAAC,QAAqB;YACxB,MAAM,uBAAuB,CAC3B,MAAM,EACN,4BAA4B,SAAS,CAAC,OAAO,CAAC,GAAG,CAAC,kCAAkC,CACrF,CAAC;QACJ,CAAC;KACF,CAAC;AACJ,CAAC;AAQD,8DAA8D;AAC9D,MAAM,UAAU,kBAAkB,CAAC,OAA4B;IAC7D,OAAO;QACL,IAAI,EAAE,QAAQ;QACd,IAAI,CAAC,QAAqB;YACxB,MAAM,KAAK,GAAG,OAAO,CAAC,MAAM,KAAK,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC;YACtD,MAAM,uBAAuB,CAC3B,QAAQ,EACR,iCAAiC,KAAK,mCAAmC,CAC1E,CAAC;QACJ,CAAC;KACF,CAAC;AACJ,CAAC;AAED,SAAS,SAAS,CAAC,GAAW;IAC5B,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;QAC5B,MAAM,CAAC,QAAQ,GAAG,EAAE,CAAC;QACrB,OAAO,MAAM,CAAC,IAAI,CAAC;IACrB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,WAAW,CAAC;IACrB,CAAC;AACH,CAAC;AAED,IAAI,OAA+B,CAAC;AAEpC,0FAA0F;AAC1F,MAAM,UAAU,aAAa,CAAC,MAAkB;IAC9C,OAAO,GAAG,MAAM,CAAC;AACnB,CAAC;AAED,MAAM,UAAU,aAAa;IAC3B,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,0FAA0F;AAC1F,MAAM,UAAU,UAAU;IACxB,IAAI,OAAO,KAAK,SAAS;QAAE,MAAM,iBAAiB,CAAC,kCAAkC,CAAC,CAAC;IACvF,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,MAAM,UAAU,eAAe;IAC7B,OAAO,GAAG,SAAS,CAAC;AACtB,CAAC"}
|