@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.
- package/README.md +271 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +2167 -0
- package/dist/lib/blocks.d.ts +113 -0
- package/dist/lib/blocks.js +217 -0
- package/dist/lib/digest.d.ts +56 -0
- package/dist/lib/digest.js +140 -0
- package/dist/lib/dns-records.d.ts +179 -0
- package/dist/lib/dns-records.js +431 -0
- package/dist/lib/mime.d.ts +125 -0
- package/dist/lib/mime.js +618 -0
- package/dist/lib/preflight.d.ts +71 -0
- package/dist/lib/preflight.js +291 -0
- package/dist/lib/quiet.d.ts +59 -0
- package/dist/lib/quiet.js +217 -0
- package/dist/lib/ratelimit.d.ts +57 -0
- package/dist/lib/ratelimit.js +87 -0
- package/dist/lib/sign.d.ts +8 -0
- package/dist/lib/sign.js +39 -0
- package/dist/lib/template.d.ts +41 -0
- package/dist/lib/template.js +85 -0
- package/dist/net.d.ts +391 -0
- package/dist/net.js +1131 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/dist/smtp.d.ts +56 -0
- package/dist/smtp.js +479 -0
- package/package.json +45 -0
package/dist/lib/mime.js
ADDED
|
@@ -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";
|