@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/smtp.js
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An SMTP client, written out rather than pulled in.
|
|
3
|
+
*
|
|
4
|
+
* SMTP is a line protocol with about eight verbs, and the parts that are
|
|
5
|
+
* easy to get wrong are the parts a dependency would hide: whether the
|
|
6
|
+
* session actually reached TLS before the password went across, whether a
|
|
7
|
+
* multi-line reply was read to its end, whether a line beginning with `.`
|
|
8
|
+
* ended the message early, and whether anything at all bounds how long the
|
|
9
|
+
* conversation may run or how much a hostile server may make the client
|
|
10
|
+
* buffer. Each of those is handled here, explicitly:
|
|
11
|
+
*
|
|
12
|
+
* - **TLS is required by default.** `requireTls` starts true. A server
|
|
13
|
+
* that does not advertise STARTTLS, or that fails the upgrade, ends the
|
|
14
|
+
* session BEFORE any credential is sent. Turning it off is an explicit
|
|
15
|
+
* argument, and the result says the session was in the clear.
|
|
16
|
+
* - **Credentials never appear anywhere.** They arrive already resolved
|
|
17
|
+
* from an environment variable name, are written once, and the
|
|
18
|
+
* transcript records `AUTH PLAIN <redacted>` — the base64 blob is never
|
|
19
|
+
* kept, logged or returned.
|
|
20
|
+
* - **Every phase is deadline-bounded.** One `AbortSignal` covers connect,
|
|
21
|
+
* handshake, every read and the whole DATA transfer; when it fires the
|
|
22
|
+
* socket is destroyed rather than left to the OS.
|
|
23
|
+
* - **Reads are capped.** A reply that grows past the cap ends the session.
|
|
24
|
+
* The cap bounds what is ever held in memory, not what is kept after
|
|
25
|
+
* buffering, so a server that streams megabytes at the greeting cannot
|
|
26
|
+
* make this process grow.
|
|
27
|
+
* - **The message is dot-stuffed** before DATA, by `mime.ts`.
|
|
28
|
+
*
|
|
29
|
+
* What it does NOT do: no connection pooling, no pipelining, no DSN, no
|
|
30
|
+
* CRAM-MD5 or XOAUTH2, no retry. One message, one session, one answer.
|
|
31
|
+
*/
|
|
32
|
+
import { Buffer } from "node:buffer";
|
|
33
|
+
import { connect as netConnect } from "node:net";
|
|
34
|
+
import { connect as tlsConnect } from "node:tls";
|
|
35
|
+
import { dotStuff } from "./lib/mime";
|
|
36
|
+
import { expandIpv6, normalizeIpv4 } from "./net";
|
|
37
|
+
/**
|
|
38
|
+
* The SNI name to present, or `undefined` when the host is an IP literal.
|
|
39
|
+
*
|
|
40
|
+
* RFC 6066 §3 forbids a literal address in `server_name`, and both Node and
|
|
41
|
+
* Bun refuse to set one — so a harness pointed at an SMTP server by address
|
|
42
|
+
* would otherwise fail during the handshake with an error about the option
|
|
43
|
+
* rather than about the connection. Certificate verification still runs; it
|
|
44
|
+
* simply matches on the address instead of on a name.
|
|
45
|
+
*/
|
|
46
|
+
function sniFor(host) {
|
|
47
|
+
const bare = host.replace(/^\[/, "").replace(/\]$/, "");
|
|
48
|
+
if (normalizeIpv4(bare) !== null)
|
|
49
|
+
return undefined;
|
|
50
|
+
if (bare.includes(":") && expandIpv6(bare) !== null)
|
|
51
|
+
return undefined;
|
|
52
|
+
return host;
|
|
53
|
+
}
|
|
54
|
+
class SmtpError extends Error {
|
|
55
|
+
name = "SmtpError";
|
|
56
|
+
}
|
|
57
|
+
/** Longest single line this client will accept, per RFC 5321 §4.5.3.1.6. */
|
|
58
|
+
const MAX_LINE = 1000;
|
|
59
|
+
/**
|
|
60
|
+
* The EHLO argument, per RFC 5321 §4.1.1.1: a domain, or an address literal
|
|
61
|
+
* in square brackets. Nothing else, and in particular nothing carrying a CR,
|
|
62
|
+
* an LF or a space.
|
|
63
|
+
*
|
|
64
|
+
* SMTP is a line protocol, so a CRLF inside ANY command argument does not
|
|
65
|
+
* produce a malformed command — it produces a SECOND command, which the
|
|
66
|
+
* server executes. A `RCPT TO:` smuggled in behind an EHLO name would add a
|
|
67
|
+
* recipient that `allowed_recipients` never saw, which is the whole gate
|
|
68
|
+
* gone. Every caller-supplied value that reaches a command line is therefore
|
|
69
|
+
* checked here, at the last place before the socket, rather than only at the
|
|
70
|
+
* schema that happens to be in front of it today.
|
|
71
|
+
*/
|
|
72
|
+
const EHLO_NAME = /^(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*|\[[0-9A-Fa-f:.]{2,45}\])$/;
|
|
73
|
+
/** An envelope path: no whitespace, no angle brackets, no control character. */
|
|
74
|
+
const ENVELOPE_ADDRESS = /^[^\s<>\r\n\0,;]+$/;
|
|
75
|
+
/** The reason an argument may not go on a command line, or `null`. */
|
|
76
|
+
function commandArgumentError(options) {
|
|
77
|
+
if (options.ehloName.length > 255 || !EHLO_NAME.test(options.ehloName)) {
|
|
78
|
+
return 'the EHLO name must be a domain or a bracketed address literal, e.g. "crewhaus.invalid" — a value carrying a space or a line break is refused, because SMTP would read the remainder as a command of its own';
|
|
79
|
+
}
|
|
80
|
+
for (const address of [options.envelopeFrom, ...options.envelopeTo]) {
|
|
81
|
+
if (address.length > 254 || !ENVELOPE_ADDRESS.test(address)) {
|
|
82
|
+
return `"${address.replace(/[\r\n]/g, "\\n").slice(0, 80)}" is not an envelope address this client will put on a command line`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const credential of [options.username, options.password]) {
|
|
86
|
+
if (credential !== undefined && /[\0\r\n]/.test(credential)) {
|
|
87
|
+
// The SASL blob is base64, so this cannot inject a line — but a NUL in
|
|
88
|
+
// a credential silently re-splits the PLAIN message into different
|
|
89
|
+
// fields, which fails in a way nobody can read off the wire.
|
|
90
|
+
return "an SMTP credential may not contain a NUL or a line break";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* One SMTP session over one socket, with a reply parser that understands
|
|
97
|
+
* multi-line replies and a buffer that cannot grow past a cap.
|
|
98
|
+
*/
|
|
99
|
+
class Session {
|
|
100
|
+
socket;
|
|
101
|
+
maxReplyBytes;
|
|
102
|
+
signal;
|
|
103
|
+
buffer = "";
|
|
104
|
+
closed = false;
|
|
105
|
+
failure = null;
|
|
106
|
+
waiter = null;
|
|
107
|
+
transcript = [];
|
|
108
|
+
constructor(socket, maxReplyBytes, signal) {
|
|
109
|
+
this.socket = socket;
|
|
110
|
+
this.maxReplyBytes = maxReplyBytes;
|
|
111
|
+
this.signal = signal;
|
|
112
|
+
this.attach(socket);
|
|
113
|
+
}
|
|
114
|
+
attach(socket) {
|
|
115
|
+
socket.setEncoding("utf8");
|
|
116
|
+
socket.on("data", (chunk) => {
|
|
117
|
+
this.buffer += chunk;
|
|
118
|
+
if (Buffer.byteLength(this.buffer, "utf8") > this.maxReplyBytes) {
|
|
119
|
+
// Cut the session rather than the string: a server that keeps
|
|
120
|
+
// talking past the cap is not one to keep reading from.
|
|
121
|
+
this.fail(new SmtpError(`the server sent more than ${this.maxReplyBytes} bytes in a single reply — session abandoned`));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this.waiter?.resolve();
|
|
125
|
+
});
|
|
126
|
+
socket.on("error", (err) => this.fail(err));
|
|
127
|
+
socket.on("close", () => {
|
|
128
|
+
this.closed = true;
|
|
129
|
+
this.waiter?.resolve();
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/** Replace the underlying socket after a STARTTLS upgrade. */
|
|
133
|
+
upgrade(socket) {
|
|
134
|
+
this.socket.removeAllListeners("data");
|
|
135
|
+
this.socket.removeAllListeners("error");
|
|
136
|
+
this.socket.removeAllListeners("close");
|
|
137
|
+
this.socket = socket;
|
|
138
|
+
this.buffer = "";
|
|
139
|
+
this.closed = false;
|
|
140
|
+
this.attach(socket);
|
|
141
|
+
}
|
|
142
|
+
fail(err) {
|
|
143
|
+
if (this.failure === null)
|
|
144
|
+
this.failure = err;
|
|
145
|
+
this.waiter?.reject(err);
|
|
146
|
+
}
|
|
147
|
+
destroy() {
|
|
148
|
+
try {
|
|
149
|
+
this.socket.destroy();
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// already gone
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Write one command or the body, under the session's deadline.
|
|
157
|
+
*
|
|
158
|
+
* A socket whose peer stops reading applies backpressure and the write
|
|
159
|
+
* callback simply never fires. Without the abort listener the deadline
|
|
160
|
+
* would elapse, nothing would notice, and the tool would hang past its own
|
|
161
|
+
* timeout — so the abort destroys the socket and the write rejects.
|
|
162
|
+
*/
|
|
163
|
+
write(text, logged) {
|
|
164
|
+
this.transcript.push(`C: ${logged}`);
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
if (this.signal.aborted) {
|
|
167
|
+
reject(new SmtpError("deadline elapsed before the client could send"));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const onAbort = () => {
|
|
171
|
+
this.destroy();
|
|
172
|
+
reject(new SmtpError("deadline elapsed while sending to the server"));
|
|
173
|
+
};
|
|
174
|
+
this.signal.addEventListener("abort", onAbort, { once: true });
|
|
175
|
+
this.socket.write(text, "utf8", (err) => {
|
|
176
|
+
this.signal.removeEventListener("abort", onAbort);
|
|
177
|
+
if (err)
|
|
178
|
+
reject(err);
|
|
179
|
+
else
|
|
180
|
+
resolve();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/** Pull one complete reply out of the buffer, or `null` if it is partial. */
|
|
185
|
+
parse() {
|
|
186
|
+
let index = 0;
|
|
187
|
+
const lines = [];
|
|
188
|
+
let code = 0;
|
|
189
|
+
while (true) {
|
|
190
|
+
const newline = this.buffer.indexOf("\r\n", index);
|
|
191
|
+
if (newline === -1) {
|
|
192
|
+
if (this.buffer.length - index > MAX_LINE) {
|
|
193
|
+
throw new SmtpError("the server sent a line longer than SMTP allows");
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
const line = this.buffer.slice(index, newline);
|
|
198
|
+
index = newline + 2;
|
|
199
|
+
const match = line.match(/^(\d{3})([ -]?)(.*)$/);
|
|
200
|
+
if (match === null) {
|
|
201
|
+
throw new SmtpError("the server sent a reply this client could not parse");
|
|
202
|
+
}
|
|
203
|
+
code = Number.parseInt(match[1], 10);
|
|
204
|
+
lines.push(match[3]);
|
|
205
|
+
if (match[2] !== "-") {
|
|
206
|
+
this.buffer = this.buffer.slice(index);
|
|
207
|
+
return { code, lines };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async read() {
|
|
212
|
+
const signal = this.signal;
|
|
213
|
+
while (true) {
|
|
214
|
+
if (this.failure !== null)
|
|
215
|
+
throw this.failure;
|
|
216
|
+
const reply = this.parse();
|
|
217
|
+
if (reply !== null) {
|
|
218
|
+
this.transcript.push(`S: ${reply.code} ${reply.lines.join(" | ")}`);
|
|
219
|
+
return reply;
|
|
220
|
+
}
|
|
221
|
+
if (this.closed)
|
|
222
|
+
throw new SmtpError("the server closed the connection mid-reply");
|
|
223
|
+
if (signal.aborted)
|
|
224
|
+
throw new SmtpError("deadline elapsed while waiting for the server");
|
|
225
|
+
await new Promise((resolve, reject) => {
|
|
226
|
+
const onAbort = () => {
|
|
227
|
+
this.waiter = null;
|
|
228
|
+
reject(new SmtpError("deadline elapsed while waiting for the server"));
|
|
229
|
+
};
|
|
230
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
231
|
+
this.waiter = {
|
|
232
|
+
resolve: () => {
|
|
233
|
+
signal.removeEventListener("abort", onAbort);
|
|
234
|
+
this.waiter = null;
|
|
235
|
+
resolve();
|
|
236
|
+
},
|
|
237
|
+
reject: (err) => {
|
|
238
|
+
signal.removeEventListener("abort", onAbort);
|
|
239
|
+
this.waiter = null;
|
|
240
|
+
reject(err);
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** Send a command and read its reply. */
|
|
247
|
+
async command(text, logged = text) {
|
|
248
|
+
await this.write(`${text}\r\n`, logged);
|
|
249
|
+
return this.read();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function connectSocket(options) {
|
|
253
|
+
return new Promise((resolve, reject) => {
|
|
254
|
+
// An `abort` that has ALREADY fired never fires again, so a listener
|
|
255
|
+
// added after it is never called and the dial would wait on the OS
|
|
256
|
+
// timeout instead of on the deadline.
|
|
257
|
+
if (options.signal.aborted) {
|
|
258
|
+
reject(new SmtpError("the deadline had already elapsed before the connection was attempted"));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const onAbort = () => {
|
|
262
|
+
socket.destroy();
|
|
263
|
+
reject(new SmtpError("deadline elapsed before the SMTP connection was established"));
|
|
264
|
+
};
|
|
265
|
+
const socket = options.implicitTls
|
|
266
|
+
? tlsConnect({
|
|
267
|
+
host: options.pinnedIp,
|
|
268
|
+
port: options.port,
|
|
269
|
+
rejectUnauthorized: options.rejectUnauthorized,
|
|
270
|
+
...(sniFor(options.host) !== undefined ? { servername: sniFor(options.host) } : {}),
|
|
271
|
+
})
|
|
272
|
+
: netConnect({ host: options.pinnedIp, port: options.port });
|
|
273
|
+
const settled = (fn) => {
|
|
274
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
275
|
+
fn();
|
|
276
|
+
};
|
|
277
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
278
|
+
socket.once(options.implicitTls ? "secureConnect" : "connect", () => settled(() => resolve(socket)));
|
|
279
|
+
socket.once("error", (err) => settled(() => reject(err)));
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
function upgradeSocket(socket, options) {
|
|
283
|
+
return new Promise((resolve, reject) => {
|
|
284
|
+
if (options.signal.aborted) {
|
|
285
|
+
socket.destroy();
|
|
286
|
+
reject(new SmtpError("the deadline elapsed before the STARTTLS handshake began"));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const tlsSocket = tlsConnect({
|
|
290
|
+
socket,
|
|
291
|
+
// The identity the certificate must match. `servername` carries it for
|
|
292
|
+
// a named host; for an IP literal, where SNI is not allowed, `host` is
|
|
293
|
+
// what the identity check falls back to — without it an upgraded
|
|
294
|
+
// socket has no name to verify against at all.
|
|
295
|
+
host: options.host,
|
|
296
|
+
rejectUnauthorized: options.rejectUnauthorized,
|
|
297
|
+
...(sniFor(options.host) !== undefined ? { servername: sniFor(options.host) } : {}),
|
|
298
|
+
});
|
|
299
|
+
const onAbort = () => {
|
|
300
|
+
tlsSocket.destroy();
|
|
301
|
+
reject(new SmtpError("deadline elapsed during the STARTTLS handshake"));
|
|
302
|
+
};
|
|
303
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
304
|
+
tlsSocket.once("secureConnect", () => {
|
|
305
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
306
|
+
resolve(tlsSocket);
|
|
307
|
+
});
|
|
308
|
+
tlsSocket.once("error", (err) => {
|
|
309
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
310
|
+
reject(err);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
/** Capability names from an EHLO reply, uppercased, in the server's order. */
|
|
315
|
+
function capabilitiesOf(reply) {
|
|
316
|
+
return reply.lines.slice(1).map((line) => line.trim().toUpperCase());
|
|
317
|
+
}
|
|
318
|
+
function advertises(capabilities, name) {
|
|
319
|
+
return capabilities.find((c) => c === name || c.startsWith(`${name} `));
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Run one session and deliver one message.
|
|
323
|
+
*
|
|
324
|
+
* Never throws: a protocol failure, a refused recipient and a blown deadline
|
|
325
|
+
* all come back as an outcome, with the transcript so far, because the
|
|
326
|
+
* transcript is what makes an SMTP problem diagnosable without a packet
|
|
327
|
+
* capture.
|
|
328
|
+
*/
|
|
329
|
+
export async function sendMail(options) {
|
|
330
|
+
let session = null;
|
|
331
|
+
try {
|
|
332
|
+
// Before the socket, not after: a refusal here has sent nothing.
|
|
333
|
+
const badArgument = commandArgumentError(options);
|
|
334
|
+
if (badArgument !== null)
|
|
335
|
+
throw new SmtpError(badArgument);
|
|
336
|
+
const socket = await connectSocket(options);
|
|
337
|
+
session = new Session(socket, options.maxReplyBytes, options.signal);
|
|
338
|
+
const s = session;
|
|
339
|
+
const greeting = await s.read();
|
|
340
|
+
if (greeting.code !== 220) {
|
|
341
|
+
throw new SmtpError(`the server refused the connection: ${greeting.code}`);
|
|
342
|
+
}
|
|
343
|
+
let ehlo = await s.command(`EHLO ${options.ehloName}`);
|
|
344
|
+
if (ehlo.code !== 250) {
|
|
345
|
+
throw new SmtpError(`EHLO was refused: ${ehlo.code} ${ehlo.lines.join(" ")}`);
|
|
346
|
+
}
|
|
347
|
+
let capabilities = capabilitiesOf(ehlo);
|
|
348
|
+
let secured = options.implicitTls;
|
|
349
|
+
if (!secured && advertises(capabilities, "STARTTLS") !== undefined) {
|
|
350
|
+
const ready = await s.command("STARTTLS");
|
|
351
|
+
if (ready.code !== 220) {
|
|
352
|
+
throw new SmtpError(`STARTTLS was refused: ${ready.code} ${ready.lines.join(" ")}`);
|
|
353
|
+
}
|
|
354
|
+
const upgraded = await upgradeSocket(socket, options);
|
|
355
|
+
s.upgrade(upgraded);
|
|
356
|
+
secured = true;
|
|
357
|
+
// RFC 3207 §4.2: everything the server said before the upgrade is
|
|
358
|
+
// discarded, because it was said by whoever was on the wire.
|
|
359
|
+
ehlo = await s.command(`EHLO ${options.ehloName}`);
|
|
360
|
+
if (ehlo.code !== 250) {
|
|
361
|
+
throw new SmtpError(`EHLO after STARTTLS was refused: ${ehlo.code}`);
|
|
362
|
+
}
|
|
363
|
+
capabilities = capabilitiesOf(ehlo);
|
|
364
|
+
}
|
|
365
|
+
if (!secured && options.requireTls) {
|
|
366
|
+
// Before AUTH, before MAIL FROM: nothing has left this process yet.
|
|
367
|
+
throw new SmtpError("the server did not offer STARTTLS and requireTls is set, so the session was ended before anything was sent. Set requireTls false only for a host you control on a network you trust.");
|
|
368
|
+
}
|
|
369
|
+
// -- authentication ---------------------------------------------------
|
|
370
|
+
let authenticated = false;
|
|
371
|
+
if (options.username !== undefined && options.password !== undefined) {
|
|
372
|
+
const authCap = advertises(capabilities, "AUTH");
|
|
373
|
+
const offered = (authCap ?? "").split(/\s+/).slice(1);
|
|
374
|
+
const wanted = options.authMethod ?? "auto";
|
|
375
|
+
const canPlain = offered.includes("PLAIN");
|
|
376
|
+
const canLogin = offered.includes("LOGIN");
|
|
377
|
+
const method = wanted === "plain"
|
|
378
|
+
? "plain"
|
|
379
|
+
: wanted === "login"
|
|
380
|
+
? "login"
|
|
381
|
+
: canPlain
|
|
382
|
+
? "plain"
|
|
383
|
+
: canLogin
|
|
384
|
+
? "login"
|
|
385
|
+
: null;
|
|
386
|
+
if (authCap === undefined) {
|
|
387
|
+
throw new SmtpError("the server does not advertise AUTH, but a username was supplied — sending the credential anyway would hand it to a server that never asked for it");
|
|
388
|
+
}
|
|
389
|
+
if (method === null) {
|
|
390
|
+
throw new SmtpError(`the server offers AUTH ${offered.join(" ")}, none of which this client implements (it speaks PLAIN and LOGIN)`);
|
|
391
|
+
}
|
|
392
|
+
if (method === "plain") {
|
|
393
|
+
const blob = Buffer.from(`\u0000${options.username}\u0000${options.password}`, "utf8").toString("base64");
|
|
394
|
+
await s.write(`AUTH PLAIN ${blob}\r\n`, "AUTH PLAIN <redacted>");
|
|
395
|
+
const reply = await s.read();
|
|
396
|
+
if (reply.code !== 235) {
|
|
397
|
+
throw new SmtpError(`authentication failed: ${reply.code} ${reply.lines.join(" ")}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
const start = await s.command("AUTH LOGIN");
|
|
402
|
+
if (start.code !== 334) {
|
|
403
|
+
throw new SmtpError(`AUTH LOGIN was refused: ${start.code}`);
|
|
404
|
+
}
|
|
405
|
+
await s.write(`${Buffer.from(options.username, "utf8").toString("base64")}\r\n`, "<username, redacted>");
|
|
406
|
+
const askPassword = await s.read();
|
|
407
|
+
if (askPassword.code !== 334) {
|
|
408
|
+
throw new SmtpError(`the server did not ask for a password: ${askPassword.code}`);
|
|
409
|
+
}
|
|
410
|
+
await s.write(`${Buffer.from(options.password, "utf8").toString("base64")}\r\n`, "<password, redacted>");
|
|
411
|
+
const reply = await s.read();
|
|
412
|
+
if (reply.code !== 235) {
|
|
413
|
+
throw new SmtpError(`authentication failed: ${reply.code} ${reply.lines.join(" ")}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
authenticated = true;
|
|
417
|
+
}
|
|
418
|
+
// -- envelope ---------------------------------------------------------
|
|
419
|
+
const sizeCap = advertises(capabilities, "SIZE");
|
|
420
|
+
if (sizeCap !== undefined) {
|
|
421
|
+
const limit = Number.parseInt(sizeCap.split(/\s+/)[1] ?? "", 10);
|
|
422
|
+
const bytes = Buffer.byteLength(options.message, "utf8");
|
|
423
|
+
if (Number.isFinite(limit) && limit > 0 && bytes > limit) {
|
|
424
|
+
throw new SmtpError(`the message is ${bytes} bytes and the server accepts at most ${limit} — refused here rather than after the whole body has been uploaded`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const mailFrom = await s.command(`MAIL FROM:<${options.envelopeFrom}>`);
|
|
428
|
+
if (mailFrom.code !== 250) {
|
|
429
|
+
throw new SmtpError(`the server refused the sender: ${mailFrom.code} ${mailFrom.lines.join(" ")}`);
|
|
430
|
+
}
|
|
431
|
+
const accepted = [];
|
|
432
|
+
const rejected = [];
|
|
433
|
+
for (const address of options.envelopeTo) {
|
|
434
|
+
const rcpt = await s.command(`RCPT TO:<${address}>`);
|
|
435
|
+
if (rcpt.code === 250 || rcpt.code === 251)
|
|
436
|
+
accepted.push(address);
|
|
437
|
+
else
|
|
438
|
+
rejected.push({ address, reply: `${rcpt.code} ${rcpt.lines.join(" ")}` });
|
|
439
|
+
}
|
|
440
|
+
if (accepted.length === 0) {
|
|
441
|
+
await s.command("QUIT").catch(() => undefined);
|
|
442
|
+
return {
|
|
443
|
+
ok: false,
|
|
444
|
+
error: `every recipient was refused: ${rejected
|
|
445
|
+
.map((r) => `${r.address} (${r.reply})`)
|
|
446
|
+
.join("; ")}`,
|
|
447
|
+
transcript: s.transcript,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
// -- body -------------------------------------------------------------
|
|
451
|
+
const data = await s.command("DATA");
|
|
452
|
+
if (data.code !== 354) {
|
|
453
|
+
throw new SmtpError(`the server refused DATA: ${data.code} ${data.lines.join(" ")}`);
|
|
454
|
+
}
|
|
455
|
+
await s.write(dotStuff(options.message), `<message body, ${options.message.length} chars>`);
|
|
456
|
+
const queued = await s.read();
|
|
457
|
+
if (queued.code !== 250) {
|
|
458
|
+
throw new SmtpError(`the message was not accepted: ${queued.code} ${queued.lines.join(" ")}`);
|
|
459
|
+
}
|
|
460
|
+
await s.command("QUIT").catch(() => undefined);
|
|
461
|
+
return {
|
|
462
|
+
ok: true,
|
|
463
|
+
secured,
|
|
464
|
+
authenticated,
|
|
465
|
+
accepted,
|
|
466
|
+
rejected,
|
|
467
|
+
queued: queued.lines.join(" "),
|
|
468
|
+
transcript: s.transcript,
|
|
469
|
+
capabilities,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
474
|
+
return { ok: false, error: message, transcript: session?.transcript ?? [] };
|
|
475
|
+
}
|
|
476
|
+
finally {
|
|
477
|
+
session?.destroy();
|
|
478
|
+
}
|
|
479
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/tool-notify",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Deterministic messaging tools: send to chat platforms, email and webhooks, render templates, assemble digests and enforce quiet hours",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "bun test src"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@crewhaus/errors": "0.7.0",
|
|
19
|
+
"@crewhaus/tool-builder": "0.7.0",
|
|
20
|
+
"@crewhaus/tool-catalog": "0.7.0",
|
|
21
|
+
"zod": "^3.23.8"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@crewhaus/tool-executor": "workspace:*"
|
|
25
|
+
},
|
|
26
|
+
"license": "Apache-2.0",
|
|
27
|
+
"author": {
|
|
28
|
+
"name": "Max Meier",
|
|
29
|
+
"email": "max@crewhaus.ai",
|
|
30
|
+
"url": "https://crewhaus.ai"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
35
|
+
"directory": "packages/tool-notify"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-notify#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
45
|
+
}
|