@zerotal/notifications 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/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/package.json +58 -0
- package/src/BroadcastChannel.ts +80 -0
- package/src/BroadcastMessage.ts +32 -0
- package/src/BroadcastNotificationJob.ts +51 -0
- package/src/DatabaseChannel.ts +295 -0
- package/src/MailChannel.ts +93 -0
- package/src/Notifiable.ts +95 -0
- package/src/Notification.ts +114 -0
- package/src/NotificationFake.ts +282 -0
- package/src/NotificationManager.ts +269 -0
- package/src/NotificationRegistry.ts +37 -0
- package/src/OnDemandNotifiable.ts +46 -0
- package/src/SendNotificationJob.ts +67 -0
- package/src/SlackChannel.ts +76 -0
- package/src/SmsChannel.ts +151 -0
- package/src/admin.ts +219 -0
- package/src/commands/NotificationsPruneCommand.ts +54 -0
- package/src/commands/NotificationsTestCommand.ts +63 -0
- package/src/commands/index.ts +2 -0
- package/src/config.ts +122 -0
- package/src/drivers/LogDriver.ts +41 -0
- package/src/drivers/MailDriver.ts +54 -0
- package/src/drivers/ResendDriver.ts +54 -0
- package/src/drivers/SmtpDriver.ts +510 -0
- package/src/errors.ts +163 -0
- package/src/events.ts +70 -0
- package/src/facades/Notify.ts +3 -0
- package/src/global.d.ts +31 -0
- package/src/index.ts +68 -0
- package/src/messages/MailMessage.ts +342 -0
- package/src/observability.ts +146 -0
- package/src/provider/NotificationProvider.ts +62 -0
- package/src/serialization.ts +188 -0
- package/src/stats.ts +93 -0
- package/src/types.ts +129 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import type { MailDriver, MailPayload, MailAddress } from "./MailDriver.ts";
|
|
2
|
+
import { SmtpResponseError, SmtpConnectionError } from "../errors.ts";
|
|
3
|
+
|
|
4
|
+
/** How long to wait for any single SMTP reply before giving up. */
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* SMTP driver — raw TCP via `Bun.connect()`, with TLS.
|
|
9
|
+
*
|
|
10
|
+
* Three transport modes, chosen by `secure` and what the server advertises:
|
|
11
|
+
*
|
|
12
|
+
* - `secure: true` — implicit TLS from the first byte (SMTPS, usually port 465).
|
|
13
|
+
* - `secure: false` on a server advertising STARTTLS — connects in the clear,
|
|
14
|
+
* then upgrades before authenticating (submission, usually port 587).
|
|
15
|
+
* - `secure: false` on a server without STARTTLS — stays plaintext. Credentials
|
|
16
|
+
* are refused in this mode unless `allowInsecureAuth` is set, because `AUTH
|
|
17
|
+
* LOGIN` is base64, not encryption.
|
|
18
|
+
*
|
|
19
|
+
* Every reply is parsed and its status code checked, so a rejected recipient or a
|
|
20
|
+
* failed authentication raises {@link SmtpResponseError} instead of being
|
|
21
|
+
* mistaken for a successful send.
|
|
22
|
+
*/
|
|
23
|
+
export class SmtpDriver implements MailDriver {
|
|
24
|
+
constructor(
|
|
25
|
+
private _host: string,
|
|
26
|
+
private _port: number,
|
|
27
|
+
private _username: string,
|
|
28
|
+
private _password: string,
|
|
29
|
+
private _secure: boolean = false,
|
|
30
|
+
private _options: {
|
|
31
|
+
/** Permit AUTH over an unencrypted connection. Off by default. */
|
|
32
|
+
allowInsecureAuth?: boolean;
|
|
33
|
+
/** Reject servers presenting an untrusted certificate. Default: true. */
|
|
34
|
+
rejectUnauthorized?: boolean;
|
|
35
|
+
/** Per-reply timeout in milliseconds. Default: 30000. */
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
/** Name sent in EHLO. Default: "zerotal". */
|
|
38
|
+
clientName?: string;
|
|
39
|
+
} = {},
|
|
40
|
+
) {}
|
|
41
|
+
|
|
42
|
+
async send(message: MailPayload): Promise<void> {
|
|
43
|
+
const conn = await SmtpConnection.open(this._host, this._port, {
|
|
44
|
+
tls: this._secure,
|
|
45
|
+
rejectUnauthorized: this._options.rejectUnauthorized ?? true,
|
|
46
|
+
timeoutMs: this._options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await conn.expect(220);
|
|
51
|
+
|
|
52
|
+
const clientName = this._options.clientName ?? "zerotal";
|
|
53
|
+
let capabilities = await this._ehlo(conn, clientName);
|
|
54
|
+
|
|
55
|
+
// Upgrade an unencrypted submission connection before anything sensitive.
|
|
56
|
+
if (!this._secure && capabilities.includes("STARTTLS")) {
|
|
57
|
+
conn.send("STARTTLS");
|
|
58
|
+
await conn.expect(220);
|
|
59
|
+
await conn.upgradeTLS(this._host, this._options.rejectUnauthorized ?? true);
|
|
60
|
+
capabilities = await this._ehlo(conn, clientName);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (this._username && this._password) {
|
|
64
|
+
if (!conn.encrypted && !this._options.allowInsecureAuth) {
|
|
65
|
+
throw new SmtpConnectionError(
|
|
66
|
+
`Refusing to send SMTP credentials over an unencrypted connection to ${this._host}:${this._port}. ` +
|
|
67
|
+
`Use secure: true (port 465), a server offering STARTTLS (port 587), or set ` +
|
|
68
|
+
`mail.smtp.allowInsecureAuth to accept the risk.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
await this._authenticate(conn, capabilities);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
conn.send(`MAIL FROM:<${message.from.address}>`);
|
|
75
|
+
await conn.expect(250);
|
|
76
|
+
|
|
77
|
+
const recipients = [...message.to, ...(message.cc ?? []), ...(message.bcc ?? [])];
|
|
78
|
+
if (recipients.length === 0) {
|
|
79
|
+
throw new SmtpConnectionError("Refusing to send a message with no recipients.");
|
|
80
|
+
}
|
|
81
|
+
for (const rcpt of recipients) {
|
|
82
|
+
conn.send(`RCPT TO:<${rcpt.address}>`);
|
|
83
|
+
// 251 = "not local, will forward" — a success.
|
|
84
|
+
await conn.expect(250, 251);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
conn.send("DATA");
|
|
88
|
+
await conn.expect(354);
|
|
89
|
+
|
|
90
|
+
conn.write(`${buildRawMessage(message)}\r\n.\r\n`);
|
|
91
|
+
await conn.expect(250);
|
|
92
|
+
|
|
93
|
+
conn.send("QUIT");
|
|
94
|
+
// A server that drops the connection instead of replying 221 has still
|
|
95
|
+
// accepted the message — the 250 above was the commit point.
|
|
96
|
+
await conn.expect(221).catch(() => undefined);
|
|
97
|
+
} finally {
|
|
98
|
+
conn.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Send EHLO and return the advertised capability lines, uppercased. */
|
|
103
|
+
private async _ehlo(conn: SmtpConnection, clientName: string): Promise<string[]> {
|
|
104
|
+
conn.send(`EHLO ${clientName}`);
|
|
105
|
+
const reply = await conn.expect(250);
|
|
106
|
+
return reply.lines.map((l) => l.slice(4).toUpperCase());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private async _authenticate(conn: SmtpConnection, capabilities: string[]): Promise<void> {
|
|
110
|
+
const authLine = capabilities.find((c) => c.startsWith("AUTH"));
|
|
111
|
+
const mechanisms = authLine ? authLine.split(/\s+/).slice(1) : [];
|
|
112
|
+
|
|
113
|
+
// PLAIN is a single round trip; LOGIN is the fallback for servers without it.
|
|
114
|
+
if (mechanisms.length === 0 || mechanisms.includes("PLAIN")) {
|
|
115
|
+
const token = btoa(`\0${this._username}\0${this._password}`);
|
|
116
|
+
conn.send(`AUTH PLAIN ${token}`);
|
|
117
|
+
await conn.expect(235);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (mechanisms.includes("LOGIN")) {
|
|
122
|
+
conn.send("AUTH LOGIN");
|
|
123
|
+
await conn.expect(334);
|
|
124
|
+
conn.send(btoa(this._username));
|
|
125
|
+
await conn.expect(334);
|
|
126
|
+
conn.send(btoa(this._password));
|
|
127
|
+
await conn.expect(235);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
throw new SmtpConnectionError(
|
|
132
|
+
`No supported SMTP auth mechanism. Server offers: ${mechanisms.join(", ") || "none"}; ` +
|
|
133
|
+
`this driver supports PLAIN and LOGIN.`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** One parsed SMTP reply: its status code and every line of it. */
|
|
139
|
+
interface SmtpReply {
|
|
140
|
+
code: number;
|
|
141
|
+
lines: string[];
|
|
142
|
+
text: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A single SMTP session — owns the socket, buffers incoming bytes, and hands
|
|
147
|
+
* back one complete reply at a time.
|
|
148
|
+
*
|
|
149
|
+
* SMTP replies are not one-per-packet: a multi-line greeting arrives as
|
|
150
|
+
* `250-SIZE\r\n250-STARTTLS\r\n250 HELP\r\n`, possibly split across TCP reads or
|
|
151
|
+
* coalesced with the next reply. Reading is therefore driven by the protocol's
|
|
152
|
+
* own framing — a reply ends at the first line whose code is followed by a space
|
|
153
|
+
* rather than a hyphen — not by packet boundaries.
|
|
154
|
+
*/
|
|
155
|
+
class SmtpConnection {
|
|
156
|
+
private _buffer = "";
|
|
157
|
+
private _replies: SmtpReply[] = [];
|
|
158
|
+
private _waiters: Array<{
|
|
159
|
+
resolve: (r: SmtpReply) => void;
|
|
160
|
+
reject: (e: Error) => void;
|
|
161
|
+
}> = [];
|
|
162
|
+
private _failure: Error | undefined;
|
|
163
|
+
private _closed = false;
|
|
164
|
+
private _encrypted: boolean;
|
|
165
|
+
|
|
166
|
+
private constructor(
|
|
167
|
+
private _socket: import("bun").Socket<undefined>,
|
|
168
|
+
private readonly _host: string,
|
|
169
|
+
private readonly _port: number,
|
|
170
|
+
private readonly _timeoutMs: number,
|
|
171
|
+
encrypted: boolean,
|
|
172
|
+
) {
|
|
173
|
+
this._encrypted = encrypted;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** True once the transport is TLS, whether implicit or upgraded. */
|
|
177
|
+
get encrypted(): boolean {
|
|
178
|
+
return this._encrypted;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
static async open(
|
|
182
|
+
host: string,
|
|
183
|
+
port: number,
|
|
184
|
+
options: { tls: boolean; rejectUnauthorized: boolean; timeoutMs: number },
|
|
185
|
+
): Promise<SmtpConnection> {
|
|
186
|
+
let conn!: SmtpConnection;
|
|
187
|
+
try {
|
|
188
|
+
const socket = await Bun.connect<undefined>({
|
|
189
|
+
hostname: host,
|
|
190
|
+
port,
|
|
191
|
+
...(options.tls
|
|
192
|
+
? { tls: { rejectUnauthorized: options.rejectUnauthorized, serverName: host } }
|
|
193
|
+
: {}),
|
|
194
|
+
socket: {
|
|
195
|
+
data: (_s, data) => conn._onData(data),
|
|
196
|
+
error: (_s, err) => conn._onError(err),
|
|
197
|
+
close: () => conn._onClose(),
|
|
198
|
+
open: () => {},
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
conn = new SmtpConnection(socket, host, port, options.timeoutMs, options.tls);
|
|
202
|
+
return conn;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
throw new SmtpConnectionError(
|
|
205
|
+
`Could not connect to SMTP server ${host}:${port} — ${
|
|
206
|
+
error instanceof Error ? error.message : String(error)
|
|
207
|
+
}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Upgrade a plaintext connection to TLS after a 220 response to STARTTLS. */
|
|
213
|
+
async upgradeTLS(host: string, rejectUnauthorized: boolean): Promise<void> {
|
|
214
|
+
try {
|
|
215
|
+
const [, tls] = this._socket.upgradeTLS<undefined>({
|
|
216
|
+
tls: { rejectUnauthorized, serverName: host },
|
|
217
|
+
socket: {
|
|
218
|
+
data: (_s, data) => this._onData(data),
|
|
219
|
+
error: (_s, err) => this._onError(err),
|
|
220
|
+
close: () => this._onClose(),
|
|
221
|
+
open: () => {},
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
this._socket = tls;
|
|
225
|
+
this._encrypted = true;
|
|
226
|
+
// The post-upgrade session starts clean: anything buffered pre-handshake
|
|
227
|
+
// belongs to the discarded plaintext stream.
|
|
228
|
+
this._buffer = "";
|
|
229
|
+
this._replies = [];
|
|
230
|
+
} catch (error) {
|
|
231
|
+
throw new SmtpConnectionError(
|
|
232
|
+
`STARTTLS upgrade failed for ${host} — ${
|
|
233
|
+
error instanceof Error ? error.message : String(error)
|
|
234
|
+
}`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Write a command plus its CRLF terminator. */
|
|
240
|
+
send(command: string): void {
|
|
241
|
+
this.write(`${command}\r\n`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
write(raw: string): void {
|
|
245
|
+
this._socket.write(new TextEncoder().encode(raw));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Read the next reply and require its code to be one of `codes`.
|
|
250
|
+
*
|
|
251
|
+
* @throws {SmtpResponseError} when the server answers with anything else.
|
|
252
|
+
*/
|
|
253
|
+
async expect(...codes: number[]): Promise<SmtpReply> {
|
|
254
|
+
const reply = await this.read();
|
|
255
|
+
if (!codes.includes(reply.code)) {
|
|
256
|
+
throw new SmtpResponseError(reply.code, reply.text, codes);
|
|
257
|
+
}
|
|
258
|
+
return reply;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Read the next complete reply, waiting for it if it has not arrived. */
|
|
262
|
+
read(): Promise<SmtpReply> {
|
|
263
|
+
const buffered = this._replies.shift();
|
|
264
|
+
if (buffered) return Promise.resolve(buffered);
|
|
265
|
+
if (this._failure) return Promise.reject(this._failure);
|
|
266
|
+
if (this._closed) {
|
|
267
|
+
return Promise.reject(
|
|
268
|
+
new SmtpConnectionError(
|
|
269
|
+
`SMTP server ${this._host}:${this._port} closed the connection unexpectedly.`,
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return new Promise<SmtpReply>((resolve, reject) => {
|
|
275
|
+
const timer = setTimeout(() => {
|
|
276
|
+
this._waiters = this._waiters.filter((w) => w.resolve !== wrapped);
|
|
277
|
+
reject(
|
|
278
|
+
new SmtpConnectionError(
|
|
279
|
+
`Timed out after ${this._timeoutMs}ms waiting for a reply from ${this._host}:${this._port}.`,
|
|
280
|
+
),
|
|
281
|
+
);
|
|
282
|
+
}, this._timeoutMs);
|
|
283
|
+
|
|
284
|
+
const wrapped = (reply: SmtpReply): void => {
|
|
285
|
+
clearTimeout(timer);
|
|
286
|
+
resolve(reply);
|
|
287
|
+
};
|
|
288
|
+
this._waiters.push({
|
|
289
|
+
resolve: wrapped,
|
|
290
|
+
reject: (e) => {
|
|
291
|
+
clearTimeout(timer);
|
|
292
|
+
reject(e);
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
close(): void {
|
|
299
|
+
if (this._closed) return;
|
|
300
|
+
this._closed = true;
|
|
301
|
+
try {
|
|
302
|
+
this._socket.end();
|
|
303
|
+
} catch {
|
|
304
|
+
/* already gone */
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private _onData(data: Uint8Array): void {
|
|
309
|
+
this._buffer += new TextDecoder().decode(data);
|
|
310
|
+
this._parse();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Split the buffer into complete replies, leaving any partial tail behind. */
|
|
314
|
+
private _parse(): void {
|
|
315
|
+
for (;;) {
|
|
316
|
+
let cursor = 0;
|
|
317
|
+
let end = -1;
|
|
318
|
+
|
|
319
|
+
for (;;) {
|
|
320
|
+
const nl = this._buffer.indexOf("\n", cursor);
|
|
321
|
+
if (nl === -1) break;
|
|
322
|
+
const line = this._buffer.slice(cursor, nl).replace(/\r$/, "");
|
|
323
|
+
// `250 ` terminates; `250-` continues. A bare code is also terminal.
|
|
324
|
+
if (/^\d{3}(?: |$)/.test(line)) {
|
|
325
|
+
end = nl;
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
cursor = nl + 1;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (end === -1) return;
|
|
332
|
+
|
|
333
|
+
const raw = this._buffer.slice(0, end).replace(/\r$/, "");
|
|
334
|
+
this._buffer = this._buffer.slice(end + 1);
|
|
335
|
+
|
|
336
|
+
const lines = raw.split("\n").map((l) => l.replace(/\r$/, ""));
|
|
337
|
+
const last = lines[lines.length - 1] ?? "";
|
|
338
|
+
const reply: SmtpReply = {
|
|
339
|
+
code: Number.parseInt(last.slice(0, 3), 10),
|
|
340
|
+
lines,
|
|
341
|
+
text: lines
|
|
342
|
+
.map((l) => l.slice(4))
|
|
343
|
+
.join(" ")
|
|
344
|
+
.trim(),
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const waiter = this._waiters.shift();
|
|
348
|
+
if (waiter) waiter.resolve(reply);
|
|
349
|
+
else this._replies.push(reply);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private _onError(error: Error): void {
|
|
354
|
+
this._failure = new SmtpConnectionError(
|
|
355
|
+
`SMTP socket error on ${this._host}:${this._port} — ${error.message}`,
|
|
356
|
+
);
|
|
357
|
+
this._rejectAll(this._failure);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private _onClose(): void {
|
|
361
|
+
this._closed = true;
|
|
362
|
+
if (this._waiters.length > 0) {
|
|
363
|
+
this._rejectAll(
|
|
364
|
+
new SmtpConnectionError(
|
|
365
|
+
`SMTP server ${this._host}:${this._port} closed the connection unexpectedly.`,
|
|
366
|
+
),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private _rejectAll(error: Error): void {
|
|
372
|
+
const waiters = this._waiters;
|
|
373
|
+
this._waiters = [];
|
|
374
|
+
for (const w of waiters) w.reject(error);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Strip CR and LF from a header value.
|
|
380
|
+
*
|
|
381
|
+
* A subject or display name is attacker-influenced often enough to matter: left
|
|
382
|
+
* raw, an embedded CRLF ends the header and lets the rest of the string be read
|
|
383
|
+
* as new headers (a `Bcc:` of the sender's choosing, or a second body).
|
|
384
|
+
*/
|
|
385
|
+
function sanitizeHeader(value: string): string {
|
|
386
|
+
return value.replace(/[\r\n]+/g, " ").trim();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Encode a header value as RFC 2047 base64 when it contains non-ASCII.
|
|
391
|
+
* Headers are 7-bit; a raw UTF-8 subject arrives mangled otherwise.
|
|
392
|
+
*/
|
|
393
|
+
function encodeHeaderValue(value: string): string {
|
|
394
|
+
const clean = sanitizeHeader(value);
|
|
395
|
+
// eslint-disable-next-line no-control-regex -- matching non-ASCII by definition
|
|
396
|
+
if (!/[^\x00-\x7F]/.test(clean)) return clean;
|
|
397
|
+
const base64 = Buffer.from(clean, "utf8").toString("base64");
|
|
398
|
+
return `=?UTF-8?B?${base64}?=`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function formatAddr(a: MailAddress): string {
|
|
402
|
+
const address = sanitizeHeader(a.address);
|
|
403
|
+
return a.name ? `"${encodeHeaderValue(a.name).replace(/"/g, "")}" <${address}>` : address;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Escape a line beginning with a period.
|
|
408
|
+
*
|
|
409
|
+
* A bare `.` on its own line ends the DATA block, so any body line starting with
|
|
410
|
+
* one is doubled on the wire and halved again by the receiver (RFC 5321 §4.5.2).
|
|
411
|
+
* Without this a message containing such a line is silently truncated.
|
|
412
|
+
*/
|
|
413
|
+
function dotStuff(body: string): string {
|
|
414
|
+
return body.replace(/\r?\n\./g, "\r\n..").replace(/^\./, "..");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Normalise bare LF to CRLF — SMTP requires canonical line endings. */
|
|
418
|
+
function toCrlf(text: string): string {
|
|
419
|
+
return text.replace(/\r\n/g, "\n").replace(/\n/g, "\r\n");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function buildRawMessage(msg: MailPayload): string {
|
|
423
|
+
const lines: string[] = [];
|
|
424
|
+
|
|
425
|
+
lines.push(`From: ${formatAddr(msg.from)}`);
|
|
426
|
+
lines.push(`To: ${msg.to.map(formatAddr).join(", ")}`);
|
|
427
|
+
if (msg.cc?.length) lines.push(`Cc: ${msg.cc.map(formatAddr).join(", ")}`);
|
|
428
|
+
if (msg.replyTo) lines.push(`Reply-To: ${formatAddr(msg.replyTo)}`);
|
|
429
|
+
lines.push(`Subject: ${encodeHeaderValue(msg.subject)}`);
|
|
430
|
+
lines.push(`Date: ${new Date().toUTCString()}`);
|
|
431
|
+
lines.push(
|
|
432
|
+
`Message-ID: <${crypto.randomUUID()}@${msg.from.address.split("@")[1] ?? "localhost"}>`,
|
|
433
|
+
);
|
|
434
|
+
lines.push("MIME-Version: 1.0");
|
|
435
|
+
|
|
436
|
+
const attachments = msg.attachments ?? [];
|
|
437
|
+
const body = renderBody(msg);
|
|
438
|
+
|
|
439
|
+
if (attachments.length === 0) {
|
|
440
|
+
lines.push(body.headers);
|
|
441
|
+
lines.push("");
|
|
442
|
+
lines.push(body.content);
|
|
443
|
+
return toCrlf(lines.join("\r\n"));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Attachments wrap the whole body in multipart/mixed, with the text/html
|
|
447
|
+
// alternative (if any) nested as the first part.
|
|
448
|
+
const mixed = `zerotal-mixed-${crypto.randomUUID()}`;
|
|
449
|
+
lines.push(`Content-Type: multipart/mixed; boundary="${mixed}"`);
|
|
450
|
+
lines.push("");
|
|
451
|
+
lines.push(`--${mixed}`);
|
|
452
|
+
lines.push(body.headers);
|
|
453
|
+
lines.push("");
|
|
454
|
+
lines.push(body.content);
|
|
455
|
+
|
|
456
|
+
for (const attachment of attachments) {
|
|
457
|
+
lines.push(`--${mixed}`);
|
|
458
|
+
lines.push(
|
|
459
|
+
`Content-Type: ${sanitizeHeader(attachment.contentType ?? "application/octet-stream")}`,
|
|
460
|
+
);
|
|
461
|
+
lines.push("Content-Transfer-Encoding: base64");
|
|
462
|
+
const disposition = attachment.inline ? "inline" : "attachment";
|
|
463
|
+
lines.push(
|
|
464
|
+
`Content-Disposition: ${disposition}; filename="${encodeHeaderValue(attachment.filename).replace(/"/g, "")}"`,
|
|
465
|
+
);
|
|
466
|
+
if (attachment.cid) lines.push(`Content-ID: <${sanitizeHeader(attachment.cid)}>`);
|
|
467
|
+
lines.push("");
|
|
468
|
+
lines.push(chunk76(toBase64(attachment.content)));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
lines.push(`--${mixed}--`);
|
|
472
|
+
return toCrlf(lines.join("\r\n"));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** The body part: either a single type, or a multipart/alternative pair. */
|
|
476
|
+
function renderBody(msg: MailPayload): { headers: string; content: string } {
|
|
477
|
+
if (msg.html && msg.text) {
|
|
478
|
+
const alt = `zerotal-alt-${crypto.randomUUID()}`;
|
|
479
|
+
const content = [
|
|
480
|
+
`--${alt}`,
|
|
481
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
482
|
+
"",
|
|
483
|
+
dotStuff(msg.text),
|
|
484
|
+
`--${alt}`,
|
|
485
|
+
"Content-Type: text/html; charset=utf-8",
|
|
486
|
+
"",
|
|
487
|
+
dotStuff(msg.html),
|
|
488
|
+
`--${alt}--`,
|
|
489
|
+
].join("\r\n");
|
|
490
|
+
return { headers: `Content-Type: multipart/alternative; boundary="${alt}"`, content };
|
|
491
|
+
}
|
|
492
|
+
if (msg.html) {
|
|
493
|
+
return { headers: "Content-Type: text/html; charset=utf-8", content: dotStuff(msg.html) };
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
headers: "Content-Type: text/plain; charset=utf-8",
|
|
497
|
+
content: dotStuff(msg.text ?? ""),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function toBase64(content: string | Uint8Array): string {
|
|
502
|
+
return typeof content === "string"
|
|
503
|
+
? Buffer.from(content, "utf8").toString("base64")
|
|
504
|
+
: Buffer.from(content).toString("base64");
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Base64 in a MIME part is wrapped at 76 characters. */
|
|
508
|
+
function chunk76(base64: string): string {
|
|
509
|
+
return (base64.match(/.{1,76}/g) ?? []).join("\r\n");
|
|
510
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { ZerotalError } from "@zerotal/core";
|
|
2
|
+
|
|
3
|
+
/** Base class for all @zerotal/notifications errors. */
|
|
4
|
+
export class NotificationError extends ZerotalError {
|
|
5
|
+
constructor(
|
|
6
|
+
message: string,
|
|
7
|
+
code = "E_NOTIFICATION",
|
|
8
|
+
status = 500,
|
|
9
|
+
context?: Record<string, unknown>,
|
|
10
|
+
) {
|
|
11
|
+
super(message, code, status, context);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Thrown when a notification is routed to a channel the manager does not know. */
|
|
16
|
+
export class UnknownNotificationChannelError extends NotificationError {
|
|
17
|
+
constructor(channel: string, known: string[] = []) {
|
|
18
|
+
super(
|
|
19
|
+
`Unknown notification channel: '${channel}'.` +
|
|
20
|
+
(known.length > 0
|
|
21
|
+
? ` Registered: ${known.join(", ")}. Add your own with notifications.extend("${channel}", () => …).`
|
|
22
|
+
: ""),
|
|
23
|
+
"E_NOTIFICATION_UNKNOWN_CHANNEL",
|
|
24
|
+
500,
|
|
25
|
+
{ channel, known },
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Thrown when a channel is used but its config block is missing. */
|
|
31
|
+
export class NotificationChannelNotConfiguredError extends NotificationError {
|
|
32
|
+
constructor(channel: string, hint = "") {
|
|
33
|
+
super(
|
|
34
|
+
`[Zerotal] Notifications ${channel} channel is not configured.${hint ? " " + hint : ""}`,
|
|
35
|
+
"E_NOTIFICATION_CHANNEL_NOT_CONFIGURED",
|
|
36
|
+
500,
|
|
37
|
+
{ channel },
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Thrown when a channel's delivery to its provider fails (non-2xx response or provider error). */
|
|
43
|
+
export class NotificationDeliveryError extends NotificationError {
|
|
44
|
+
constructor(message: string, context?: Record<string, unknown>) {
|
|
45
|
+
super(message, "E_NOTIFICATION_DELIVERY", 502, context);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Thrown when a channel cannot run because an optional peer package or required
|
|
51
|
+
* config is missing (e.g. the broadcast channel without `@zerotal/broadcasting`,
|
|
52
|
+
* or the SMS driver's credential block).
|
|
53
|
+
*/
|
|
54
|
+
export class NotificationChannelUnavailableError extends NotificationError {
|
|
55
|
+
constructor(message: string) {
|
|
56
|
+
super(message, "E_NOTIFICATION_CHANNEL_UNAVAILABLE", 500);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Thrown when a notification routes to a channel whose representation method
|
|
62
|
+
* (e.g. `toMail()`) hasn't been implemented on the notification class.
|
|
63
|
+
*/
|
|
64
|
+
export class NotificationContractError extends NotificationError {
|
|
65
|
+
constructor(notification: string, method: string, channel: string) {
|
|
66
|
+
super(
|
|
67
|
+
`${notification} must implement ${method}() to use the '${channel}' channel.`,
|
|
68
|
+
"E_NOTIFICATION_CONTRACT",
|
|
69
|
+
500,
|
|
70
|
+
{ notification, method, channel },
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Thrown when a queued notification names a class the worker cannot find — the
|
|
77
|
+
* notification lives outside `app/notifications/` and was never registered.
|
|
78
|
+
*/
|
|
79
|
+
export class UnknownNotificationTypeError extends NotificationError {
|
|
80
|
+
constructor(type: string, known: string[]) {
|
|
81
|
+
super(
|
|
82
|
+
`Cannot rebuild queued notification '${type}' — no class of that name is registered. ` +
|
|
83
|
+
`Notifications under app/notifications/ are found automatically; for one elsewhere, call ` +
|
|
84
|
+
`NotificationRegistry.register(${type}) where it is defined.` +
|
|
85
|
+
(known.length > 0 ? ` Known: ${known.join(", ")}.` : ""),
|
|
86
|
+
"E_NOTIFICATION_UNKNOWN_TYPE",
|
|
87
|
+
500,
|
|
88
|
+
{ type, known },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Thrown when a notification finished dispatching but one or more channels
|
|
95
|
+
* failed. Every channel is attempted regardless — a broken Slack webhook must
|
|
96
|
+
* not cost the recipient their email — so this reports the failures together
|
|
97
|
+
* once the rest have been delivered.
|
|
98
|
+
*/
|
|
99
|
+
export class NotificationDispatchError extends NotificationError {
|
|
100
|
+
constructor(
|
|
101
|
+
readonly notification: string,
|
|
102
|
+
readonly failures: Array<{ channel: string; error: Error }>,
|
|
103
|
+
readonly delivered: string[],
|
|
104
|
+
) {
|
|
105
|
+
super(
|
|
106
|
+
`${notification} failed on ${failures.length} of ${failures.length + delivered.length} channel(s): ` +
|
|
107
|
+
failures.map((f) => `${f.channel} (${f.error.message})`).join("; ") +
|
|
108
|
+
(delivered.length > 0 ? `. Delivered on: ${delivered.join(", ")}.` : "."),
|
|
109
|
+
"E_NOTIFICATION_DISPATCH",
|
|
110
|
+
500,
|
|
111
|
+
{
|
|
112
|
+
notification,
|
|
113
|
+
failures: failures.map((f) => ({ channel: f.channel, error: f.error.message })),
|
|
114
|
+
delivered,
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Thrown when an SMTP server answers a command with an unexpected status code.
|
|
122
|
+
* Carries the server's own text, which is usually the most useful part.
|
|
123
|
+
*/
|
|
124
|
+
export class SmtpResponseError extends NotificationError {
|
|
125
|
+
constructor(
|
|
126
|
+
readonly replyCode: number,
|
|
127
|
+
readonly replyText: string,
|
|
128
|
+
expected: number[],
|
|
129
|
+
) {
|
|
130
|
+
super(
|
|
131
|
+
`SMTP server replied ${replyCode} (${replyText || "no message"}); expected ${expected.join(" or ")}.`,
|
|
132
|
+
"E_NOTIFICATION_SMTP_RESPONSE",
|
|
133
|
+
502,
|
|
134
|
+
{ replyCode, replyText, expected },
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Thrown when the SMTP transport itself fails — connect, TLS, timeout, or early close. */
|
|
140
|
+
export class SmtpConnectionError extends NotificationError {
|
|
141
|
+
constructor(message: string) {
|
|
142
|
+
super(`[Zerotal/notifications] ${message}`, "E_NOTIFICATION_SMTP_CONNECTION", 502);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Thrown when `config/notifications.ts` is internally inconsistent. */
|
|
147
|
+
export class NotificationConfigError extends NotificationError {
|
|
148
|
+
constructor(message: string, context?: Record<string, unknown>) {
|
|
149
|
+
super(`[Zerotal/notifications] ${message}`, "E_NOTIFICATION_CONFIG", 500, context);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Thrown when the configured SMS driver name is not recognised. */
|
|
154
|
+
export class UnknownSmsDriverError extends NotificationError {
|
|
155
|
+
constructor(driver: string) {
|
|
156
|
+
super(
|
|
157
|
+
`[Zerotal/notifications] Unknown SMS driver: '${driver}'`,
|
|
158
|
+
"E_NOTIFICATION_UNKNOWN_SMS_DRIVER",
|
|
159
|
+
500,
|
|
160
|
+
{ driver },
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|