@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,246 @@
|
|
|
1
|
+
// Single responsibility: the SMTP conversation — greeting, EHLO, STARTTLS, AUTH, envelope, DATA.
|
|
2
|
+
// It talks to an `SmtpStream`, never to a socket, so the whole protocol runs in a test with no
|
|
3
|
+
// network. Every refusal becomes `X_MAIL_SEND_FAILED` naming the stage and the server's own reply.
|
|
4
|
+
|
|
5
|
+
import { base64Utf8 } from './base64';
|
|
6
|
+
import { type MailError, type SendStage, sendFailed } from './errors';
|
|
7
|
+
import {
|
|
8
|
+
authPlain,
|
|
9
|
+
createReplyParser,
|
|
10
|
+
dotStuff,
|
|
11
|
+
isPositive,
|
|
12
|
+
isTransient,
|
|
13
|
+
parseCapabilities,
|
|
14
|
+
replySummary,
|
|
15
|
+
type SmtpCapabilities,
|
|
16
|
+
type SmtpReply,
|
|
17
|
+
} from './smtp-protocol';
|
|
18
|
+
|
|
19
|
+
/** The byte pipe the conversation runs over. `smtp-socket.ts` implements it over `Bun.connect`. */
|
|
20
|
+
export interface SmtpStream {
|
|
21
|
+
/** The next chunk the server sent, or `undefined` once it closed the connection. */
|
|
22
|
+
read(): Promise<string | undefined>;
|
|
23
|
+
write(data: string): Promise<void>;
|
|
24
|
+
/** STARTTLS: negotiate TLS in place. Everything read or written after this is encrypted. */
|
|
25
|
+
startTls(): Promise<void>;
|
|
26
|
+
close(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SmtpTarget {
|
|
30
|
+
readonly host: string;
|
|
31
|
+
readonly port: number;
|
|
32
|
+
/** Implicit TLS (`smtps://`, usually port 465). `false` starts the session in the clear. */
|
|
33
|
+
readonly tls: boolean;
|
|
34
|
+
readonly timeoutMs: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type SmtpConnector = (target: SmtpTarget) => Promise<SmtpStream>;
|
|
38
|
+
|
|
39
|
+
export interface SmtpEnvelope {
|
|
40
|
+
/** The bare addr-spec for `MAIL FROM`, never a `Name <addr>` display form. */
|
|
41
|
+
readonly from: string;
|
|
42
|
+
readonly recipients: readonly string[];
|
|
43
|
+
/** The MIME message. Dot-stuffing and the terminator belong to this module. */
|
|
44
|
+
readonly data: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SmtpSessionOptions {
|
|
48
|
+
/** The `EHLO` argument. A real name the receiving server can resolve, not `localhost`. */
|
|
49
|
+
readonly clientName: string;
|
|
50
|
+
/** True when the socket is already TLS, so STARTTLS is neither needed nor offered. */
|
|
51
|
+
readonly secure: boolean;
|
|
52
|
+
readonly user?: string | undefined;
|
|
53
|
+
readonly password?: string | undefined;
|
|
54
|
+
/** Send — and authenticate — over a cleartext channel. Off by default, for obvious reasons. */
|
|
55
|
+
readonly allowInsecure: boolean;
|
|
56
|
+
readonly timeoutMs: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The goodbye is not worth the read deadline the rest of the conversation gets. */
|
|
60
|
+
const QUIT_TIMEOUT_MS = 5_000;
|
|
61
|
+
|
|
62
|
+
// Keyed by `SendStage`, so a stage that is added to the union and forgotten here is a lookup that
|
|
63
|
+
// falls back rather than a silent typo. `Partial` because `connect` and `request` never reach a
|
|
64
|
+
// server reply: one belongs to the socket and the other to the HTTPS transport.
|
|
65
|
+
const FIXES: Readonly<Partial<Record<SendStage, string>>> = {
|
|
66
|
+
greeting: 'correct the host and port in SMTP_URL — the server did not open with 220',
|
|
67
|
+
ehlo: 'point SMTP_URL at an ESMTP server (submission on 587, implicit TLS on 465)',
|
|
68
|
+
reply: 'point SMTP_URL at the SMTP port itself — a proxy or an HTTP port answers like this',
|
|
69
|
+
tls: 'fix the certificate on the implicit-TLS port, or use smtp://host:587 and STARTTLS',
|
|
70
|
+
starttls: 'set SMTP_URL in .env to smtps://host:465',
|
|
71
|
+
auth: 'set SMTP_URL in .env to smtps://user:password@host:465 with a valid user and password',
|
|
72
|
+
from: 'set mail.from in app.config.ts to an address this server is willing to relay for',
|
|
73
|
+
recipient: 'the reply above names the address the server refused — correct or drop it',
|
|
74
|
+
data: 'the reply above says why the body was refused (size, content or policy)',
|
|
75
|
+
quit: 'nothing to fix: the message was already accepted before the goodbye failed',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const refused = (stage: SendStage, reply: SmtpReply): MailError =>
|
|
79
|
+
sendFailed({
|
|
80
|
+
driver: 'smtp',
|
|
81
|
+
stage,
|
|
82
|
+
detail: replySummary(reply),
|
|
83
|
+
status: reply.code,
|
|
84
|
+
retryable: isTransient(reply.code),
|
|
85
|
+
fix: FIXES[stage] ?? 'run x doctor --json and check the mail section',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/** Reads whole replies off a chunked stream, with a deadline on every one of them. */
|
|
89
|
+
class Conversation {
|
|
90
|
+
private readonly parser = createReplyParser();
|
|
91
|
+
private readonly pending: SmtpReply[] = [];
|
|
92
|
+
|
|
93
|
+
constructor(
|
|
94
|
+
private readonly stream: SmtpStream,
|
|
95
|
+
private readonly timeoutMs: number,
|
|
96
|
+
) {}
|
|
97
|
+
|
|
98
|
+
/** Sends one command line and reads the reply it expects. The line is never logged. */
|
|
99
|
+
async say(stage: SendStage, line: string, wanted: (code: number) => boolean): Promise<SmtpReply> {
|
|
100
|
+
await this.stream.write(`${line}\r\n`);
|
|
101
|
+
return this.expect(stage, wanted);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async expect(stage: SendStage, wanted: (code: number) => boolean): Promise<SmtpReply> {
|
|
105
|
+
const reply = await this.next(stage);
|
|
106
|
+
if (!wanted(reply.code)) throw refused(stage, reply);
|
|
107
|
+
return reply;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Waits for the `221` so the server records a clean close instead of an aborted transaction —
|
|
112
|
+
* but on a short leash, since the message is already accepted and a rude server that never
|
|
113
|
+
* answers must not add its own delay to every send. Failures here are deliberately swallowed.
|
|
114
|
+
*/
|
|
115
|
+
async quit(): Promise<void> {
|
|
116
|
+
await this.stream.write('QUIT\r\n').catch(() => undefined);
|
|
117
|
+
await this.next('quit', Math.min(this.timeoutMs, QUIT_TIMEOUT_MS)).catch(() => undefined);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async next(stage: SendStage, timeoutMs = this.timeoutMs): Promise<SmtpReply> {
|
|
121
|
+
for (;;) {
|
|
122
|
+
const ready = this.pending.shift();
|
|
123
|
+
if (ready !== undefined) return ready;
|
|
124
|
+
this.pending.push(...this.parser.push(await this.chunk(stage, timeoutMs)));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private async chunk(stage: SendStage, timeoutMs: number): Promise<string> {
|
|
129
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
130
|
+
const expired = new Promise<never>((_resolve, reject) => {
|
|
131
|
+
timer = setTimeout(() => {
|
|
132
|
+
reject(
|
|
133
|
+
sendFailed({
|
|
134
|
+
driver: 'smtp',
|
|
135
|
+
stage,
|
|
136
|
+
detail: `the server sent nothing for ${timeoutMs}ms, so the read deadline expired`,
|
|
137
|
+
retryable: true,
|
|
138
|
+
fix: 'pass timeoutMs: 60_000 to createSmtpDriver() in app.config.ts',
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
}, timeoutMs);
|
|
142
|
+
});
|
|
143
|
+
try {
|
|
144
|
+
const chunk = await Promise.race([this.stream.read(), expired]);
|
|
145
|
+
if (chunk === undefined) {
|
|
146
|
+
throw sendFailed({
|
|
147
|
+
driver: 'smtp',
|
|
148
|
+
stage,
|
|
149
|
+
detail:
|
|
150
|
+
'the server closed the connection mid-conversation, which is usually it ' +
|
|
151
|
+
'rate-limiting the sessions it keeps open at once',
|
|
152
|
+
retryable: true,
|
|
153
|
+
fix: 'pass poolSize: 1 to createSmtpDriver() in app.config.ts',
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return chunk;
|
|
157
|
+
} finally {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* One message, one connection. Returns the server's final `DATA` reply — the receipt an operator
|
|
165
|
+
* needs when the recipient says the mail never arrived.
|
|
166
|
+
*/
|
|
167
|
+
export async function smtpDeliver(
|
|
168
|
+
stream: SmtpStream,
|
|
169
|
+
envelope: SmtpEnvelope,
|
|
170
|
+
options: SmtpSessionOptions,
|
|
171
|
+
): Promise<SmtpReply> {
|
|
172
|
+
const talk = new Conversation(stream, options.timeoutMs);
|
|
173
|
+
await talk.expect('greeting', (code) => code === 220);
|
|
174
|
+
|
|
175
|
+
const greet = async (): Promise<SmtpCapabilities> =>
|
|
176
|
+
parseCapabilities(await talk.say('ehlo', `EHLO ${options.clientName}`, isPositive));
|
|
177
|
+
|
|
178
|
+
let capabilities = await greet();
|
|
179
|
+
let secure = options.secure;
|
|
180
|
+
|
|
181
|
+
if (!secure && capabilities.starttls) {
|
|
182
|
+
await talk.say('starttls', 'STARTTLS', (code) => code === 220);
|
|
183
|
+
await stream.startTls();
|
|
184
|
+
// Capabilities before TLS are not the capabilities after it: most servers only advertise AUTH
|
|
185
|
+
// once the channel is encrypted, and a cleartext EHLO can be stripped in flight anyway.
|
|
186
|
+
capabilities = await greet();
|
|
187
|
+
secure = true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!secure && !options.allowInsecure) {
|
|
191
|
+
throw sendFailed({
|
|
192
|
+
driver: 'smtp',
|
|
193
|
+
stage: 'starttls',
|
|
194
|
+
detail:
|
|
195
|
+
'the server does not advertise STARTTLS and the connection is not already TLS; ' +
|
|
196
|
+
'allowInsecure: true on createSmtpDriver() would send this in the clear instead',
|
|
197
|
+
retryable: false,
|
|
198
|
+
fix: FIXES['starttls'] ?? 'set SMTP_URL in .env to smtps://host:465',
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (options.user !== undefined) await authenticate(talk, capabilities, options);
|
|
203
|
+
|
|
204
|
+
await talk.say('from', `MAIL FROM:<${envelope.from}>`, isPositive);
|
|
205
|
+
for (const recipient of envelope.recipients) {
|
|
206
|
+
// Fail closed on any refusal: delivering to three of four addresses and reporting success is
|
|
207
|
+
// the one outcome the caller cannot detect.
|
|
208
|
+
await talk.say('recipient', `RCPT TO:<${recipient}>`, isPositive);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await talk.say('data', 'DATA', (code) => code === 354);
|
|
212
|
+
const body = dotStuff(envelope.data).replace(/\r\n$/, '');
|
|
213
|
+
const accepted = await talk.say('data', `${body}\r\n.`, isPositive);
|
|
214
|
+
await talk.quit();
|
|
215
|
+
return accepted;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function authenticate(
|
|
219
|
+
talk: Conversation,
|
|
220
|
+
capabilities: SmtpCapabilities,
|
|
221
|
+
options: SmtpSessionOptions,
|
|
222
|
+
): Promise<void> {
|
|
223
|
+
const user = options.user ?? '';
|
|
224
|
+
const password = options.password ?? '';
|
|
225
|
+
const mechanisms = capabilities.authMechanisms;
|
|
226
|
+
|
|
227
|
+
if (mechanisms.includes('PLAIN')) {
|
|
228
|
+
await talk.say('auth', `AUTH PLAIN ${authPlain(user, password)}`, (code) => code === 235);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (mechanisms.includes('LOGIN')) {
|
|
232
|
+
await talk.say('auth', 'AUTH LOGIN', (code) => code === 334);
|
|
233
|
+
await talk.say('auth', base64Utf8(user), (code) => code === 334);
|
|
234
|
+
await talk.say('auth', base64Utf8(password), (code) => code === 235);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
throw sendFailed({
|
|
238
|
+
driver: 'smtp',
|
|
239
|
+
stage: 'auth',
|
|
240
|
+
detail: `the server offers no mechanism this client speaks (offered: ${
|
|
241
|
+
mechanisms.join(', ') || 'none'
|
|
242
|
+
})`,
|
|
243
|
+
retryable: false,
|
|
244
|
+
fix: 'drop user:password from SMTP_URL if the server wants none, or enable AUTH PLAIN on it',
|
|
245
|
+
});
|
|
246
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Single responsibility: pure SMTP protocol parsing and encoding — reply framing, EHLO
|
|
2
|
+
// capability parsing, AUTH payloads, DATA-phase dot-stuffing. No sockets, no IO: `smtp-client.ts`
|
|
3
|
+
// drives the connection and calls into these functions with raw chunks, getting typed values back.
|
|
4
|
+
|
|
5
|
+
import { base64Utf8 } from './base64';
|
|
6
|
+
import { sendFailed } from './errors';
|
|
7
|
+
|
|
8
|
+
/** One complete server reply. `text` is every continuation line joined by a space. */
|
|
9
|
+
export interface SmtpReply {
|
|
10
|
+
readonly code: number;
|
|
11
|
+
readonly lines: readonly string[];
|
|
12
|
+
readonly text: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SmtpCapabilities {
|
|
16
|
+
readonly starttls: boolean;
|
|
17
|
+
/** Upper-cased, in the order the server advertised them: e.g. `['PLAIN','LOGIN']`. */
|
|
18
|
+
readonly authMechanisms: readonly string[];
|
|
19
|
+
/** From `SIZE 35882577`. Absent when the server does not advertise one. */
|
|
20
|
+
readonly maxSizeBytes?: number | undefined;
|
|
21
|
+
readonly eightBitMime: boolean;
|
|
22
|
+
readonly pipelining: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ReplyParser {
|
|
26
|
+
/** Feed a socket chunk; returns every reply that completed inside it, in order. */
|
|
27
|
+
push(chunk: string): readonly SmtpReply[];
|
|
28
|
+
/** True when a partial line is still buffered — a connection closing here died mid-reply. */
|
|
29
|
+
hasPending(): boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Three digits then `-` (more lines follow), ` ` (last line) or end-of-line (a bare code with
|
|
33
|
+
// no text, also a last line). Anything else — four digits, no digits, prose — is not a reply
|
|
34
|
+
// line and must be skipped, never crashed on or folded into a neighbouring reply.
|
|
35
|
+
const REPLY_LINE = /^(\d{3})(?:([- ])(.*))?$/;
|
|
36
|
+
|
|
37
|
+
// RFC 5321 caps a reply line at 512 octets and a full reply is a handful of them, so nothing
|
|
38
|
+
// legitimate comes near this. The cap exists because the read deadline in `smtp-client.ts`
|
|
39
|
+
// measures the gap between chunks: a peer that dribbles bytes with no line ending resets that
|
|
40
|
+
// deadline on every read while this buffer grows, which is an unbounded allocation no timeout
|
|
41
|
+
// ever interrupts. Coded failure > OOM.
|
|
42
|
+
const MAX_REPLY_BYTES = 64 * 1024;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Buffers socket chunks into complete `SmtpReply` values. A chunk can split anywhere — mid-line,
|
|
46
|
+
* mid-CRLF, or carry several replies at once — so the dangling partial line and any continuation
|
|
47
|
+
* lines already read for a reply whose final line has not arrived yet both live in the closure
|
|
48
|
+
* and survive across `push()` calls.
|
|
49
|
+
*/
|
|
50
|
+
export function createReplyParser(): ReplyParser {
|
|
51
|
+
let buffer = '';
|
|
52
|
+
let pendingLines: string[] | null = null;
|
|
53
|
+
// Continuation lines held for a reply whose final line never arrives are the same unbounded
|
|
54
|
+
// growth as an endless partial line, so both count against the one cap.
|
|
55
|
+
let pendingBytes = 0;
|
|
56
|
+
|
|
57
|
+
const guard = (): void => {
|
|
58
|
+
if (buffer.length + pendingBytes <= MAX_REPLY_BYTES) return;
|
|
59
|
+
throw sendFailed({
|
|
60
|
+
driver: 'smtp',
|
|
61
|
+
stage: 'reply',
|
|
62
|
+
detail: `the server sent more than ${MAX_REPLY_BYTES} bytes without completing one reply`,
|
|
63
|
+
retryable: true,
|
|
64
|
+
fix: 'point SMTP_URL at the SMTP port itself — a proxy or an HTTP port answers like this',
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
push(chunk: string): readonly SmtpReply[] {
|
|
70
|
+
buffer += chunk;
|
|
71
|
+
const rawLines = buffer.split('\n');
|
|
72
|
+
buffer = rawLines.pop() ?? '';
|
|
73
|
+
|
|
74
|
+
const replies: SmtpReply[] = [];
|
|
75
|
+
for (const rawLine of rawLines) {
|
|
76
|
+
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
|
|
77
|
+
if (line.length === 0) continue; // blank line: not part of any reply
|
|
78
|
+
|
|
79
|
+
const match = REPLY_LINE.exec(line);
|
|
80
|
+
if (!match) continue; // garbage: skipped, not merged into the surrounding reply
|
|
81
|
+
|
|
82
|
+
const codeText = match[1];
|
|
83
|
+
if (codeText === undefined) continue; // the group is mandatory; narrows the type for TS
|
|
84
|
+
|
|
85
|
+
pendingLines ??= [];
|
|
86
|
+
pendingLines.push(match[3] ?? '');
|
|
87
|
+
pendingBytes += line.length;
|
|
88
|
+
|
|
89
|
+
if (match[2] !== '-') {
|
|
90
|
+
replies.push({
|
|
91
|
+
code: Number(codeText),
|
|
92
|
+
lines: pendingLines,
|
|
93
|
+
text: pendingLines.join(' '),
|
|
94
|
+
});
|
|
95
|
+
pendingLines = null;
|
|
96
|
+
pendingBytes = 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Checked after the loop so only what is still unresolved counts: every reply this chunk
|
|
101
|
+
// completed has already drained both counters, and a well-behaved server never accumulates.
|
|
102
|
+
guard();
|
|
103
|
+
return replies;
|
|
104
|
+
},
|
|
105
|
+
hasPending(): boolean {
|
|
106
|
+
return buffer.length > 0 || pendingLines !== null;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const STARTTLS_LINE = /^STARTTLS$/i;
|
|
112
|
+
const PIPELINING_LINE = /^PIPELINING$/i;
|
|
113
|
+
const EIGHT_BIT_MIME_LINE = /^8BITMIME$/i;
|
|
114
|
+
const SIZE_LINE = /^SIZE(?:\s+(\S+))?$/i;
|
|
115
|
+
// Some servers still send the legacy `AUTH=PLAIN LOGIN` form instead of `AUTH PLAIN LOGIN`;
|
|
116
|
+
// `[=\s]+` accepts either separator, and mechanisms are re-split on any run of spaces too.
|
|
117
|
+
const AUTH_LINE = /^AUTH[=\s]+(.*)$/i;
|
|
118
|
+
|
|
119
|
+
/** `EHLO` reply -> what the server can do. The greeting line itself is not a capability. */
|
|
120
|
+
export function parseCapabilities(reply: SmtpReply): SmtpCapabilities {
|
|
121
|
+
let starttls = false;
|
|
122
|
+
let pipelining = false;
|
|
123
|
+
let eightBitMime = false;
|
|
124
|
+
let maxSizeBytes: number | undefined;
|
|
125
|
+
const authMechanisms: string[] = [];
|
|
126
|
+
|
|
127
|
+
for (const raw of reply.lines.slice(1)) {
|
|
128
|
+
const line = raw.trim();
|
|
129
|
+
const sizeMatch = SIZE_LINE.exec(line);
|
|
130
|
+
const authMatch = AUTH_LINE.exec(line);
|
|
131
|
+
|
|
132
|
+
if (STARTTLS_LINE.test(line)) {
|
|
133
|
+
starttls = true;
|
|
134
|
+
} else if (PIPELINING_LINE.test(line)) {
|
|
135
|
+
pipelining = true;
|
|
136
|
+
} else if (EIGHT_BIT_MIME_LINE.test(line)) {
|
|
137
|
+
eightBitMime = true;
|
|
138
|
+
} else if (sizeMatch) {
|
|
139
|
+
const value = sizeMatch[1];
|
|
140
|
+
// A non-numeric or missing size is silently absent — never `Number('abc')`'s `NaN`.
|
|
141
|
+
if (value !== undefined && /^\d+$/.test(value)) maxSizeBytes = Number(value);
|
|
142
|
+
} else if (authMatch) {
|
|
143
|
+
for (const mechanism of (authMatch[1] ?? '').split(/\s+/)) {
|
|
144
|
+
if (mechanism.length > 0) authMechanisms.push(mechanism.toUpperCase());
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { starttls, authMechanisms, maxSizeBytes, eightBitMime, pipelining };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** RFC 4616: base64 of `\0user\0password`. */
|
|
153
|
+
export function authPlain(user: string, password: string): string {
|
|
154
|
+
return base64Utf8(`\0${user}\0${password}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** True for a 4xx reply: a greylist or throttle that the job's next attempt can clear. */
|
|
158
|
+
export function isTransient(code: number): boolean {
|
|
159
|
+
return code >= 400 && code < 500;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** True for 2xx. 3xx is a continuation (`354` for DATA, `334` for AUTH), never a success. */
|
|
163
|
+
export function isPositive(code: number): boolean {
|
|
164
|
+
return code >= 200 && code < 300;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const LINE_BREAK = /\r\n|\n/;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* RFC 5321 transparency: CRLF-normalise the message and double any leading `.` so a body line
|
|
171
|
+
* of `.` cannot end the DATA phase early. Does NOT append the `\r\n.\r\n` terminator — the
|
|
172
|
+
* client owns that.
|
|
173
|
+
*/
|
|
174
|
+
export function dotStuff(body: string): string {
|
|
175
|
+
return body
|
|
176
|
+
.split(LINE_BREAK)
|
|
177
|
+
.map((line) => (line.startsWith('.') ? `.${line}` : line))
|
|
178
|
+
.join('\r\n');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** One line fit for an error `cause`: `550 5.1.1 no such user`. Never multi-line. */
|
|
182
|
+
export function replySummary(reply: SmtpReply): string {
|
|
183
|
+
return reply.text.length === 0 ? `${reply.code}` : `${reply.code} ${reply.text}`;
|
|
184
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// Single responsibility: the one production `SmtpStream`, over `Bun.connect`. Bun pushes bytes at
|
|
2
|
+
// handlers while the conversation pulls replies, so a queue sits between them; `startTls` swaps in
|
|
3
|
+
// the upgraded socket for STARTTLS. Nothing here knows an SMTP verb — that is `smtp-client.ts`.
|
|
4
|
+
|
|
5
|
+
import { type MailError, sendFailed } from './errors';
|
|
6
|
+
import type { SmtpStream, SmtpTarget } from './smtp-client';
|
|
7
|
+
|
|
8
|
+
/** Structural view of Bun's socket — declared here so the contract does not depend on bun-types. */
|
|
9
|
+
export interface SocketLike {
|
|
10
|
+
write(data: Uint8Array): number;
|
|
11
|
+
end(): void;
|
|
12
|
+
upgradeTLS(options: {
|
|
13
|
+
readonly tls: { readonly serverName: string };
|
|
14
|
+
readonly socket: SocketHandlers;
|
|
15
|
+
}): readonly SocketLike[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SocketHandlers {
|
|
19
|
+
data(socket: SocketLike, data: Uint8Array): void;
|
|
20
|
+
close(): void;
|
|
21
|
+
end(): void;
|
|
22
|
+
drain(): void;
|
|
23
|
+
error(socket: SocketLike, error: Error): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface BunConnect {
|
|
27
|
+
connect(options: {
|
|
28
|
+
readonly hostname: string;
|
|
29
|
+
readonly port: number;
|
|
30
|
+
readonly tls: boolean;
|
|
31
|
+
readonly socket: SocketHandlers;
|
|
32
|
+
}): Promise<SocketLike>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Waiter {
|
|
36
|
+
readonly resolve: (chunk: string | undefined) => void;
|
|
37
|
+
readonly reject: (error: Error) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Chunks the socket pushed, handed out one `read()` at a time — EOF and socket errors included. */
|
|
41
|
+
class ChunkQueue {
|
|
42
|
+
private readonly chunks: string[] = [];
|
|
43
|
+
private waiting: Waiter | undefined;
|
|
44
|
+
private ended = false;
|
|
45
|
+
private failure: Error | undefined;
|
|
46
|
+
|
|
47
|
+
push(chunk: string): void {
|
|
48
|
+
const waiter = this.take();
|
|
49
|
+
if (waiter === undefined) {
|
|
50
|
+
this.chunks.push(chunk);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
waiter.resolve(chunk);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** EOF. A reader parked on `read()` is released rather than left hanging forever. */
|
|
57
|
+
end(): void {
|
|
58
|
+
this.ended = true;
|
|
59
|
+
this.take()?.resolve(undefined);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A socket-level failure (TLS, reset) reaches the caller instead of looking like a clean EOF. */
|
|
63
|
+
fail(error: Error): void {
|
|
64
|
+
this.failure = error;
|
|
65
|
+
this.ended = true;
|
|
66
|
+
this.take()?.reject(error);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
read(): Promise<string | undefined> {
|
|
70
|
+
const next = this.chunks.shift();
|
|
71
|
+
if (next !== undefined) return Promise.resolve(next);
|
|
72
|
+
if (this.failure !== undefined) return Promise.reject(this.failure);
|
|
73
|
+
if (this.ended) return Promise.resolve(undefined);
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
this.waiting = { resolve, reject };
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private take(): Waiter | undefined {
|
|
80
|
+
const waiter = this.waiting;
|
|
81
|
+
this.waiting = undefined;
|
|
82
|
+
return waiter;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A write parked for backpressure: released by `drain`, failed by whatever ends the socket. */
|
|
87
|
+
interface DrainWaiter {
|
|
88
|
+
readonly resolve: () => void;
|
|
89
|
+
readonly reject: (error: MailError) => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Which TLS negotiation is in flight, and therefore how a failure now has to be reported: `tls` is
|
|
94
|
+
* the implicit handshake an `smtps://` connection opens with, `starttls` the in-band upgrade. Both
|
|
95
|
+
* windows close on the first byte back — encrypted bytes only flow once the handshake completed,
|
|
96
|
+
* so that first chunk is the proof it did. `undefined` is a channel with no handshake pending.
|
|
97
|
+
*/
|
|
98
|
+
type Handshake = 'tls' | 'starttls' | undefined;
|
|
99
|
+
|
|
100
|
+
const encoder = new TextEncoder();
|
|
101
|
+
const decoder = new TextDecoder();
|
|
102
|
+
|
|
103
|
+
export function bunSmtpStream(target: SmtpTarget): Promise<SmtpStream> {
|
|
104
|
+
return smtpStreamOver(Bun as unknown as BunConnect, target);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `bunSmtpStream` with the runtime handed in. A `write` that returns `-1`, an `error` during the
|
|
109
|
+
* TLS handoff and a `drain` that never comes are all unreachable over a real socket, and each one
|
|
110
|
+
* used to cost a full deadline or a wrong stage — so they are driven by hand in the tests.
|
|
111
|
+
*/
|
|
112
|
+
export function smtpStreamOver(runtime: BunConnect, target: SmtpTarget): Promise<SmtpStream> {
|
|
113
|
+
const queue = new ChunkQueue();
|
|
114
|
+
let draining: DrainWaiter | undefined;
|
|
115
|
+
// `smtps://` hands the socket to TLS before a single SMTP byte is exchanged, so the window is
|
|
116
|
+
// already open when the connection is made; a plaintext one opens it at `startTls()` or never.
|
|
117
|
+
let handshake: Handshake = target.tls ? 'tls' : undefined;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A failed handshake is a refused certificate or a protocol mismatch, and the same attempt fails
|
|
121
|
+
* identically forever — reporting it as retryable requeues a job against a wall. The two windows
|
|
122
|
+
* differ only in the command that reproduces them, so they are two stages, not one.
|
|
123
|
+
*/
|
|
124
|
+
const handshakeFailure = (kind: 'tls' | 'starttls', detail: string): MailError =>
|
|
125
|
+
sendFailed({
|
|
126
|
+
driver: 'smtp',
|
|
127
|
+
stage: kind,
|
|
128
|
+
detail: `the TLS handshake with ${target.host} failed: ${detail}`,
|
|
129
|
+
retryable: false,
|
|
130
|
+
fix:
|
|
131
|
+
`check the certificate and protocol the host offers: openssl s_client ` +
|
|
132
|
+
`${kind === 'starttls' ? '-starttls smtp ' : ''}-connect ${target.host}:${target.port}`,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/** Outside a handshake, a dead socket really is transient — a reset, a rate limit, a restart. */
|
|
136
|
+
const socketFailure = (detail: string): MailError =>
|
|
137
|
+
handshake === undefined
|
|
138
|
+
? sendFailed({
|
|
139
|
+
driver: 'smtp',
|
|
140
|
+
stage: 'data',
|
|
141
|
+
detail,
|
|
142
|
+
retryable: true,
|
|
143
|
+
fix: `read the SMTP server log on ${target.host} — the job will retry automatically`,
|
|
144
|
+
})
|
|
145
|
+
: handshakeFailure(handshake, detail);
|
|
146
|
+
|
|
147
|
+
const failDrain = (error: MailError): void => {
|
|
148
|
+
const waiter = draining;
|
|
149
|
+
draining = undefined;
|
|
150
|
+
waiter?.reject(error);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** What an ended socket means, in one place: `close` and `end` differ only in their wording. */
|
|
154
|
+
const died = (detail: string): void => {
|
|
155
|
+
// A write parked for `drain` can never get one from a socket that is gone; failing it here is
|
|
156
|
+
// the difference between an immediate error and burning the whole deadline first.
|
|
157
|
+
if (draining !== undefined) failDrain(socketFailure(detail));
|
|
158
|
+
// EOF inside a handshake window is a refused handshake, not the clean end of a conversation.
|
|
159
|
+
if (handshake !== undefined) queue.fail(socketFailure(detail));
|
|
160
|
+
else queue.end();
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const handlers: SocketHandlers = {
|
|
164
|
+
data: (_socket, data) => {
|
|
165
|
+
handshake = undefined;
|
|
166
|
+
queue.push(decoder.decode(data));
|
|
167
|
+
},
|
|
168
|
+
close: () => died('the socket closed'),
|
|
169
|
+
end: () => died('the server half-closed the socket'),
|
|
170
|
+
drain: () => {
|
|
171
|
+
const waiter = draining;
|
|
172
|
+
draining = undefined;
|
|
173
|
+
waiter?.resolve();
|
|
174
|
+
},
|
|
175
|
+
error: (_socket, error) => {
|
|
176
|
+
const failure = socketFailure(error.message);
|
|
177
|
+
failDrain(failure);
|
|
178
|
+
queue.fail(failure);
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const opening = runtime.connect({
|
|
183
|
+
hostname: target.host,
|
|
184
|
+
port: target.port,
|
|
185
|
+
tls: target.tls,
|
|
186
|
+
socket: handlers,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return opening.then((opened): SmtpStream => {
|
|
190
|
+
let socket = opened;
|
|
191
|
+
|
|
192
|
+
const waitForDrain = (): Promise<void> =>
|
|
193
|
+
new Promise((resolve, reject) => {
|
|
194
|
+
const timer = setTimeout(() => {
|
|
195
|
+
draining = undefined;
|
|
196
|
+
reject(
|
|
197
|
+
sendFailed({
|
|
198
|
+
driver: 'smtp',
|
|
199
|
+
stage: 'data',
|
|
200
|
+
detail: `the socket stopped accepting bytes for ${target.timeoutMs}ms`,
|
|
201
|
+
retryable: true,
|
|
202
|
+
fix: 'pass timeoutMs: 60_000 to createSmtpDriver() in app.config.ts',
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
}, target.timeoutMs);
|
|
206
|
+
draining = {
|
|
207
|
+
resolve: () => {
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
resolve();
|
|
210
|
+
},
|
|
211
|
+
reject: (error) => {
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
reject(error);
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const flush = async (bytes: Uint8Array): Promise<void> => {
|
|
219
|
+
let rest = bytes;
|
|
220
|
+
while (rest.length > 0) {
|
|
221
|
+
const written = socket.write(rest);
|
|
222
|
+
if (written >= rest.length) return;
|
|
223
|
+
// A negative count is a refusal, not backpressure: no `drain` follows a socket that cannot
|
|
224
|
+
// emit one, so waiting for it would park this write until the deadline and call a closed
|
|
225
|
+
// connection a slow one.
|
|
226
|
+
if (written < 0) throw socketFailure(`the socket refused a ${rest.length}-byte write`);
|
|
227
|
+
// Backpressure: a 200KB message does not fit one buffer. Wait for `drain`, bounded by the
|
|
228
|
+
// same deadline as a read so a stalled socket cannot hold a worker slot forever.
|
|
229
|
+
if (written > 0) rest = rest.subarray(written);
|
|
230
|
+
await waitForDrain();
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
read: () => queue.read(),
|
|
236
|
+
write: (data: string) => flush(encoder.encode(data)),
|
|
237
|
+
startTls: () => {
|
|
238
|
+
// Bun hands back `[raw, tls]`; every later read and write goes through the second one, and
|
|
239
|
+
// the handlers are re-registered because the upgraded socket is a different object.
|
|
240
|
+
const upgraded = socket.upgradeTLS({
|
|
241
|
+
tls: { serverName: target.host },
|
|
242
|
+
socket: handlers,
|
|
243
|
+
})[1];
|
|
244
|
+
if (upgraded === undefined) {
|
|
245
|
+
return Promise.reject(
|
|
246
|
+
sendFailed({
|
|
247
|
+
driver: 'smtp',
|
|
248
|
+
stage: 'starttls',
|
|
249
|
+
detail: 'the runtime returned no TLS socket for the STARTTLS upgrade',
|
|
250
|
+
retryable: false,
|
|
251
|
+
fix: 'upgrade the runtime: bun upgrade # STARTTLS needs bun >= 1.3',
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
socket = upgraded;
|
|
256
|
+
// Writes made now are buffered by the runtime until the handshake completes, so the client
|
|
257
|
+
// may send EHLO straight away — what changes is how a failure from here is reported.
|
|
258
|
+
handshake = 'starttls';
|
|
259
|
+
return Promise.resolve();
|
|
260
|
+
},
|
|
261
|
+
close: () => {
|
|
262
|
+
socket.end();
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
}
|