@bumail/smtp 0.1.0 → 0.2.1
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 +149 -7
- package/dist/chunks/index-3423jxf9.js +249 -0
- package/dist/chunks/index-3423jxf9.js.map +14 -0
- package/dist/client/content.d.ts +25 -0
- package/dist/client/content.d.ts.map +1 -0
- package/dist/client/inbox.d.ts +18 -0
- package/dist/client/inbox.d.ts.map +1 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +948 -0
- package/dist/client/index.js.map +22 -0
- package/dist/client/mx.d.ts +29 -0
- package/dist/client/mx.d.ts.map +1 -0
- package/dist/client/options.d.ts +136 -0
- package/dist/client/options.d.ts.map +1 -0
- package/dist/client/refusal.d.ts +15 -0
- package/dist/client/refusal.d.ts.map +1 -0
- package/dist/client/send.d.ts +13 -0
- package/dist/client/send.d.ts.map +1 -0
- package/dist/client/session.d.ts +11 -0
- package/dist/client/session.d.ts.map +1 -0
- package/dist/client/settings.d.ts +30 -0
- package/dist/client/settings.d.ts.map +1 -0
- package/dist/client/socket.d.ts +31 -0
- package/dist/client/socket.d.ts.map +1 -0
- package/dist/client/target.d.ts +29 -0
- package/dist/client/target.d.ts.map +1 -0
- package/dist/client/transaction.d.ts +15 -0
- package/dist/client/transaction.d.ts.map +1 -0
- package/dist/errors.d.ts +28 -4
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +164 -197
- package/dist/index.js.map +9 -13
- package/dist/io/outgoing.d.ts +21 -0
- package/dist/io/outgoing.d.ts.map +1 -0
- package/dist/protocol/data-writer.d.ts +22 -0
- package/dist/protocol/data-writer.d.ts.map +1 -0
- package/dist/protocol/ehlo.d.ts +7 -0
- package/dist/protocol/ehlo.d.ts.map +1 -0
- package/dist/{server → protocol}/lines.d.ts +9 -4
- package/dist/protocol/lines.d.ts.map +1 -0
- package/dist/protocol/path.d.ts +24 -3
- package/dist/protocol/path.d.ts.map +1 -1
- package/dist/protocol/reply-reader.d.ts +19 -0
- package/dist/protocol/reply-reader.d.ts.map +1 -0
- package/dist/protocol/sasl.d.ts +4 -0
- package/dist/protocol/sasl.d.ts.map +1 -1
- package/dist/server/commands.d.ts.map +1 -1
- package/dist/server/connection.d.ts +8 -0
- package/dist/server/connection.d.ts.map +1 -1
- package/dist/server/input.d.ts.map +1 -1
- package/dist/server/options.d.ts +2 -1
- package/dist/server/options.d.ts.map +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/settings.d.ts.map +1 -1
- package/dist/server/transport.d.ts +40 -4
- package/dist/server/transport.d.ts.map +1 -1
- package/docs/README.md +3 -3
- package/docs/guide.md +407 -13
- package/docs/roadmap.md +43 -8
- package/docs/troubleshooting.md +706 -9
- package/package.json +12 -4
- package/dist/server/lines.d.ts.map +0 -1
|
@@ -0,0 +1,948 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SmtpError2,
|
|
3
|
+
isHelloName,
|
|
4
|
+
parsePath2,
|
|
5
|
+
encodePlain,
|
|
6
|
+
encodeLoginStep,
|
|
7
|
+
MAX_LINE,
|
|
8
|
+
LineSplitter,
|
|
9
|
+
Outgoing
|
|
10
|
+
} from "../chunks/index-3423jxf9.js";
|
|
11
|
+
// src/client/mx.ts
|
|
12
|
+
function dnsCode(error) {
|
|
13
|
+
return error instanceof Error && error.name === "DnsError" ? error.code : undefined;
|
|
14
|
+
}
|
|
15
|
+
function temporary(error) {
|
|
16
|
+
const code = dnsCode(error);
|
|
17
|
+
return code === undefined || code === "TEMPORARY" || code === "TIMEOUT";
|
|
18
|
+
}
|
|
19
|
+
var messageOf = (error) => error instanceof Error ? error.message : String(error);
|
|
20
|
+
function byPreference(hosts, random = Math.random) {
|
|
21
|
+
return hosts.map((host) => ({ host, key: random() })).sort((x, y) => x.host.priority - y.host.priority || x.key - y.key).map(({ host }) => host);
|
|
22
|
+
}
|
|
23
|
+
async function resolveMx(domain, resolver) {
|
|
24
|
+
const implicit = [
|
|
25
|
+
{ host: domain.toLowerCase(), priority: 0, implicit: true }
|
|
26
|
+
];
|
|
27
|
+
let records;
|
|
28
|
+
try {
|
|
29
|
+
records = await resolver.mx(domain);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (dnsCode(error) === "NOT_FOUND")
|
|
32
|
+
return implicit;
|
|
33
|
+
throw new SmtpError2("DNS_FAILED", `Could not look up the MX records of ${domain}: ${messageOf(error)}`, { temporary: temporary(error) });
|
|
34
|
+
}
|
|
35
|
+
const hosts = records.filter((record) => record.exchange !== "").map((record) => ({
|
|
36
|
+
host: record.exchange,
|
|
37
|
+
priority: record.priority,
|
|
38
|
+
implicit: false
|
|
39
|
+
}));
|
|
40
|
+
if (records.length === 0)
|
|
41
|
+
return implicit;
|
|
42
|
+
if (hosts.length === 0) {
|
|
43
|
+
throw new SmtpError2("NULL_MX", `${domain} accepts no mail: its MX record is the null MX (RFC 7505)`, { temporary: false });
|
|
44
|
+
}
|
|
45
|
+
return byPreference(hosts);
|
|
46
|
+
}
|
|
47
|
+
async function addressesOf(host, resolver) {
|
|
48
|
+
const found = [];
|
|
49
|
+
let failure;
|
|
50
|
+
for (const lookup of [resolver.a, resolver.aaaa]) {
|
|
51
|
+
try {
|
|
52
|
+
for (const record of await lookup.call(resolver, host)) {
|
|
53
|
+
found.push(record.address);
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (failure === undefined || temporary(error))
|
|
57
|
+
failure = error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return found.length > 0 ? found : { error: failure };
|
|
61
|
+
}
|
|
62
|
+
function noAddress(domain, error) {
|
|
63
|
+
const why = error === undefined ? "no A or AAAA record" : messageOf(error);
|
|
64
|
+
return new SmtpError2("DNS_FAILED", `No mail host of ${domain} has an address: ${why}`, { temporary: error !== undefined && temporary(error) });
|
|
65
|
+
}
|
|
66
|
+
// src/protocol/data-writer.ts
|
|
67
|
+
var CR = 13;
|
|
68
|
+
var LF = 10;
|
|
69
|
+
var DOT = 46;
|
|
70
|
+
|
|
71
|
+
class DataWriter {
|
|
72
|
+
#normalize;
|
|
73
|
+
#lineStart = true;
|
|
74
|
+
#heldCr = false;
|
|
75
|
+
bareLineBreaks = 0;
|
|
76
|
+
eightBit = false;
|
|
77
|
+
constructor(normalize = false) {
|
|
78
|
+
this.#normalize = normalize;
|
|
79
|
+
}
|
|
80
|
+
#bare(out, n, byte) {
|
|
81
|
+
if (this.#normalize) {
|
|
82
|
+
out[n++] = CR;
|
|
83
|
+
out[n++] = LF;
|
|
84
|
+
this.#lineStart = true;
|
|
85
|
+
return n;
|
|
86
|
+
}
|
|
87
|
+
this.bareLineBreaks++;
|
|
88
|
+
out[n++] = byte;
|
|
89
|
+
this.#lineStart = false;
|
|
90
|
+
return n;
|
|
91
|
+
}
|
|
92
|
+
write(chunk) {
|
|
93
|
+
const out = new Uint8Array(chunk.length * 2 + 2);
|
|
94
|
+
let n = 0;
|
|
95
|
+
for (let i = 0;i < chunk.length; i++) {
|
|
96
|
+
const byte = chunk[i];
|
|
97
|
+
if (this.#heldCr) {
|
|
98
|
+
this.#heldCr = false;
|
|
99
|
+
if (byte === LF) {
|
|
100
|
+
out[n++] = CR;
|
|
101
|
+
out[n++] = LF;
|
|
102
|
+
this.#lineStart = true;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
n = this.#bare(out, n, CR);
|
|
106
|
+
}
|
|
107
|
+
if (byte === CR) {
|
|
108
|
+
this.#heldCr = true;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (byte === LF) {
|
|
112
|
+
n = this.#bare(out, n, LF);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (byte === DOT && this.#lineStart)
|
|
116
|
+
out[n++] = DOT;
|
|
117
|
+
if (byte > 127)
|
|
118
|
+
this.eightBit = true;
|
|
119
|
+
out[n++] = byte;
|
|
120
|
+
this.#lineStart = false;
|
|
121
|
+
}
|
|
122
|
+
return out.subarray(0, n);
|
|
123
|
+
}
|
|
124
|
+
end() {
|
|
125
|
+
const out = new Uint8Array(7);
|
|
126
|
+
let n = 0;
|
|
127
|
+
if (this.#heldCr) {
|
|
128
|
+
this.#heldCr = false;
|
|
129
|
+
n = this.#bare(out, n, CR);
|
|
130
|
+
}
|
|
131
|
+
if (!this.#lineStart) {
|
|
132
|
+
out[n++] = CR;
|
|
133
|
+
out[n++] = LF;
|
|
134
|
+
}
|
|
135
|
+
out[n++] = DOT;
|
|
136
|
+
out[n++] = CR;
|
|
137
|
+
out[n++] = LF;
|
|
138
|
+
return out.subarray(0, n);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// src/client/content.ts
|
|
143
|
+
var SLICE = 64 * 1024;
|
|
144
|
+
var bareLineBreak = () => new SmtpError2("BARE_LINE_BREAK", "sendMail(): the message holds a bare CR or LF, which a server must refuse (SMTP smuggling); end every line with CRLF, or pass normalizeLineEnds: true");
|
|
145
|
+
function sliced(next) {
|
|
146
|
+
let held;
|
|
147
|
+
return async () => {
|
|
148
|
+
if (!held || held.length === 0)
|
|
149
|
+
held = await next();
|
|
150
|
+
if (!held)
|
|
151
|
+
return;
|
|
152
|
+
const slice = held.subarray(0, SLICE);
|
|
153
|
+
held = held.subarray(SLICE);
|
|
154
|
+
return slice;
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function fromBytes(bytes) {
|
|
158
|
+
let given = false;
|
|
159
|
+
const next = sliced(async () => {
|
|
160
|
+
if (given)
|
|
161
|
+
return;
|
|
162
|
+
given = true;
|
|
163
|
+
return bytes;
|
|
164
|
+
});
|
|
165
|
+
return { next, cancel() {} };
|
|
166
|
+
}
|
|
167
|
+
function fromStream(stream) {
|
|
168
|
+
const reader = stream.getReader();
|
|
169
|
+
const next = sliced(async () => {
|
|
170
|
+
const { done, value } = await reader.read();
|
|
171
|
+
if (done)
|
|
172
|
+
return;
|
|
173
|
+
if (!(value instanceof Uint8Array)) {
|
|
174
|
+
throw new SmtpError2("INVALID_OPTION", "sendMail(): a message stream must give Uint8Array chunks");
|
|
175
|
+
}
|
|
176
|
+
return value;
|
|
177
|
+
});
|
|
178
|
+
return {
|
|
179
|
+
next,
|
|
180
|
+
cancel: () => void reader.cancel().catch(() => {})
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function contentOf(message, normalize, size) {
|
|
184
|
+
if (message instanceof ReadableStream) {
|
|
185
|
+
let read = false;
|
|
186
|
+
return {
|
|
187
|
+
...size === undefined ? {} : { size },
|
|
188
|
+
eightBit: false,
|
|
189
|
+
chunks() {
|
|
190
|
+
if (read) {
|
|
191
|
+
throw new SmtpError2("INVALID_OPTION", "sendMail(): a message stream can be sent once only");
|
|
192
|
+
}
|
|
193
|
+
read = true;
|
|
194
|
+
return fromStream(message);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
let bytes;
|
|
199
|
+
if (typeof message === "string")
|
|
200
|
+
bytes = new TextEncoder().encode(message);
|
|
201
|
+
else if (message instanceof Uint8Array)
|
|
202
|
+
bytes = message;
|
|
203
|
+
else {
|
|
204
|
+
throw new SmtpError2("INVALID_OPTION", "sendMail(): the message must be a Uint8Array, a string or a ReadableStream");
|
|
205
|
+
}
|
|
206
|
+
const writer = new DataWriter(normalize);
|
|
207
|
+
let sent = 0;
|
|
208
|
+
for (let at = 0;at < bytes.length; at += SLICE) {
|
|
209
|
+
sent += writer.write(bytes.subarray(at, at + SLICE)).length;
|
|
210
|
+
}
|
|
211
|
+
writer.end();
|
|
212
|
+
if (writer.bareLineBreaks > 0)
|
|
213
|
+
throw bareLineBreak();
|
|
214
|
+
return {
|
|
215
|
+
size: sent,
|
|
216
|
+
eightBit: writer.eightBit,
|
|
217
|
+
chunks: () => fromBytes(bytes)
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// src/protocol/reply-reader.ts
|
|
222
|
+
var MAX_REPLY_LINES = 100;
|
|
223
|
+
var isDigit = (c) => c >= 48 && c <= 57;
|
|
224
|
+
function parseLine(line) {
|
|
225
|
+
if (line.length < 3)
|
|
226
|
+
return;
|
|
227
|
+
const a = line.charCodeAt(0);
|
|
228
|
+
if (a < 50 || a > 53)
|
|
229
|
+
return;
|
|
230
|
+
if (!isDigit(line.charCodeAt(1)) || !isDigit(line.charCodeAt(2)))
|
|
231
|
+
return;
|
|
232
|
+
const code = Number(line.slice(0, 3));
|
|
233
|
+
if (line.length === 3)
|
|
234
|
+
return { code, last: true, text: "" };
|
|
235
|
+
const separator = line[3];
|
|
236
|
+
if (separator !== " " && separator !== "-")
|
|
237
|
+
return;
|
|
238
|
+
return { code, last: separator === " ", text: line.slice(4) };
|
|
239
|
+
}
|
|
240
|
+
function statusOf(code, text) {
|
|
241
|
+
const space = text.indexOf(" ");
|
|
242
|
+
const token = space < 0 ? text : text.slice(0, space);
|
|
243
|
+
if (token.length > 11 || !/^[245]\.\d{1,3}\.\d{1,3}$/.test(token))
|
|
244
|
+
return;
|
|
245
|
+
return token[0] === String(code)[0] ? token : undefined;
|
|
246
|
+
}
|
|
247
|
+
function build(code, lines) {
|
|
248
|
+
const status = statusOf(code, lines[0] ?? "");
|
|
249
|
+
const text = status ? lines.map((line) => line.startsWith(`${status} `) ? line.slice(status.length + 1) : line === status ? "" : line) : [...lines];
|
|
250
|
+
const body = text.length === 1 ? text[0] : text;
|
|
251
|
+
return status === undefined ? { code, text: body } : { code, status, text: body };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
class ReplyReader {
|
|
255
|
+
#lines = [];
|
|
256
|
+
#code = 0;
|
|
257
|
+
line(text) {
|
|
258
|
+
const parsed = parseLine(text);
|
|
259
|
+
if (!parsed) {
|
|
260
|
+
const shown = text.length > 40 ? `${text.slice(0, 40)}…` : text;
|
|
261
|
+
return {
|
|
262
|
+
error: `a line that is not an SMTP reply: ${JSON.stringify(shown)}`
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
if (this.#lines.length > 0 && parsed.code !== this.#code) {
|
|
266
|
+
return {
|
|
267
|
+
error: `a reply whose lines change code, from ${this.#code} to ${parsed.code}`
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
if (this.#lines.length >= MAX_REPLY_LINES) {
|
|
271
|
+
return { error: `a reply of more than ${MAX_REPLY_LINES} lines` };
|
|
272
|
+
}
|
|
273
|
+
this.#code = parsed.code;
|
|
274
|
+
this.#lines.push(parsed.text);
|
|
275
|
+
if (!parsed.last)
|
|
276
|
+
return;
|
|
277
|
+
const lines = this.#lines;
|
|
278
|
+
this.#lines = [];
|
|
279
|
+
return build(parsed.code, lines);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function replyText(reply) {
|
|
283
|
+
return typeof reply.text === "string" ? reply.text : reply.text.join(" ");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// src/client/refusal.ts
|
|
287
|
+
function shown(reply) {
|
|
288
|
+
const text = replyText(reply);
|
|
289
|
+
const line = `${reply.code}${reply.status ? ` ${reply.status}` : ""} ${text}`;
|
|
290
|
+
return line.length > 200 ? `${line.slice(0, 200)}…` : line;
|
|
291
|
+
}
|
|
292
|
+
function refused(host, what, reply) {
|
|
293
|
+
return new SmtpError2("REFUSED", `${host} refused ${what}: ${shown(reply)}`, {
|
|
294
|
+
reply
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
var badReply = (host, what) => new SmtpError2("BAD_REPLY", `${host} sent ${what}`);
|
|
298
|
+
var lost = (host, what) => new SmtpError2("CONNECTION_LOST", what === undefined ? `The connection to ${host} closed while the message was sent` : `The connection to ${host} closed before ${what}`);
|
|
299
|
+
var timedOut = (host, what, seconds, deadline) => new SmtpError2("TIMEOUT", deadline === undefined ? `Timed out after ${seconds} s waiting for ${what} (${host})` : `The deadline of ${deadline} s passed waiting for ${what} (${host})`);
|
|
300
|
+
var tlsFailed = (host, why) => new SmtpError2("TLS_FAILED", `TLS with ${host} failed: ${why}`);
|
|
301
|
+
|
|
302
|
+
// src/protocol/ehlo.ts
|
|
303
|
+
function parseEhlo(lines) {
|
|
304
|
+
const extensions = new Map;
|
|
305
|
+
for (const line of lines.slice(1)) {
|
|
306
|
+
const text = line.trim();
|
|
307
|
+
const space = text.indexOf(" ");
|
|
308
|
+
const keyword = (space < 0 ? text : text.slice(0, space)).toUpperCase();
|
|
309
|
+
if (keyword === "")
|
|
310
|
+
continue;
|
|
311
|
+
extensions.set(keyword, space < 0 ? "" : text.slice(space + 1).trim());
|
|
312
|
+
}
|
|
313
|
+
return extensions;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// src/client/session.ts
|
|
317
|
+
async function hello(socket, settings, allowHelo) {
|
|
318
|
+
const seconds = settings.timeouts.command;
|
|
319
|
+
socket.write(`EHLO ${settings.helo}\r
|
|
320
|
+
`);
|
|
321
|
+
const ehlo = await socket.reply("the reply to EHLO", seconds);
|
|
322
|
+
if (ehlo.code === 250) {
|
|
323
|
+
return parseEhlo(typeof ehlo.text === "string" ? [ehlo.text] : ehlo.text);
|
|
324
|
+
}
|
|
325
|
+
if (ehlo.code < 500 || !allowHelo)
|
|
326
|
+
throw refused(socket.host, "EHLO", ehlo);
|
|
327
|
+
socket.write(`HELO ${settings.helo}\r
|
|
328
|
+
`);
|
|
329
|
+
const helo = await socket.reply("the reply to HELO", seconds);
|
|
330
|
+
if (helo.code !== 250)
|
|
331
|
+
throw refused(socket.host, "HELO", helo);
|
|
332
|
+
return new Map;
|
|
333
|
+
}
|
|
334
|
+
async function startTls(socket, settings, tls, extensions) {
|
|
335
|
+
const required = settings.tls === "required";
|
|
336
|
+
if (!extensions.has("STARTTLS")) {
|
|
337
|
+
if (!required)
|
|
338
|
+
return extensions;
|
|
339
|
+
throw new SmtpError2("TLS_UNAVAILABLE", `${socket.host} does not offer STARTTLS, and tls is 'required'`);
|
|
340
|
+
}
|
|
341
|
+
socket.write(`STARTTLS\r
|
|
342
|
+
`);
|
|
343
|
+
const answer = await socket.reply("the reply to STARTTLS", settings.timeouts.command);
|
|
344
|
+
if (answer.code !== 220) {
|
|
345
|
+
if (!required)
|
|
346
|
+
return extensions;
|
|
347
|
+
throw new SmtpError2("TLS_UNAVAILABLE", `${socket.host} refused STARTTLS, and tls is 'required': ${shown(answer)}`, { reply: answer, temporary: true });
|
|
348
|
+
}
|
|
349
|
+
await socket.startTls(tls, settings.timeouts.connect);
|
|
350
|
+
return hello(socket, settings, false);
|
|
351
|
+
}
|
|
352
|
+
async function authenticate(socket, settings, extensions) {
|
|
353
|
+
const { auth } = settings;
|
|
354
|
+
if (!auth)
|
|
355
|
+
return;
|
|
356
|
+
const { host } = socket;
|
|
357
|
+
if (!socket.secure && !settings.allowPlaintextAuth) {
|
|
358
|
+
throw new SmtpError2("AUTH_UNAVAILABLE", `Refusing to send credentials to ${host} in clear: it did not start TLS (allowPlaintextAuth is for a local test server only)`);
|
|
359
|
+
}
|
|
360
|
+
const offered = (extensions.get("AUTH") ?? "").toUpperCase().split(" ");
|
|
361
|
+
const mechanism = auth.mechanism ?? (offered.includes("PLAIN") ? "PLAIN" : offered.includes("LOGIN") ? "LOGIN" : undefined);
|
|
362
|
+
if (!mechanism || !offered.includes(mechanism)) {
|
|
363
|
+
throw new SmtpError2("AUTH_UNAVAILABLE", `${host} offers no AUTH mechanism this client speaks (${auth.mechanism ?? "PLAIN, LOGIN"}): AUTH ${extensions.get("AUTH") ?? "is not offered"}`);
|
|
364
|
+
}
|
|
365
|
+
const seconds = settings.timeouts.command;
|
|
366
|
+
const steps = mechanism === "PLAIN" ? [`AUTH PLAIN ${encodePlain(auth.username, auth.password)}`] : [
|
|
367
|
+
"AUTH LOGIN",
|
|
368
|
+
encodeLoginStep(auth.username),
|
|
369
|
+
encodeLoginStep(auth.password)
|
|
370
|
+
];
|
|
371
|
+
for (const [i, step] of steps.entries()) {
|
|
372
|
+
socket.write(`${step}\r
|
|
373
|
+
`);
|
|
374
|
+
const answer = await socket.reply("the reply to AUTH", seconds);
|
|
375
|
+
const last = i === steps.length - 1;
|
|
376
|
+
if (answer.code === (last ? 235 : 334))
|
|
377
|
+
continue;
|
|
378
|
+
throw refused(host, `AUTH ${mechanism}`, answer);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
async function open(socket, settings, tls) {
|
|
382
|
+
const greeting = await socket.reply("the greeting", settings.timeouts.greeting);
|
|
383
|
+
if (greeting.code !== 220) {
|
|
384
|
+
throw refused(socket.host, "the connection", greeting);
|
|
385
|
+
}
|
|
386
|
+
let extensions = await hello(socket, settings, true);
|
|
387
|
+
if (!socket.secure && settings.tls !== "none") {
|
|
388
|
+
extensions = await startTls(socket, settings, tls, extensions);
|
|
389
|
+
}
|
|
390
|
+
await authenticate(socket, settings, extensions);
|
|
391
|
+
return { extensions, authenticated: settings.auth !== undefined };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// src/client/settings.ts
|
|
395
|
+
import { hostname as machineName } from "node:os";
|
|
396
|
+
var MAX_SECONDS = Math.floor(2147483647 / 1000);
|
|
397
|
+
var TIMEOUTS = {
|
|
398
|
+
connect: 30,
|
|
399
|
+
greeting: 300,
|
|
400
|
+
command: 300,
|
|
401
|
+
mail: 300,
|
|
402
|
+
rcpt: 300,
|
|
403
|
+
dataStart: 120,
|
|
404
|
+
dataBlock: 180,
|
|
405
|
+
dataEnd: 600
|
|
406
|
+
};
|
|
407
|
+
var invalid = (message) => new SmtpError2("INVALID_OPTION", `sendMail(): ${message}`);
|
|
408
|
+
function seconds(name, value, fallback) {
|
|
409
|
+
if (value === undefined)
|
|
410
|
+
return fallback;
|
|
411
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > MAX_SECONDS) {
|
|
412
|
+
throw invalid(`${name} must be a number of seconds above 0 and at most ${MAX_SECONDS}, not ${String(value)}`);
|
|
413
|
+
}
|
|
414
|
+
return value;
|
|
415
|
+
}
|
|
416
|
+
function timeoutsOf(given = {}) {
|
|
417
|
+
const out = {};
|
|
418
|
+
for (const [name, fallback] of Object.entries(TIMEOUTS)) {
|
|
419
|
+
out[name] = seconds(`timeouts.${name}`, given[name], fallback);
|
|
420
|
+
}
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
function address(value, allowNull) {
|
|
424
|
+
const text = typeof value === "string" ? value : "";
|
|
425
|
+
if (typeof value === "string" && parsePath2(`<${text}>`, allowNull, "refuse"))
|
|
426
|
+
return text;
|
|
427
|
+
const routed = typeof value === "string" && parsePath2(`<${text}>`, false);
|
|
428
|
+
if (routed) {
|
|
429
|
+
throw invalid(`${JSON.stringify(value)} holds a source route (@host:), which RFC 5321 says a client should not send: pass ${JSON.stringify(`${routed.local}@${text.slice(text.lastIndexOf("@") + 1)}`)} alone`);
|
|
430
|
+
}
|
|
431
|
+
throw invalid(`${JSON.stringify(value)} is not an address (local@domain${allowNull ? ", or '' for a bounce" : ""})`);
|
|
432
|
+
}
|
|
433
|
+
function heloOf(value, byMx) {
|
|
434
|
+
if (value !== undefined) {
|
|
435
|
+
if (!isHelloName(value))
|
|
436
|
+
throw invalid(`helo "${value}" is not a host name`);
|
|
437
|
+
return value;
|
|
438
|
+
}
|
|
439
|
+
if (byMx)
|
|
440
|
+
throw invalid("helo is required for delivery by MX: pass your server's public name, such as helo: 'mail.example.com'");
|
|
441
|
+
const name = machineName();
|
|
442
|
+
return isHelloName(name) ? name : "localhost";
|
|
443
|
+
}
|
|
444
|
+
function authOf(auth) {
|
|
445
|
+
if (auth === undefined)
|
|
446
|
+
return;
|
|
447
|
+
const { username, password, mechanism } = auth;
|
|
448
|
+
if (typeof username !== "string" || username === "")
|
|
449
|
+
throw invalid("auth.username must be a non-empty string");
|
|
450
|
+
if (typeof password !== "string" || password === "")
|
|
451
|
+
throw invalid("auth.password must be a non-empty string");
|
|
452
|
+
if (mechanism !== undefined && mechanism !== "PLAIN" && mechanism !== "LOGIN")
|
|
453
|
+
throw invalid(`auth.mechanism must be 'PLAIN' or 'LOGIN', not ${mechanism}`);
|
|
454
|
+
return auth;
|
|
455
|
+
}
|
|
456
|
+
function tlsOf(options, plaintextAuth) {
|
|
457
|
+
const secure = options.secure === true;
|
|
458
|
+
const tls = options.tls ?? (secure || options.auth && !plaintextAuth ? "required" : "opportunistic");
|
|
459
|
+
if (tls !== "opportunistic" && tls !== "required" && tls !== "none")
|
|
460
|
+
throw invalid(`tls must be 'opportunistic', 'required' or 'none', not ${tls}`);
|
|
461
|
+
if (tls === "none" && secure)
|
|
462
|
+
throw invalid("secure is TLS from the first byte: it cannot go with tls: 'none'");
|
|
463
|
+
if (tls === "none" && options.auth && !plaintextAuth)
|
|
464
|
+
throw invalid("auth with tls: 'none' would send the password in clear; pass allowPlaintextAuth: true for a local test server");
|
|
465
|
+
if (tls === "opportunistic" && options.auth && !plaintextAuth)
|
|
466
|
+
throw invalid("auth with tls: 'opportunistic' would send the password to a server whose certificate is not checked; leave tls out to check it, or pass allowPlaintextAuth: true for a local test server");
|
|
467
|
+
return tls;
|
|
468
|
+
}
|
|
469
|
+
function settingsOf(options) {
|
|
470
|
+
if (typeof options !== "object" || options === null)
|
|
471
|
+
throw invalid("options must be an object");
|
|
472
|
+
const from = address(options.from, true);
|
|
473
|
+
const list = typeof options.to === "string" ? [options.to] : options.to;
|
|
474
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
475
|
+
throw invalid("to must name one recipient or more");
|
|
476
|
+
const to = list.map((recipient) => address(recipient, false));
|
|
477
|
+
const allowPlaintextAuth = options.allowPlaintextAuth === true;
|
|
478
|
+
const { size } = options;
|
|
479
|
+
if (size !== undefined && (!Number.isSafeInteger(size) || size < 0))
|
|
480
|
+
throw invalid(`size must be a number of bytes, not ${size}`);
|
|
481
|
+
const ca = options.ca === undefined ? undefined : [options.ca].flat();
|
|
482
|
+
const auth = authOf(options.auth);
|
|
483
|
+
return {
|
|
484
|
+
from,
|
|
485
|
+
to,
|
|
486
|
+
helo: heloOf(options.helo, byMx(options)),
|
|
487
|
+
tls: tlsOf(options, allowPlaintextAuth),
|
|
488
|
+
secure: options.secure === true,
|
|
489
|
+
...ca ? { ca } : {},
|
|
490
|
+
...auth ? { auth } : {},
|
|
491
|
+
allowPlaintextAuth,
|
|
492
|
+
...size === undefined ? {} : { size },
|
|
493
|
+
smtputf8: options.smtputf8 === true || [from, ...to].some((text) => /[^ -~]/.test(text)),
|
|
494
|
+
normalizeLineEnds: options.normalizeLineEnds === true,
|
|
495
|
+
timeouts: timeoutsOf(options.timeouts),
|
|
496
|
+
deadline: seconds("deadline", options.deadline, 1800)
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
var byMx = (options) => !(("host" in options) && options.host !== undefined);
|
|
500
|
+
function portOf(port, fallback) {
|
|
501
|
+
if (port === undefined)
|
|
502
|
+
return fallback;
|
|
503
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
504
|
+
throw invalid(`port must be an integer from 1 to 65535, not ${port}`);
|
|
505
|
+
return port;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// src/client/inbox.ts
|
|
509
|
+
var MAX_REPLY_BYTES = 1024 * 1024;
|
|
510
|
+
|
|
511
|
+
class Inbox {
|
|
512
|
+
#lines = new LineSplitter;
|
|
513
|
+
#reader = new ReplyReader;
|
|
514
|
+
#replies = [];
|
|
515
|
+
#received = 0;
|
|
516
|
+
push(chunk) {
|
|
517
|
+
this.#received += chunk.length;
|
|
518
|
+
if (this.#received > MAX_REPLY_BYTES) {
|
|
519
|
+
return `more than ${MAX_REPLY_BYTES} bytes of replies`;
|
|
520
|
+
}
|
|
521
|
+
this.#lines.push(chunk);
|
|
522
|
+
for (let event = this.#lines.next();event; event = this.#lines.next()) {
|
|
523
|
+
if ("tooLong" in event)
|
|
524
|
+
return `a reply line longer than ${MAX_LINE} bytes`;
|
|
525
|
+
const result = this.#reader.line(event.line);
|
|
526
|
+
if (result === undefined)
|
|
527
|
+
continue;
|
|
528
|
+
if ("error" in result)
|
|
529
|
+
return result.error;
|
|
530
|
+
this.#replies.push(result);
|
|
531
|
+
}
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
shift() {
|
|
535
|
+
return this.#replies.shift();
|
|
536
|
+
}
|
|
537
|
+
get holding() {
|
|
538
|
+
return this.#lines.holding || this.#replies.length > 0;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// src/client/target.ts
|
|
543
|
+
var tlsOptions = (tls) => ({
|
|
544
|
+
serverName: tls.servername,
|
|
545
|
+
rejectUnauthorized: tls.verify,
|
|
546
|
+
...tls.ca ? { ca: [...tls.ca] } : {}
|
|
547
|
+
});
|
|
548
|
+
var reason = (error) => error?.code ?? error?.message ?? "the handshake failed";
|
|
549
|
+
|
|
550
|
+
class Clock {
|
|
551
|
+
seconds;
|
|
552
|
+
#end;
|
|
553
|
+
constructor(seconds) {
|
|
554
|
+
this.seconds = seconds;
|
|
555
|
+
this.#end = performance.now() + seconds * 1000;
|
|
556
|
+
}
|
|
557
|
+
async race(promise, ms, expire) {
|
|
558
|
+
const left = this.#end - performance.now();
|
|
559
|
+
const deadline = left < ms;
|
|
560
|
+
ms = Math.min(ms, left);
|
|
561
|
+
if (ms <= 0)
|
|
562
|
+
throw expire(deadline);
|
|
563
|
+
let timer;
|
|
564
|
+
const expired = new Promise((_, reject) => {
|
|
565
|
+
timer = setTimeout(() => reject(expire(deadline)), ms);
|
|
566
|
+
});
|
|
567
|
+
try {
|
|
568
|
+
return await Promise.race([promise, expired]);
|
|
569
|
+
} finally {
|
|
570
|
+
clearTimeout(timer);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// src/client/socket.ts
|
|
576
|
+
class ClientSocket {
|
|
577
|
+
host;
|
|
578
|
+
#clock;
|
|
579
|
+
#socket;
|
|
580
|
+
#outgoing = new Outgoing(() => 0);
|
|
581
|
+
#tls = false;
|
|
582
|
+
#inbox = new Inbox;
|
|
583
|
+
#failure;
|
|
584
|
+
#closed = false;
|
|
585
|
+
#wake;
|
|
586
|
+
#handshaking;
|
|
587
|
+
#gone = Promise.withResolvers();
|
|
588
|
+
secure = false;
|
|
589
|
+
verified = false;
|
|
590
|
+
constructor(host, clock) {
|
|
591
|
+
this.host = host;
|
|
592
|
+
this.#clock = clock;
|
|
593
|
+
}
|
|
594
|
+
static async open(endpoint, tls, seconds, clock) {
|
|
595
|
+
const client = new ClientSocket(endpoint.host, clock);
|
|
596
|
+
client.#tls = tls !== undefined;
|
|
597
|
+
const handshake = tls ? client.#expectHandshake(tls) : undefined;
|
|
598
|
+
handshake?.catch(() => {});
|
|
599
|
+
const { address, port } = endpoint;
|
|
600
|
+
const connecting = Bun.connect({
|
|
601
|
+
hostname: address,
|
|
602
|
+
port,
|
|
603
|
+
...tls ? { tls: tlsOptions(tls) } : {},
|
|
604
|
+
socket: client.#handlers(tls !== undefined)
|
|
605
|
+
});
|
|
606
|
+
try {
|
|
607
|
+
client.#use(await client.#race(connecting, seconds, "the connection"));
|
|
608
|
+
} catch (error) {
|
|
609
|
+
connecting.then((socket) => socket.terminate(), () => {});
|
|
610
|
+
if (error instanceof SmtpError2)
|
|
611
|
+
throw error;
|
|
612
|
+
throw new SmtpError2("CONNECTION_FAILED", `Could not connect to ${endpoint.host} (${address}:${port}): ${reason(error)}`);
|
|
613
|
+
}
|
|
614
|
+
if (handshake) {
|
|
615
|
+
await client.#race(handshake, seconds, "the TLS handshake").catch((e) => {
|
|
616
|
+
client.close(true);
|
|
617
|
+
throw e;
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
return client;
|
|
621
|
+
}
|
|
622
|
+
#use(socket) {
|
|
623
|
+
this.#socket = socket;
|
|
624
|
+
this.#outgoing = new Outgoing((bytes) => socket.write(bytes));
|
|
625
|
+
}
|
|
626
|
+
#handlers(tls) {
|
|
627
|
+
const live = (run) => (...args) => {
|
|
628
|
+
if (this.#tls === tls)
|
|
629
|
+
run(...args);
|
|
630
|
+
};
|
|
631
|
+
return {
|
|
632
|
+
data: live((_, chunk) => this.#receive(chunk)),
|
|
633
|
+
drain: live(() => this.#outgoing.drain()),
|
|
634
|
+
close: live(() => this.#lost()),
|
|
635
|
+
error: live(() => this.#lost()),
|
|
636
|
+
handshake: live((_, ok, error) => this.#handshaking?.(ok, error))
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
#expectHandshake(tls) {
|
|
640
|
+
return new Promise((resolve, reject) => {
|
|
641
|
+
this.#handshaking = (ok, error) => {
|
|
642
|
+
this.#handshaking = undefined;
|
|
643
|
+
if (this.#closed || tls.verify && !ok) {
|
|
644
|
+
const why = this.#closed ? "the connection closed" : reason(error);
|
|
645
|
+
return reject(this.#fail(tlsFailed(this.host, why)));
|
|
646
|
+
}
|
|
647
|
+
this.secure = true;
|
|
648
|
+
this.verified = ok && error === null;
|
|
649
|
+
resolve();
|
|
650
|
+
};
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
#receive(chunk) {
|
|
654
|
+
if (this.#failure || this.#closed)
|
|
655
|
+
return;
|
|
656
|
+
const wrong = this.#inbox.push(chunk);
|
|
657
|
+
if (wrong)
|
|
658
|
+
this.#fail(badReply(this.host, wrong));
|
|
659
|
+
else
|
|
660
|
+
this.#wakeUp();
|
|
661
|
+
}
|
|
662
|
+
#wakeUp() {
|
|
663
|
+
const wake = this.#wake;
|
|
664
|
+
this.#wake = undefined;
|
|
665
|
+
wake?.();
|
|
666
|
+
}
|
|
667
|
+
#lost() {
|
|
668
|
+
this.#closed = true;
|
|
669
|
+
this.#gone.resolve();
|
|
670
|
+
this.#outgoing.clear();
|
|
671
|
+
this.#handshaking?.(false, null);
|
|
672
|
+
this.#wakeUp();
|
|
673
|
+
}
|
|
674
|
+
#fail(error) {
|
|
675
|
+
this.#failure ??= error;
|
|
676
|
+
this.#socket?.terminate();
|
|
677
|
+
this.#lost();
|
|
678
|
+
return this.#failure;
|
|
679
|
+
}
|
|
680
|
+
#race(promise, seconds, what, start = performance.now()) {
|
|
681
|
+
const ms = start + seconds * 1000 - performance.now();
|
|
682
|
+
const { host } = this;
|
|
683
|
+
const deadline = this.#clock.seconds;
|
|
684
|
+
return this.#clock.race(promise, ms, (passed) => this.#fail(timedOut(host, what, seconds, passed ? deadline : undefined)));
|
|
685
|
+
}
|
|
686
|
+
async reply(what, seconds) {
|
|
687
|
+
const start = performance.now();
|
|
688
|
+
for (;; ) {
|
|
689
|
+
const next = this.#inbox.shift();
|
|
690
|
+
if (next)
|
|
691
|
+
return next;
|
|
692
|
+
if (this.#failure)
|
|
693
|
+
throw this.#failure;
|
|
694
|
+
if (this.#closed)
|
|
695
|
+
throw this.#fail(lost(this.host, what));
|
|
696
|
+
await this.#race(new Promise((wake) => {
|
|
697
|
+
this.#wake = wake;
|
|
698
|
+
}), seconds, what, start);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
within(promise, seconds, what) {
|
|
702
|
+
const gone = this.#gone.promise.then(() => {
|
|
703
|
+
throw this.#hungUp(lost(this.host, what));
|
|
704
|
+
});
|
|
705
|
+
return this.#race(Promise.race([promise, gone]), seconds, what);
|
|
706
|
+
}
|
|
707
|
+
write(data) {
|
|
708
|
+
if (this.#failure || this.#closed)
|
|
709
|
+
return;
|
|
710
|
+
this.#outgoing.write(typeof data === "string" ? new TextEncoder().encode(data) : data);
|
|
711
|
+
}
|
|
712
|
+
async drained(seconds) {
|
|
713
|
+
await this.#race(this.#outgoing.drained(), seconds, "the server to take the message");
|
|
714
|
+
if (this.#failure || this.#closed)
|
|
715
|
+
throw this.#hungUp(lost(this.host));
|
|
716
|
+
}
|
|
717
|
+
#hungUp(otherwise) {
|
|
718
|
+
if (this.#failure)
|
|
719
|
+
return this.#failure;
|
|
720
|
+
const said = this.#inbox.shift();
|
|
721
|
+
return this.#fail(said ? refused(this.host, "the message", said) : otherwise);
|
|
722
|
+
}
|
|
723
|
+
async startTls(tls, seconds) {
|
|
724
|
+
const socket = this.#socket;
|
|
725
|
+
if (this.#inbox.holding || !socket) {
|
|
726
|
+
throw this.#fail(badReply(this.host, "more after its 220 to STARTTLS"));
|
|
727
|
+
}
|
|
728
|
+
const handshake = this.#expectHandshake(tls);
|
|
729
|
+
handshake.catch(() => {});
|
|
730
|
+
this.#tls = true;
|
|
731
|
+
const [, encrypted] = socket.upgradeTLS({
|
|
732
|
+
tls: tlsOptions(tls),
|
|
733
|
+
socket: this.#handlers(true)
|
|
734
|
+
});
|
|
735
|
+
this.#use(encrypted);
|
|
736
|
+
await this.#race(handshake, seconds, "the TLS handshake");
|
|
737
|
+
}
|
|
738
|
+
close(now = false) {
|
|
739
|
+
this.#closed = true;
|
|
740
|
+
if (now)
|
|
741
|
+
this.#socket?.terminate();
|
|
742
|
+
else
|
|
743
|
+
this.#socket?.end();
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// src/client/transaction.ts
|
|
748
|
+
var missing = (host, extension, why) => new SmtpError2("EXTENSION_MISSING", `${host} does not offer ${extension}, which ${why}`);
|
|
749
|
+
function mailParameters(host, settings, content, extensions) {
|
|
750
|
+
let parameters = "";
|
|
751
|
+
const size = extensions.get("SIZE");
|
|
752
|
+
if (size !== undefined && content.size !== undefined) {
|
|
753
|
+
const max = Number(size);
|
|
754
|
+
if (Number.isSafeInteger(max) && max > 0 && content.size > max) {
|
|
755
|
+
throw new SmtpError2("MESSAGE_TOO_BIG", `The message (${content.size} bytes) is larger than the ${max} bytes ${host} takes (SIZE)`, { temporary: false });
|
|
756
|
+
}
|
|
757
|
+
parameters += ` SIZE=${content.size}`;
|
|
758
|
+
}
|
|
759
|
+
if (extensions.has("8BITMIME"))
|
|
760
|
+
parameters += " BODY=8BITMIME";
|
|
761
|
+
else if (content.eightBit) {
|
|
762
|
+
throw missing(host, "8BITMIME", "the message needs: it has 8-bit bytes");
|
|
763
|
+
}
|
|
764
|
+
if (settings.smtputf8) {
|
|
765
|
+
if (!extensions.has("SMTPUTF8")) {
|
|
766
|
+
throw missing(host, "SMTPUTF8", "an address that is not ASCII needs (or smtputf8 asked for)");
|
|
767
|
+
}
|
|
768
|
+
parameters += " SMTPUTF8";
|
|
769
|
+
}
|
|
770
|
+
return parameters;
|
|
771
|
+
}
|
|
772
|
+
async function envelope(socket, settings, content, ready) {
|
|
773
|
+
const { host } = socket;
|
|
774
|
+
const { timeouts } = settings;
|
|
775
|
+
const mail = `MAIL FROM:<${settings.from}>${mailParameters(host, settings, content, ready.extensions)}\r
|
|
776
|
+
`;
|
|
777
|
+
const rcpts = settings.to.map((to) => `RCPT TO:<${to}>\r
|
|
778
|
+
`);
|
|
779
|
+
const pipelining = ready.extensions.has("PIPELINING");
|
|
780
|
+
socket.write(pipelining ? mail + rcpts.join("") : mail);
|
|
781
|
+
const from = await socket.reply("the reply to MAIL FROM", timeouts.mail);
|
|
782
|
+
if (from.code !== 250)
|
|
783
|
+
throw refused(host, "the sender", from);
|
|
784
|
+
const accepted = [];
|
|
785
|
+
const rejected = [];
|
|
786
|
+
for (const [i, recipient] of settings.to.entries()) {
|
|
787
|
+
if (!pipelining)
|
|
788
|
+
socket.write(rcpts[i]);
|
|
789
|
+
const reply = await socket.reply("the reply to RCPT TO", timeouts.rcpt);
|
|
790
|
+
const into = reply.code === 250 || reply.code === 251 ? accepted : rejected;
|
|
791
|
+
into.push({ recipient, reply });
|
|
792
|
+
}
|
|
793
|
+
if (accepted.length === 0) {
|
|
794
|
+
const first = rejected[0];
|
|
795
|
+
throw new SmtpError2("RECIPIENTS_REFUSED", `${host} refused every recipient, ${first.recipient} with ${shown(first.reply)}`, {
|
|
796
|
+
reply: first.reply,
|
|
797
|
+
rejected,
|
|
798
|
+
temporary: rejected.some(({ reply }) => reply.code < 500)
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
return { accepted, rejected };
|
|
802
|
+
}
|
|
803
|
+
async function data(socket, settings, content, ready) {
|
|
804
|
+
const { host } = socket;
|
|
805
|
+
const { timeouts } = settings;
|
|
806
|
+
socket.write(`DATA\r
|
|
807
|
+
`);
|
|
808
|
+
const start = await socket.reply("the reply to DATA", timeouts.dataStart);
|
|
809
|
+
if (start.code !== 354)
|
|
810
|
+
throw refused(host, "DATA", start);
|
|
811
|
+
const writer = new DataWriter(settings.normalizeLineEnds);
|
|
812
|
+
const eightBitOk = ready.extensions.has("8BITMIME");
|
|
813
|
+
const chunks = content.chunks();
|
|
814
|
+
let tail;
|
|
815
|
+
try {
|
|
816
|
+
for (;; ) {
|
|
817
|
+
const chunk = await socket.within(chunks.next(), timeouts.dataBlock, "the next part of the message");
|
|
818
|
+
if (!chunk)
|
|
819
|
+
break;
|
|
820
|
+
const bytes = writer.write(chunk);
|
|
821
|
+
if (writer.bareLineBreaks > 0)
|
|
822
|
+
throw bareLineBreak();
|
|
823
|
+
if (writer.eightBit && !eightBitOk) {
|
|
824
|
+
throw missing(host, "8BITMIME", "the message needs: it has 8-bit bytes");
|
|
825
|
+
}
|
|
826
|
+
socket.write(bytes);
|
|
827
|
+
await socket.drained(timeouts.dataBlock);
|
|
828
|
+
}
|
|
829
|
+
tail = writer.end();
|
|
830
|
+
if (writer.bareLineBreaks > 0)
|
|
831
|
+
throw bareLineBreak();
|
|
832
|
+
} catch (error) {
|
|
833
|
+
chunks.cancel();
|
|
834
|
+
socket.close(true);
|
|
835
|
+
throw error;
|
|
836
|
+
}
|
|
837
|
+
socket.write(tail);
|
|
838
|
+
const end = await socket.reply("the reply to the final dot", timeouts.dataEnd);
|
|
839
|
+
if (end.code !== 250)
|
|
840
|
+
throw refused(host, "the message", end);
|
|
841
|
+
return end;
|
|
842
|
+
}
|
|
843
|
+
async function transact(socket, settings, content, ready) {
|
|
844
|
+
const { accepted, rejected } = await envelope(socket, settings, content, ready);
|
|
845
|
+
const reply = await data(socket, settings, content, ready);
|
|
846
|
+
return { accepted, rejected, reply };
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// src/client/send.ts
|
|
850
|
+
var MAX_ADDRESSES = 10;
|
|
851
|
+
var QUIT_WAIT = 5;
|
|
852
|
+
var early = new WeakSet;
|
|
853
|
+
async function attempt(endpoint, settings, content, clock) {
|
|
854
|
+
const tls = {
|
|
855
|
+
servername: endpoint.host,
|
|
856
|
+
verify: settings.tls === "required",
|
|
857
|
+
...settings.ca ? { ca: settings.ca } : {}
|
|
858
|
+
};
|
|
859
|
+
let socket;
|
|
860
|
+
try {
|
|
861
|
+
socket = await ClientSocket.open(endpoint, settings.secure ? tls : undefined, settings.timeouts.connect, clock);
|
|
862
|
+
} catch (error) {
|
|
863
|
+
early.add(error);
|
|
864
|
+
throw error;
|
|
865
|
+
}
|
|
866
|
+
let quit = false;
|
|
867
|
+
try {
|
|
868
|
+
const ready = await open(socket, settings, tls).catch((error) => {
|
|
869
|
+
early.add(error);
|
|
870
|
+
throw error;
|
|
871
|
+
});
|
|
872
|
+
const delivered = await transact(socket, settings, content, ready);
|
|
873
|
+
socket.write(`QUIT\r
|
|
874
|
+
`);
|
|
875
|
+
quit = true;
|
|
876
|
+
await socket.reply("the reply to QUIT", Math.min(QUIT_WAIT, settings.timeouts.command)).catch(() => {});
|
|
877
|
+
return {
|
|
878
|
+
...delivered,
|
|
879
|
+
host: endpoint.host,
|
|
880
|
+
port: endpoint.port,
|
|
881
|
+
tls: socket.secure ? { verified: socket.verified } : false,
|
|
882
|
+
authenticated: ready.authenticated
|
|
883
|
+
};
|
|
884
|
+
} finally {
|
|
885
|
+
if (!quit)
|
|
886
|
+
socket.write(`QUIT\r
|
|
887
|
+
`);
|
|
888
|
+
socket.close();
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
var withinDeadline = (lookup, clock, name, what) => clock.race(lookup, Number.POSITIVE_INFINITY, () => timedOut(name, what, clock.seconds, clock.seconds));
|
|
892
|
+
async function viaMx(options, settings, content, clock) {
|
|
893
|
+
const { domain, resolver } = options;
|
|
894
|
+
if (typeof domain !== "string" || domain === "")
|
|
895
|
+
throw invalid("domain must be a domain name");
|
|
896
|
+
if (typeof resolver?.mx !== "function")
|
|
897
|
+
throw invalid("resolver must be a Resolver, such as @bumail/dns gives");
|
|
898
|
+
const port = portOf(options.port, 25);
|
|
899
|
+
let last;
|
|
900
|
+
let dnsError;
|
|
901
|
+
let tried = 0;
|
|
902
|
+
let looked = 0;
|
|
903
|
+
const mailHosts = await withinDeadline(resolveMx(domain, resolver), clock, domain, "the MX lookup");
|
|
904
|
+
hosts:
|
|
905
|
+
for (const mx of mailHosts) {
|
|
906
|
+
if (tried >= MAX_ADDRESSES || looked++ >= MAX_ADDRESSES)
|
|
907
|
+
break;
|
|
908
|
+
const addresses = await withinDeadline(addressesOf(mx.host, resolver), clock, mx.host, "the address lookup");
|
|
909
|
+
if (!Array.isArray(addresses)) {
|
|
910
|
+
dnsError = addresses.error;
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
for (const address of addresses) {
|
|
914
|
+
if (tried++ >= MAX_ADDRESSES)
|
|
915
|
+
break hosts;
|
|
916
|
+
try {
|
|
917
|
+
return await attempt({ host: mx.host, address, port }, settings, content, clock);
|
|
918
|
+
} catch (error) {
|
|
919
|
+
const next = error instanceof SmtpError2 && error.temporary && early.has(error);
|
|
920
|
+
if (!next)
|
|
921
|
+
throw error;
|
|
922
|
+
last = error;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
throw last ?? noAddress(domain, dnsError);
|
|
927
|
+
}
|
|
928
|
+
async function sendMail(message, options) {
|
|
929
|
+
const settings = settingsOf(options);
|
|
930
|
+
const content = contentOf(message, settings.normalizeLineEnds, settings.size);
|
|
931
|
+
const clock = new Clock(settings.deadline);
|
|
932
|
+
if (!byMx(options)) {
|
|
933
|
+
const { host } = options;
|
|
934
|
+
if (typeof host !== "string" || !/^[^\s/]+$/.test(host))
|
|
935
|
+
throw invalid(`host must be a host name or an address, not ${host}`);
|
|
936
|
+
const port = portOf(options.port, settings.secure ? 465 : 25);
|
|
937
|
+
return attempt({ host, address: host, port }, settings, content, clock);
|
|
938
|
+
}
|
|
939
|
+
return viaMx(options, settings, content, clock);
|
|
940
|
+
}
|
|
941
|
+
export {
|
|
942
|
+
SmtpError2 as SmtpError,
|
|
943
|
+
resolveMx,
|
|
944
|
+
sendMail
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
//# debugId=A7806B344FA2670664756E2164756E21
|
|
948
|
+
//# sourceMappingURL=index.js.map
|