@crewhaus/tool-notify 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,618 @@
1
+ /**
2
+ * Building an RFC 5322 message, correctly, without a mail library.
3
+ *
4
+ * Four things go wrong when a program assembles an email by string
5
+ * concatenation, and each of them is handled here rather than hoped about:
6
+ *
7
+ * 1. **Header injection.** A subject or display name carrying a newline
8
+ * splits the header block and lets the caller add `Bcc:` — or a whole
9
+ * second message. Every header value that is not plain printable ASCII
10
+ * is RFC 2047 encoded, which turns a CR or LF into `=0D`/`=0A` inside
11
+ * an encoded word, and addresses containing either are refused
12
+ * outright. The values that CANNOT be encoded because they are emitted
13
+ * verbatim — a message id in `In-Reply-To` or `References`, a
14
+ * `Content-ID` — are validated against their grammar instead, and the
15
+ * finished header lines are re-checked for a break that is not a fold
16
+ * before the message is returned. There is no path by which caller text
17
+ * becomes a header.
18
+ * 2. **Non-ASCII.** A subject in German, a display name in Japanese, a
19
+ * body with an em dash: headers get encoded words, bodies get UTF-8
20
+ * with quoted-printable, and the charset is declared rather than
21
+ * assumed.
22
+ * 3. **Line length.** SMTP allows 998 octets per line and a lot of relays
23
+ * are stricter. Bodies are quoted-printable with soft breaks at 76,
24
+ * headers are folded at 78, and base64 is wrapped at 76.
25
+ * 4. **Determinism.** MIME boundaries are usually random. Here they are
26
+ * derived from a hash of the message's own parts, so composing the same
27
+ * message twice produces the same bytes — which is what makes the
28
+ * output testable, diffable, and safe to use as an idempotency input.
29
+ *
30
+ * Nothing here reads the clock: the `Date` header is an argument. Nothing
31
+ * here opens a socket: this builds bytes, and `smtp.ts` is what sends them.
32
+ */
33
+ import { Buffer } from "node:buffer";
34
+ import { createHash } from "node:crypto";
35
+ export const CRLF = "\r\n";
36
+ /** Longest header line before folding, per RFC 5322 §2.1.1 (78 recommended). */
37
+ const HEADER_WIDTH = 78;
38
+ /** Longest quoted-printable line, including the trailing soft-break `=`. */
39
+ const QP_WIDTH = 76;
40
+ /** Longest base64 line. */
41
+ const B64_WIDTH = 76;
42
+ /** Longest line SMTP carries, excluding the CRLF (RFC 5321 §4.5.3.1.6). */
43
+ const MAX_LINE_OCTETS = 998;
44
+ const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
45
+ const MONTHS = [
46
+ "Jan",
47
+ "Feb",
48
+ "Mar",
49
+ "Apr",
50
+ "May",
51
+ "Jun",
52
+ "Jul",
53
+ "Aug",
54
+ "Sep",
55
+ "Oct",
56
+ "Nov",
57
+ "Dec",
58
+ ];
59
+ // ---------------------------------------------------------------------------
60
+ // addresses
61
+ // ---------------------------------------------------------------------------
62
+ /**
63
+ * A deliberately conservative addr-spec.
64
+ *
65
+ * It accepts dot-atom local parts and dotted domain labels, which is what
66
+ * every deliverable address in practice looks like, and refuses quoted local
67
+ * parts, address literals and anything containing whitespace, a comma, angle
68
+ * brackets or a control character. Refusing a legal-but-exotic address is a
69
+ * message that does not go out; accepting one that carries a comma or a
70
+ * newline is a message that goes somewhere else.
71
+ */
72
+ const ADDR_SPEC = /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/;
73
+ const MAX_ADDRESS_LENGTH = 254;
74
+ export function isValidAddress(address) {
75
+ if (address.length === 0 || address.length > MAX_ADDRESS_LENGTH)
76
+ return false;
77
+ if (/[\r\n\0]/.test(address))
78
+ return false;
79
+ return ADDR_SPEC.test(address);
80
+ }
81
+ /** The domain half of an address, lowercased. `null` when there is none. */
82
+ export function addressDomain(address) {
83
+ const at = address.lastIndexOf("@");
84
+ if (at <= 0 || at === address.length - 1)
85
+ return null;
86
+ return address.slice(at + 1).toLowerCase();
87
+ }
88
+ // ---------------------------------------------------------------------------
89
+ // header encoding
90
+ // ---------------------------------------------------------------------------
91
+ /** True when the text contains anything a raw header may not carry. */
92
+ function needsEncoding(text) {
93
+ // Everything outside printable ASCII — which includes CR, LF and NUL, the
94
+ // three characters an injection depends on.
95
+ for (let i = 0; i < text.length; i++) {
96
+ const code = text.charCodeAt(i);
97
+ if (code < 0x20 || code > 0x7e)
98
+ return true;
99
+ }
100
+ return false;
101
+ }
102
+ /**
103
+ * RFC 2047 encoded words, base64, UTF-8.
104
+ *
105
+ * An encoded word may be at most 75 characters. `=?UTF-8?B?` + `?=` costs
106
+ * 12, so the base64 payload is capped at 60 characters — 45 source bytes —
107
+ * and the split is made on whole code points, because half of a UTF-8
108
+ * sequence in one word and half in the next decodes to a replacement
109
+ * character on every client.
110
+ */
111
+ export function encodeWords(text) {
112
+ const words = [];
113
+ let chunk = [];
114
+ const flush = () => {
115
+ if (chunk.length === 0)
116
+ return;
117
+ words.push(`=?UTF-8?B?${Buffer.from(Uint8Array.from(chunk)).toString("base64")}?=`);
118
+ chunk = [];
119
+ };
120
+ for (const char of text) {
121
+ const bytes = [...Buffer.from(char, "utf8")];
122
+ if (chunk.length + bytes.length > 45)
123
+ flush();
124
+ chunk.push(...bytes);
125
+ }
126
+ flush();
127
+ return words.join(" ");
128
+ }
129
+ /** A header value safe to emit: encoded when it has to be, raw when it can be. */
130
+ export function encodeHeaderValue(text) {
131
+ return needsEncoding(text) ? encodeWords(text) : text;
132
+ }
133
+ /**
134
+ * Fold an unstructured header (Subject, References) at `HEADER_WIDTH`.
135
+ *
136
+ * A fold is `CRLF` followed by whitespace, and unfolding removes only the
137
+ * `CRLF` — so breaking at an existing run of spaces and re-emitting that
138
+ * exact run reproduces the original value byte for byte. A short header is
139
+ * returned untouched, which is the overwhelmingly common case and the one
140
+ * where a rewrite would be most visible.
141
+ */
142
+ function foldUnstructured(name, value) {
143
+ const single = `${name}: ${value}`;
144
+ if (single.length <= HEADER_WIDTH)
145
+ return single;
146
+ const tokens = value.match(/\S+|\s+/g) ?? [];
147
+ const lines = [];
148
+ let line = `${name}:`;
149
+ let whitespace = " ";
150
+ let hasContent = false;
151
+ for (const token of tokens) {
152
+ if (token.trim() === "") {
153
+ whitespace = token;
154
+ continue;
155
+ }
156
+ const candidate = `${line}${whitespace}${token}`;
157
+ if (candidate.length > HEADER_WIDTH && hasContent) {
158
+ lines.push(line);
159
+ line = `${whitespace}${token}`;
160
+ }
161
+ else {
162
+ line = candidate;
163
+ }
164
+ whitespace = " ";
165
+ hasContent = true;
166
+ }
167
+ lines.push(line);
168
+ return lines.join(CRLF);
169
+ }
170
+ /**
171
+ * Fold an address list, breaking only between whole addresses.
172
+ *
173
+ * The list arrives as already-formatted entries rather than as one joined
174
+ * string, because a display name may legitimately contain `, ` inside a
175
+ * quoted-string — splitting the joined form would cut `"Smith, Bob"
176
+ * <b@x.test>` into two nonexistent addresses.
177
+ */
178
+ function foldList(name, pieces) {
179
+ const lines = [];
180
+ let line = `${name}:`;
181
+ let hasContent = false;
182
+ pieces.forEach((piece, index) => {
183
+ const tail = index === pieces.length - 1 ? "" : ",";
184
+ const candidate = `${line} ${piece}${tail}`;
185
+ if (candidate.length > HEADER_WIDTH && hasContent) {
186
+ lines.push(line);
187
+ line = ` ${piece}${tail}`;
188
+ }
189
+ else {
190
+ line = candidate;
191
+ }
192
+ hasContent = true;
193
+ });
194
+ lines.push(line);
195
+ return lines.join(CRLF);
196
+ }
197
+ /** `Name <addr>`, with the display name quoted or encoded as needed. */
198
+ export function formatMailbox(mailbox) {
199
+ const name = mailbox.name;
200
+ if (name === undefined || name.trim() === "")
201
+ return `<${mailbox.address}>`;
202
+ if (needsEncoding(name))
203
+ return `${encodeWords(name)} <${mailbox.address}>`;
204
+ // A display name containing a special must be a quoted-string; escaping
205
+ // the two characters a quoted-string cannot carry bare is what keeps a
206
+ // name like `Smith, "Bob"` from ending the field early.
207
+ if (/[()<>@,;:\\".\[\]]/.test(name)) {
208
+ return `"${name.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}" <${mailbox.address}>`;
209
+ }
210
+ return `${name} <${mailbox.address}>`;
211
+ }
212
+ /** RFC 5322 date in UTC. Locale-free by construction — no `toLocaleString`. */
213
+ export function formatDate(date) {
214
+ const day = DAYS[date.getUTCDay()];
215
+ const month = MONTHS[date.getUTCMonth()];
216
+ const pad = (n) => String(n).padStart(2, "0");
217
+ return `${day}, ${pad(date.getUTCDate())} ${month} ${date.getUTCFullYear()} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} +0000`;
218
+ }
219
+ /** Header names this builder sets itself and will not let a caller override. */
220
+ const RESERVED_HEADERS = new Set([
221
+ "from",
222
+ "to",
223
+ "cc",
224
+ "bcc",
225
+ "reply-to",
226
+ "subject",
227
+ "date",
228
+ "message-id",
229
+ "in-reply-to",
230
+ "references",
231
+ "mime-version",
232
+ "content-type",
233
+ "content-transfer-encoding",
234
+ "content-disposition",
235
+ "content-id",
236
+ ]);
237
+ const HEADER_NAME = /^[A-Za-z][A-Za-z0-9-]*$/;
238
+ /**
239
+ * The two header questions asked in more than one place.
240
+ *
241
+ * `EmailSendPreflight` reports every bad header name at once, while
242
+ * `composeMessage` refuses at the first. Both have to be asking the SAME
243
+ * question: a preflight that says "ready" against its own copy of the rule,
244
+ * and a compose that then refuses, is a preflight nobody can act on. So the
245
+ * predicate is exported and the composer below is one of its callers rather
246
+ * than a second implementation.
247
+ */
248
+ export function isValidHeaderName(name) {
249
+ return HEADER_NAME.test(name);
250
+ }
251
+ export function isReservedHeaderName(name) {
252
+ return RESERVED_HEADERS.has(name.toLowerCase());
253
+ }
254
+ /**
255
+ * A `msg-id`, per RFC 5322 §3.6.4: angle-bracketed, with no whitespace and
256
+ * no control character anywhere inside it.
257
+ *
258
+ * `In-Reply-To` and `References` carry message ids VERBATIM — an encoded
259
+ * word there would break threading on every client that reads it, so the
260
+ * escape hatch that protects `Subject` is not available. The value is
261
+ * therefore validated instead of encoded, and a value that is not a msg-id
262
+ * is refused rather than trimmed into one: `<a@b>\r\nBcc: …` must not become
263
+ * a header, and silently dropping the tail would hide the attempt.
264
+ */
265
+ const MSG_ID = /^<[^\s<>\r\n\0]+>$/;
266
+ /** True for exactly one bracketed message id, per the grammar above. */
267
+ export function isMessageId(value) {
268
+ return MSG_ID.test(value);
269
+ }
270
+ /**
271
+ * A `Content-ID` token: the same shape, without the brackets this builder
272
+ * adds itself. A CR or LF here would open a header inside a MIME part.
273
+ */
274
+ const CONTENT_ID = /^[^\s<>\r\n\0]+$/;
275
+ /**
276
+ * The backstop: no assembled header line may carry a bare CR or LF.
277
+ *
278
+ * Every value above is validated or encoded at the point it is built, and
279
+ * this re-checks the finished lines anyway. Header injection is the one
280
+ * defect in this file that turns a message into a different message, and a
281
+ * structural check that cannot be reasoned around is worth more than the
282
+ * sum of the individual ones — a header added here later inherits it for
283
+ * free.
284
+ *
285
+ * A fold is legal and is `CRLF` followed by space or tab; anything else is
286
+ * not a fold.
287
+ */
288
+ function headerFault(headerLines) {
289
+ for (const line of headerLines) {
290
+ const name = line.slice(0, Math.max(0, line.indexOf(":")));
291
+ const segments = line.split(CRLF);
292
+ for (let i = 0; i < segments.length; i++) {
293
+ const segment = segments[i];
294
+ if (/[\r\n]/.test(segment) || (i > 0 && !/^[ \t]/.test(segment))) {
295
+ return `the "${name}" header value carried a line break, which would have added headers of its own`;
296
+ }
297
+ // RFC 5321 §4.5.3.1.6: 998 octets plus the CRLF. A longer line is not
298
+ // a message a relay has to accept, and a value with nowhere to fold —
299
+ // one enormous token — cannot be rescued by folding harder.
300
+ if (Buffer.byteLength(segment, "utf8") > MAX_LINE_OCTETS) {
301
+ return `the "${name}" header is longer than the ${MAX_LINE_OCTETS} octets a line may carry and has no whitespace to fold at`;
302
+ }
303
+ }
304
+ }
305
+ return null;
306
+ }
307
+ // ---------------------------------------------------------------------------
308
+ // body encoding
309
+ // ---------------------------------------------------------------------------
310
+ /**
311
+ * Quoted-printable, per RFC 2045 §6.7.
312
+ *
313
+ * The parts people get wrong, handled explicitly: trailing whitespace before
314
+ * a hard break is encoded (a relay that strips it would otherwise change the
315
+ * message), a soft break is never inserted inside an `=XX` triplet, hard
316
+ * breaks stay CRLF rather than being encoded, and a line-leading `.` is
317
+ * encoded so no amount of SMTP dot handling can eat it.
318
+ */
319
+ export function encodeQuotedPrintable(text) {
320
+ const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
321
+ const outLines = [];
322
+ for (const sourceLine of normalized.split("\n")) {
323
+ const bytes = Buffer.from(sourceLine, "utf8");
324
+ // Encode each byte into its own token first; wrapping then works on
325
+ // whole tokens, which is what keeps `=C3` and `=A9` together.
326
+ const tokens = [];
327
+ for (let i = 0; i < bytes.length; i++) {
328
+ const byte = bytes[i];
329
+ const isLast = i === bytes.length - 1;
330
+ const literalSpace = (byte === 0x20 || byte === 0x09) && !isLast;
331
+ const printable = byte >= 0x21 && byte <= 0x7e && byte !== 0x3d;
332
+ if (printable && !(i === 0 && byte === 0x2e)) {
333
+ tokens.push(String.fromCharCode(byte));
334
+ }
335
+ else if (literalSpace) {
336
+ tokens.push(String.fromCharCode(byte));
337
+ }
338
+ else {
339
+ tokens.push(`=${byte.toString(16).toUpperCase().padStart(2, "0")}`);
340
+ }
341
+ }
342
+ let line = "";
343
+ /** Whitespace may not sit immediately before a soft break either. */
344
+ const closeSoft = (text) => {
345
+ const last = text.charCodeAt(text.length - 1);
346
+ if (last === 0x20 || last === 0x09) {
347
+ return `${text.slice(0, -1)}=${last.toString(16).toUpperCase().padStart(2, "0")}=`;
348
+ }
349
+ return `${text}=`;
350
+ };
351
+ for (const token of tokens) {
352
+ // Three characters of headroom: a line ending in whitespace has that
353
+ // whitespace re-encoded as `=XX` before the `=` soft break is added,
354
+ // which is two characters longer than what it replaced.
355
+ if (line.length + token.length > QP_WIDTH - 3) {
356
+ outLines.push(closeSoft(line));
357
+ line = "";
358
+ }
359
+ line += token;
360
+ }
361
+ outLines.push(line);
362
+ }
363
+ return outLines.join(CRLF);
364
+ }
365
+ /** Base64, wrapped at 76 columns. */
366
+ export function encodeBase64Lines(bytes) {
367
+ const encoded = Buffer.from(bytes).toString("base64");
368
+ const lines = [];
369
+ for (let i = 0; i < encoded.length; i += B64_WIDTH) {
370
+ lines.push(encoded.slice(i, i + B64_WIDTH));
371
+ }
372
+ return lines.join(CRLF);
373
+ }
374
+ /**
375
+ * A MIME boundary derived from the content it separates.
376
+ *
377
+ * Random boundaries make the same message compose to different bytes every
378
+ * time, which costs the caller the ability to compare two composes or to use
379
+ * the message as its own idempotency key. Hashing the parts keeps the
380
+ * boundary unique-in-practice while staying reproducible; the `=_` prefix
381
+ * uses characters that cannot appear in base64 or quoted-printable output,
382
+ * so a boundary can never collide with the content it delimits.
383
+ */
384
+ export function boundaryFor(kind, material) {
385
+ const digest = createHash("sha256").update(`${kind}\u0000${material}`, "utf8").digest("hex");
386
+ return `=_crewhaus_${kind}_${digest.slice(0, 32)}`;
387
+ }
388
+ /** Quote a MIME parameter value, encoding it when it is not plain ASCII. */
389
+ function mimeParameter(name, value) {
390
+ if (needsEncoding(value)) {
391
+ // RFC 2231 continuation-free form: one encoded value, charset declared.
392
+ const encoded = [...Buffer.from(value, "utf8")]
393
+ .map((b) => (b >= 0x30 && b <= 0x39) ||
394
+ (b >= 0x41 && b <= 0x5a) ||
395
+ (b >= 0x61 && b <= 0x7a) ||
396
+ b === 0x2d ||
397
+ b === 0x2e ||
398
+ b === 0x5f
399
+ ? String.fromCharCode(b)
400
+ : `%${b.toString(16).toUpperCase().padStart(2, "0")}`)
401
+ .join("");
402
+ return `${name}*=UTF-8''${encoded}`;
403
+ }
404
+ return `${name}="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
405
+ }
406
+ // ---------------------------------------------------------------------------
407
+ // the builder
408
+ // ---------------------------------------------------------------------------
409
+ function mailboxError(label, mailbox) {
410
+ if (!isValidAddress(mailbox.address)) {
411
+ return `${label} address "${mailbox.address.replace(/[\r\n]/g, "\\n")}" is not a deliverable addr-spec (local@domain, no spaces, commas or angle brackets)`;
412
+ }
413
+ return null;
414
+ }
415
+ /** Build the complete message. Pure: same input, same bytes, every time. */
416
+ export function composeMessage(input) {
417
+ const fromError = mailboxError("from", input.from);
418
+ if (fromError !== null)
419
+ return { ok: false, error: fromError };
420
+ const groups = [
421
+ ["to", input.to],
422
+ ["cc", input.cc ?? []],
423
+ ["bcc", input.bcc ?? []],
424
+ ["replyTo", input.replyTo ?? []],
425
+ ];
426
+ for (const [label, list] of groups) {
427
+ for (const mailbox of list) {
428
+ const error = mailboxError(label, mailbox);
429
+ if (error !== null)
430
+ return { ok: false, error };
431
+ }
432
+ }
433
+ if (input.to.length === 0 && (input.cc ?? []).length === 0 && (input.bcc ?? []).length === 0) {
434
+ return { ok: false, error: "a message needs at least one recipient" };
435
+ }
436
+ if (Number.isNaN(input.date.getTime())) {
437
+ return { ok: false, error: "the date is not a valid instant" };
438
+ }
439
+ for (const name of Object.keys(input.headers ?? {})) {
440
+ if (!isValidHeaderName(name)) {
441
+ return { ok: false, error: `"${name}" is not a valid header name` };
442
+ }
443
+ if (isReservedHeaderName(name)) {
444
+ return {
445
+ ok: false,
446
+ error: `header "${name}" is built by this tool and cannot be overridden — set it through the matching argument instead`,
447
+ };
448
+ }
449
+ }
450
+ if (input.inReplyTo !== undefined && input.inReplyTo !== "" && !isMessageId(input.inReplyTo)) {
451
+ return {
452
+ ok: false,
453
+ error: 'inReplyTo must be one message id in angle brackets, e.g. "<abc@example.com>" — a value carrying a space, a line break or a second id is refused, because it would become a header of its own',
454
+ };
455
+ }
456
+ for (const reference of input.references ?? []) {
457
+ if (!isMessageId(reference)) {
458
+ return {
459
+ ok: false,
460
+ error: 'each entry in references must be one message id in angle brackets, e.g. "<abc@example.com>" — a value carrying a space or a line break is refused, because it would become a header of its own',
461
+ };
462
+ }
463
+ }
464
+ for (const attachment of input.attachments ?? []) {
465
+ if (attachment.filename.includes("\r") || attachment.filename.includes("\n")) {
466
+ return { ok: false, error: "an attachment filename may not contain a line break" };
467
+ }
468
+ if (attachment.contentId !== undefined && !CONTENT_ID.test(attachment.contentId)) {
469
+ return {
470
+ ok: false,
471
+ error: 'a contentId must be a plain token with no whitespace, angle brackets or line break, e.g. "logo@crewhaus" — this tool adds the brackets',
472
+ };
473
+ }
474
+ if (!/^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/.test(attachment.contentType)) {
475
+ return {
476
+ ok: false,
477
+ error: `"${attachment.contentType}" is not a media type (type/subtype)`,
478
+ };
479
+ }
480
+ }
481
+ const attachments = input.attachments ?? [];
482
+ const hasHtml = input.html !== undefined && input.html !== "";
483
+ const material = JSON.stringify([
484
+ input.from.address,
485
+ input.to.map((m) => m.address),
486
+ input.subject,
487
+ input.text,
488
+ input.html ?? "",
489
+ attachments.map((a) => [a.filename, a.contentType, a.content.byteLength]),
490
+ input.date.toISOString(),
491
+ ]);
492
+ // -- body ---------------------------------------------------------------
493
+ const textPart = [
494
+ 'Content-Type: text/plain; charset="utf-8"',
495
+ "Content-Transfer-Encoding: quoted-printable",
496
+ "",
497
+ encodeQuotedPrintable(input.text),
498
+ ].join(CRLF);
499
+ let content;
500
+ let contentTypeHeader;
501
+ if (hasHtml) {
502
+ const altBoundary = boundaryFor("alt", material);
503
+ const htmlPart = [
504
+ 'Content-Type: text/html; charset="utf-8"',
505
+ "Content-Transfer-Encoding: quoted-printable",
506
+ "",
507
+ encodeQuotedPrintable(input.html),
508
+ ].join(CRLF);
509
+ // Least-faithful first: a client that shows only one shows the last it
510
+ // understands, which is the rule multipart/alternative is built on.
511
+ content = [
512
+ `--${altBoundary}`,
513
+ textPart,
514
+ `--${altBoundary}`,
515
+ htmlPart,
516
+ `--${altBoundary}--`,
517
+ "",
518
+ ].join(CRLF);
519
+ contentTypeHeader = `multipart/alternative; boundary="${altBoundary}"`;
520
+ }
521
+ else {
522
+ content = encodeQuotedPrintable(input.text);
523
+ contentTypeHeader = 'text/plain; charset="utf-8"';
524
+ }
525
+ let transferEncoding = hasHtml ? undefined : "quoted-printable";
526
+ if (attachments.length > 0) {
527
+ const mixedBoundary = boundaryFor("mixed", material);
528
+ const bodyPart = hasHtml
529
+ ? [`Content-Type: ${contentTypeHeader}`, "", content].join(CRLF)
530
+ : [
531
+ 'Content-Type: text/plain; charset="utf-8"',
532
+ "Content-Transfer-Encoding: quoted-printable",
533
+ "",
534
+ content,
535
+ ].join(CRLF);
536
+ const parts = [`--${mixedBoundary}`, bodyPart];
537
+ for (const attachment of attachments) {
538
+ const disposition = attachment.disposition ?? "attachment";
539
+ const headers = [
540
+ `Content-Type: ${attachment.contentType}; ${mimeParameter("name", attachment.filename)}`,
541
+ "Content-Transfer-Encoding: base64",
542
+ `Content-Disposition: ${disposition}; ${mimeParameter("filename", attachment.filename)}`,
543
+ ...(attachment.contentId !== undefined ? [`Content-ID: <${attachment.contentId}>`] : []),
544
+ ];
545
+ parts.push(`--${mixedBoundary}`);
546
+ parts.push([...headers, "", encodeBase64Lines(attachment.content)].join(CRLF));
547
+ }
548
+ parts.push(`--${mixedBoundary}--`);
549
+ parts.push("");
550
+ content = parts.join(CRLF);
551
+ contentTypeHeader = `multipart/mixed; boundary="${mixedBoundary}"`;
552
+ transferEncoding = undefined;
553
+ }
554
+ // -- headers ------------------------------------------------------------
555
+ const domain = input.messageIdDomain ?? addressDomain(input.from.address) ?? "localhost";
556
+ const local = input.messageIdLocal ??
557
+ createHash("sha256").update(material, "utf8").digest("hex").slice(0, 40);
558
+ const messageId = `<${local}@${domain}>`;
559
+ const headerLines = [
560
+ "MIME-Version: 1.0",
561
+ `Date: ${formatDate(input.date)}`,
562
+ foldList("From", [formatMailbox(input.from)]),
563
+ ];
564
+ if (input.to.length > 0) {
565
+ headerLines.push(foldList("To", input.to.map(formatMailbox)));
566
+ }
567
+ if ((input.cc ?? []).length > 0) {
568
+ headerLines.push(foldList("Cc", input.cc.map(formatMailbox)));
569
+ }
570
+ if ((input.replyTo ?? []).length > 0) {
571
+ headerLines.push(foldList("Reply-To", input.replyTo.map(formatMailbox)));
572
+ }
573
+ headerLines.push(foldUnstructured("Subject", encodeHeaderValue(input.subject)));
574
+ headerLines.push(`Message-ID: ${messageId}`);
575
+ if (input.inReplyTo !== undefined && input.inReplyTo !== "") {
576
+ headerLines.push(`In-Reply-To: ${input.inReplyTo}`);
577
+ }
578
+ if ((input.references ?? []).length > 0) {
579
+ headerLines.push(foldUnstructured("References", input.references.join(" ")));
580
+ }
581
+ for (const name of Object.keys(input.headers ?? {}).sort()) {
582
+ const value = input.headers[name];
583
+ headerLines.push(foldUnstructured(name, encodeHeaderValue(value)));
584
+ }
585
+ headerLines.push(`Content-Type: ${contentTypeHeader}`);
586
+ if (transferEncoding !== undefined) {
587
+ headerLines.push(`Content-Transfer-Encoding: ${transferEncoding}`);
588
+ }
589
+ const fault = headerFault(headerLines);
590
+ if (fault !== null)
591
+ return { ok: false, error: fault };
592
+ const message = `${headerLines.join(CRLF)}${CRLF}${CRLF}${content}`;
593
+ const envelope = new Set();
594
+ for (const mailbox of [...input.to, ...(input.cc ?? []), ...(input.bcc ?? [])]) {
595
+ envelope.add(mailbox.address);
596
+ }
597
+ return {
598
+ ok: true,
599
+ message,
600
+ messageId,
601
+ bytes: Buffer.byteLength(message, "utf8"),
602
+ envelopeTo: [...envelope].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
603
+ };
604
+ }
605
+ /**
606
+ * Dot-stuff a message for SMTP DATA, per RFC 5321 §4.5.2, and terminate it.
607
+ *
608
+ * A line that begins with `.` would otherwise end the DATA phase early and
609
+ * leave the remainder of the message being parsed as commands.
610
+ */
611
+ export function dotStuff(message) {
612
+ const normalized = message.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
613
+ const stuffed = normalized
614
+ .split("\n")
615
+ .map((line) => (line.startsWith(".") ? `.${line}` : line))
616
+ .join(CRLF);
617
+ return `${stuffed}${CRLF}.${CRLF}`;
618
+ }
@@ -0,0 +1,71 @@
1
+ import type { Mailbox } from "./mime";
2
+ /**
3
+ * `pass` - asked and answered well. `warn` - answered, and the answer is
4
+ * worth reading before sending anyway. `fail` - this message does not go
5
+ * out, or goes out wrong. `unknown` - the question could not be answered,
6
+ * and the reason is in `detail`.
7
+ */
8
+ export type CheckStatus = "pass" | "warn" | "fail" | "unknown";
9
+ export type Check = {
10
+ readonly check: string;
11
+ readonly status: CheckStatus;
12
+ readonly detail: string;
13
+ };
14
+ export type Draft = {
15
+ readonly from: Mailbox;
16
+ readonly to: readonly Mailbox[];
17
+ readonly cc: readonly Mailbox[];
18
+ readonly bcc: readonly Mailbox[];
19
+ readonly replyTo: readonly Mailbox[];
20
+ readonly subject: string;
21
+ readonly text: string;
22
+ readonly html?: string | undefined;
23
+ readonly date: string;
24
+ readonly inReplyTo?: string | undefined;
25
+ readonly references?: readonly string[] | undefined;
26
+ readonly headers?: Readonly<Record<string, string>> | undefined;
27
+ };
28
+ export type EnvelopeFolding = {
29
+ /**
30
+ * Mailboxes listed more than once with the SAME spelling. `composeMessage`
31
+ * builds its envelope as a Set of exact addresses, so these really do
32
+ * collapse to one `RCPT TO`.
33
+ */
34
+ readonly folded: readonly string[];
35
+ /**
36
+ * Groups of spellings that name one mailbox by this file's key but are
37
+ * different strings. The composer's Set keeps every one of them, so each
38
+ * becomes its own `RCPT TO`.
39
+ */
40
+ readonly variants: readonly (readonly string[])[];
41
+ /**
42
+ * How many `RCPT TO` the composer will actually issue - the size of a Set
43
+ * of the EXACT addresses, which is the line `composeMessage` runs.
44
+ */
45
+ readonly envelopeSize: number;
46
+ };
47
+ /**
48
+ * How the envelope folds, counted the way the composer counts it.
49
+ *
50
+ * The trap this exists to avoid: comparing on a case-folded key and then
51
+ * reporting a conclusion about a composer that de-duplicates on the exact
52
+ * string. `ops@example.com` on To and `OPS@example.com` on Bcc is ONE
53
+ * mailbox by the key above and TWO entries in `composeMessage`'s envelope
54
+ * Set, so "it will be sent one copy, not two" is false for exactly that
55
+ * case - which is the case a caller runs a preflight to find. Both facts are
56
+ * returned so the caller is told which one applies, and the count reported
57
+ * is the composer's, never the key's.
58
+ */
59
+ export declare function envelopeFolding(lists: ReadonlyArray<readonly Mailbox[]>): EnvelopeFolding;
60
+ /** `{{placeholder}}` names still standing in the text a recipient would read. */
61
+ export declare function leftoverPlaceholders(draft: Draft): string[];
62
+ /** Every rule that needs nothing but the draft itself, in a fixed order. */
63
+ export declare function draftChecks(draft: Draft): Check[];
64
+ /**
65
+ * The one-word answer, from the rows.
66
+ *
67
+ * `incomplete` exists so a check that could not run cannot be mistaken for
68
+ * one that passed: a message whose attachment could not be read is not ready
69
+ * to send, and it is not blocked either - somebody has to look.
70
+ */
71
+ export declare function verdictFrom(checks: readonly Check[]): "blocked" | "incomplete" | "ready";