@sandsoftwaresolutions/mail-kit 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +90 -2
- package/dist/address.cjs +132 -0
- package/dist/errors.cjs +13 -0
- package/dist/index.cjs +35 -0
- package/dist/index.d.cts +166 -0
- package/dist/lint.cjs +34 -0
- package/dist/mailer.cjs +69 -0
- package/dist/message.cjs +194 -0
- package/dist/preview.cjs +15 -0
- package/dist/smtp.cjs +278 -0
- package/dist/template.cjs +206 -0
- package/dist/text.cjs +80 -0
- package/dist/transports.cjs +118 -0
- package/package.json +56 -4
- package/src/address.js +123 -0
- package/src/errors.js +10 -0
- package/src/index.d.ts +166 -0
- package/src/index.js +14 -0
- package/src/lint.js +31 -0
- package/src/mailer.js +66 -0
- package/src/message.js +190 -0
- package/src/preview.js +12 -0
- package/src/smtp.js +275 -0
- package/src/template.js +201 -0
- package/src/text.js +74 -0
- package/src/transports.js +113 -0
package/dist/message.cjs
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const { randomBytes } = require("node:crypto");
|
|
3
|
+
|
|
4
|
+
const { encodeWords, formatAddress, formatAddressList, parseAddress, parseAddressList } = require("./address.cjs");
|
|
5
|
+
const { MailError } = require("./errors.cjs");
|
|
6
|
+
const { htmlToText } = require("./text.cjs");
|
|
7
|
+
|
|
8
|
+
const RESERVED = new Set(["from", "to", "cc", "bcc", "subject", "date", "message-id", "mime-version", "content-type", "content-transfer-encoding", "reply-to", "sender", "return-path"]);
|
|
9
|
+
const MAX_RECIPIENTS = 50;
|
|
10
|
+
|
|
11
|
+
const fail = (message, code = "invalid_message") => new MailError(message, { code });
|
|
12
|
+
|
|
13
|
+
/** Quoted-printable (RFC 2045) of a text: lines of at most 76 characters, CRLF line breaks. */
|
|
14
|
+
function quotedPrintable(text) {
|
|
15
|
+
const bytes = Buffer.from(String(text).replace(/\r\n?|\n/g, "\n"), "utf8");
|
|
16
|
+
const lines = [];
|
|
17
|
+
let line = "";
|
|
18
|
+
let tokens = [];
|
|
19
|
+
const flush = () => {
|
|
20
|
+
if (tokens.length) {
|
|
21
|
+
const last = tokens[tokens.length - 1];
|
|
22
|
+
if (last === " " || last === "\t") tokens[tokens.length - 1] = "=" + last.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0");
|
|
23
|
+
}
|
|
24
|
+
let width = 0;
|
|
25
|
+
line = "";
|
|
26
|
+
for (const t of tokens) {
|
|
27
|
+
if (width + t.length > 75) {
|
|
28
|
+
lines.push(line + "=");
|
|
29
|
+
line = "";
|
|
30
|
+
width = 0;
|
|
31
|
+
}
|
|
32
|
+
line += t;
|
|
33
|
+
width += t.length;
|
|
34
|
+
}
|
|
35
|
+
lines.push(line);
|
|
36
|
+
tokens = [];
|
|
37
|
+
};
|
|
38
|
+
for (const b of bytes) {
|
|
39
|
+
if (b === 10) flush();
|
|
40
|
+
else if ((b >= 33 && b <= 126 && b !== 61) || b === 32 || b === 9) tokens.push(String.fromCharCode(b));
|
|
41
|
+
else tokens.push("=" + b.toString(16).toUpperCase().padStart(2, "0"));
|
|
42
|
+
}
|
|
43
|
+
flush();
|
|
44
|
+
return lines.join("\r\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const base64Lines = (buffer) => (buffer.toString("base64").match(/.{1,76}/g) ?? []).join("\r\n");
|
|
48
|
+
const boundary = () => `=_ss_${randomBytes(12).toString("hex")}`;
|
|
49
|
+
const needsEncoding = (name) => !/^[\x20-\x7e]*$/.test(name);
|
|
50
|
+
const quote = (value) => `"${value.replace(/(["\\])/g, "\\$1")}"`;
|
|
51
|
+
|
|
52
|
+
const extended = (name) => `UTF-8''${encodeURIComponent(name).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)}`;
|
|
53
|
+
// ASCII names are plain quoted parameters; others use RFC 2231 (`name*=UTF-8''...`), which Gmail, Outlook, Apple Mail and Thunderbird read.
|
|
54
|
+
const param = (key, name) => (needsEncoding(name) ? `${key}*=${extended(name)}` : `${key}=${quote(name)}`);
|
|
55
|
+
|
|
56
|
+
function textPart(contentType, body) {
|
|
57
|
+
return `Content-Type: ${contentType}; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n${quotedPrintable(body)}\r\n`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function multipart(type, parts) {
|
|
61
|
+
const b = boundary();
|
|
62
|
+
return { header: `Content-Type: multipart/${type}; boundary="${b}"`, body: `${parts.map((p) => `--${b}\r\n${p}`).join("")}--${b}--\r\n` };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A child part is a string that starts with its own headers (it is already a full MIME entity).
|
|
66
|
+
const entity = (m) => `${m.header}\r\n\r\n${m.body}`;
|
|
67
|
+
|
|
68
|
+
function attachmentPart(a) {
|
|
69
|
+
const disposition = a.cid ? "inline" : "attachment";
|
|
70
|
+
return (
|
|
71
|
+
`Content-Type: ${a.contentType}; ${param("name", a.filename)}\r\n` +
|
|
72
|
+
`Content-Transfer-Encoding: base64\r\n` +
|
|
73
|
+
`Content-Disposition: ${disposition}; ${param("filename", a.filename)}\r\n` +
|
|
74
|
+
(a.cid ? `Content-ID: <${a.cid}>\r\n` : "") +
|
|
75
|
+
`\r\n${base64Lines(a.content)}\r\n`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function checkHeaderValue(name, value) {
|
|
80
|
+
if (typeof value !== "string" || /[\r\n\0]/.test(value)) throw fail(`Header ${name} must be a single line of text`, "invalid_header");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeAttachment(a, index) {
|
|
84
|
+
if (!a || typeof a !== "object") throw fail(`attachments[${index}] must be an object`);
|
|
85
|
+
const content = typeof a.content === "string" ? Buffer.from(a.content, a.encoding === "base64" ? "base64" : "utf8") : a.content;
|
|
86
|
+
if (!Buffer.isBuffer(content) && !(content instanceof Uint8Array)) throw fail(`attachments[${index}].content must be a Buffer or string`);
|
|
87
|
+
const filename = String(a.filename ?? "").replace(/[\r\n\0/\\]/g, "_").trim();
|
|
88
|
+
if (!filename) throw fail(`attachments[${index}].filename is required`);
|
|
89
|
+
const contentType = String(a.contentType ?? "application/octet-stream");
|
|
90
|
+
if (!/^[\w.+-]+\/[\w.+-]+$/.test(contentType)) throw fail(`attachments[${index}].contentType is not valid`);
|
|
91
|
+
const cid = a.cid === undefined ? undefined : String(a.cid);
|
|
92
|
+
if (cid !== undefined && !/^[\w.@+-]{1,200}$/.test(cid)) throw fail(`attachments[${index}].cid is not valid`);
|
|
93
|
+
return { filename, content: Buffer.from(content), contentType, cid };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Checks and normalizes a message. The result has everything a transport needs, and `raw` (a Buffer with the
|
|
98
|
+
* complete RFC 5322 message) is built on first use.
|
|
99
|
+
*/
|
|
100
|
+
function prepareMessage(input, defaults = {}) {
|
|
101
|
+
if (!input || typeof input !== "object") throw fail("A message must be an object");
|
|
102
|
+
const merged = { ...defaults, ...input, headers: { ...defaults.headers, ...input.headers } };
|
|
103
|
+
const from = parseAddress(merged.from ?? (() => { throw fail("from is required", "missing_from"); })());
|
|
104
|
+
const to = parseAddressList(merged.to);
|
|
105
|
+
const cc = parseAddressList(merged.cc);
|
|
106
|
+
const bcc = parseAddressList(merged.bcc);
|
|
107
|
+
const replyTo = parseAddressList(merged.replyTo);
|
|
108
|
+
const unique = new Map();
|
|
109
|
+
for (const a of [...to, ...cc, ...bcc]) if (!unique.has(a.address.toLowerCase())) unique.set(a.address.toLowerCase(), a.address);
|
|
110
|
+
const total = new Set(unique.values());
|
|
111
|
+
if (!total.size) throw fail("At least one recipient (to, cc or bcc) is required", "no_recipients");
|
|
112
|
+
if (total.size > (merged.maxRecipients ?? MAX_RECIPIENTS)) throw fail(`Too many recipients (${total.size}); send in smaller batches or raise maxRecipients`, "too_many_recipients");
|
|
113
|
+
|
|
114
|
+
const subject = String(merged.subject ?? "").replace(/[\t ]+/g, " ").trim();
|
|
115
|
+
if (!subject) throw fail("subject is required", "missing_subject");
|
|
116
|
+
if (/[\r\n\0]/.test(subject)) throw fail("subject may not contain line breaks", "invalid_header");
|
|
117
|
+
|
|
118
|
+
let html = merged.html === undefined ? undefined : String(merged.html);
|
|
119
|
+
let text = merged.text === undefined ? undefined : String(merged.text);
|
|
120
|
+
if (html === undefined && text === undefined) throw fail("Give the message a text or html body", "empty_body");
|
|
121
|
+
if (text === undefined) text = htmlToText(html);
|
|
122
|
+
|
|
123
|
+
const attachments = (merged.attachments ?? []).map(normalizeAttachment);
|
|
124
|
+
const headers = {};
|
|
125
|
+
for (const [name, value] of Object.entries(merged.headers)) {
|
|
126
|
+
if (value === undefined || value === null) continue;
|
|
127
|
+
if (!/^[A-Za-z0-9-]{1,60}$/.test(name)) throw fail(`Header name not allowed: ${name.slice(0, 40)}`, "invalid_header");
|
|
128
|
+
if (RESERVED.has(name.toLowerCase())) throw fail(`Set ${name} with its own option, not in headers`, "invalid_header");
|
|
129
|
+
checkHeaderValue(name, String(value));
|
|
130
|
+
headers[name] = String(value);
|
|
131
|
+
}
|
|
132
|
+
if (merged.autoSubmitted !== false && !Object.keys(headers).some((n) => n.toLowerCase() === "auto-submitted")) headers["Auto-Submitted"] = "auto-generated";
|
|
133
|
+
if (merged.unsubscribe) {
|
|
134
|
+
const { url, mailto } = merged.unsubscribe;
|
|
135
|
+
const items = [];
|
|
136
|
+
if (url) {
|
|
137
|
+
if (!/^https:\/\/[^\s<>]+$/.test(url)) throw fail("unsubscribe.url must be an https URL", "invalid_header");
|
|
138
|
+
items.push(`<${url}>`);
|
|
139
|
+
headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click";
|
|
140
|
+
}
|
|
141
|
+
if (mailto) items.push(`<mailto:${parseAddress(mailto).address}>`);
|
|
142
|
+
if (!items.length) throw fail("unsubscribe needs a url or mailto", "invalid_header");
|
|
143
|
+
headers["List-Unsubscribe"] = items.join(", ");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const messageId = merged.messageId ?? `<${randomBytes(16).toString("hex")}@${from.address.split("@")[1]}>`;
|
|
147
|
+
if (!/^<[^<>\s@]+@[^<>\s@]+>$/.test(messageId)) throw fail("messageId must look like <id@domain>", "invalid_header");
|
|
148
|
+
const date = merged.date instanceof Date ? merged.date : new Date();
|
|
149
|
+
const envelopeFrom = merged.envelopeFrom ? parseAddress(merged.envelopeFrom).address : from.address;
|
|
150
|
+
const envelopeTo = [...total];
|
|
151
|
+
|
|
152
|
+
const message = {
|
|
153
|
+
from, to, cc, bcc, replyTo, subject, html, text, headers, attachments, messageId, date,
|
|
154
|
+
envelope: { from: envelopeFrom, to: envelopeTo },
|
|
155
|
+
};
|
|
156
|
+
let raw;
|
|
157
|
+
Object.defineProperty(message, "raw", { enumerable: false, get: () => (raw ??= buildRaw(message)) });
|
|
158
|
+
return message;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildRaw(m) {
|
|
162
|
+
const head = [];
|
|
163
|
+
head.push(`From: ${formatAddress(m.from)}`);
|
|
164
|
+
if (m.to.length) head.push(`To: ${formatAddressList(m.to)}`);
|
|
165
|
+
if (m.cc.length) head.push(`Cc: ${formatAddressList(m.cc)}`);
|
|
166
|
+
if (m.replyTo.length) head.push(`Reply-To: ${formatAddressList(m.replyTo)}`);
|
|
167
|
+
head.push(`Subject: ${encodeWords(m.subject)}`);
|
|
168
|
+
head.push(`Date: ${m.date.toUTCString().replace("GMT", "+0000")}`);
|
|
169
|
+
head.push(`Message-ID: ${m.messageId}`);
|
|
170
|
+
head.push("MIME-Version: 1.0");
|
|
171
|
+
for (const [name, value] of Object.entries(m.headers)) head.push(`${name}: ${encodeWords(value)}`);
|
|
172
|
+
|
|
173
|
+
const inline = m.attachments.filter((a) => a.cid);
|
|
174
|
+
const regular = m.attachments.filter((a) => !a.cid);
|
|
175
|
+
let body;
|
|
176
|
+
if (m.html === undefined) body = { header: null, text: m.text };
|
|
177
|
+
else {
|
|
178
|
+
const htmlEntity = inline.length
|
|
179
|
+
? multipart("related", [textPart("text/html", m.html), ...inline.map(attachmentPart)])
|
|
180
|
+
: null;
|
|
181
|
+
const htmlPart = htmlEntity ? entity(htmlEntity) + "\r\n" : textPart("text/html", m.html);
|
|
182
|
+
body = multipart("alternative", [textPart("text/plain", m.text), htmlPart]);
|
|
183
|
+
}
|
|
184
|
+
let top;
|
|
185
|
+
if (regular.length) {
|
|
186
|
+
const first = body.header ? entity(body) + "\r\n" : textPart("text/plain", m.text);
|
|
187
|
+
top = multipart("mixed", [first, ...regular.map(attachmentPart)]);
|
|
188
|
+
} else if (body.header) top = body;
|
|
189
|
+
else top = { header: "Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable", body: `${quotedPrintable(m.text)}\r\n` };
|
|
190
|
+
return Buffer.from(`${head.join("\r\n")}\r\n${top.header}\r\n\r\n${top.body}`, "latin1");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
exports.quotedPrintable = quotedPrintable;
|
|
194
|
+
exports.prepareMessage = prepareMessage;
|
package/dist/preview.cjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const { escapeHtml } = require("./text.cjs");
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One HTML page that shows a mail the way you want to review it: subject, the rendered mail (narrow and wide) and
|
|
6
|
+
* the plain-text version. Write it to a file and open it in a browser: `writeFileSync("mail.html", preview(mail))`.
|
|
7
|
+
*/
|
|
8
|
+
function preview(mail) {
|
|
9
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Preview: ${escapeHtml(mail.subject)}</title>
|
|
10
|
+
<style>body{font:15px system-ui,sans-serif;margin:0;padding:24px;background:#e7eaee;color:#1f2933}h1{font-size:18px;margin:0 0 4px}.meta{color:#6b7785;margin:0 0 20px}.row{display:flex;gap:20px;flex-wrap:wrap;align-items:flex-start}iframe{border:0;background:#fff;box-shadow:0 1px 4px #0003;height:760px}pre{background:#fff;padding:16px;white-space:pre-wrap;max-width:560px;box-shadow:0 1px 4px #0003}</style></head><body>
|
|
11
|
+
<h1>${escapeHtml(mail.subject)}</h1><p class="meta">HTML (wide and phone width) and the text version</p>
|
|
12
|
+
<div class="row"><iframe style="width:680px" srcdoc="${escapeHtml(mail.html ?? "")}"></iframe><iframe style="width:380px" srcdoc="${escapeHtml(mail.html ?? "")}"></iframe><pre>${escapeHtml(mail.text)}</pre></div></body></html>`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
exports.preview = preview;
|
package/dist/smtp.cjs
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const { hostname } = require("node:os");
|
|
3
|
+
const { connect: netConnect } = require("node:net");
|
|
4
|
+
const { connect: tlsConnect } = require("node:tls");
|
|
5
|
+
|
|
6
|
+
const { MailError } = require("./errors.cjs");
|
|
7
|
+
const { prepareMessage } = require("./message.cjs");
|
|
8
|
+
|
|
9
|
+
const LOOPBACK = /^(localhost|127\.\d+\.\d+\.\d+|::1)$/;
|
|
10
|
+
|
|
11
|
+
const networkError = (error, stage) => {
|
|
12
|
+
const code = error?.code;
|
|
13
|
+
if (code === "ERR_TLS_CERT_ALTNAME_INVALID" || /certificate|self.signed|unable to verify/i.test(error?.message ?? "")) {
|
|
14
|
+
return new MailError(`The mail server's certificate was refused: ${error.message}`, { code: "tls_failed", cause: error });
|
|
15
|
+
}
|
|
16
|
+
if (stage === "sent") {
|
|
17
|
+
return new MailError("The connection broke after the message was sent; it may or may not have been delivered", { code: "delivery_unknown", cause: error });
|
|
18
|
+
}
|
|
19
|
+
return new MailError(`Could not talk to the mail server (${code ?? error?.message ?? "error"})`, { code: "connection_failed", retryable: true, cause: error });
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
class Connection {
|
|
23
|
+
constructor(socket, timeoutMs) {
|
|
24
|
+
this.timeoutMs = timeoutMs;
|
|
25
|
+
this.stage = "connect";
|
|
26
|
+
this.bind(socket);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
bind(socket) {
|
|
30
|
+
this.socket = socket;
|
|
31
|
+
this.buffer = "";
|
|
32
|
+
this.lines = [];
|
|
33
|
+
this.queue = [];
|
|
34
|
+
this.waiter = null;
|
|
35
|
+
this.failure = null;
|
|
36
|
+
socket.setTimeout(this.timeoutMs);
|
|
37
|
+
socket.on("data", (chunk) => this.onData(chunk));
|
|
38
|
+
socket.on("error", (error) => this.fail(networkError(error, this.stage)));
|
|
39
|
+
socket.on("close", () => this.fail(networkError(Object.assign(new Error("connection closed"), { code: "ECONNRESET" }), this.stage)));
|
|
40
|
+
socket.on("timeout", () => this.fail(this.stage === "sent"
|
|
41
|
+
? new MailError("The mail server did not answer after the message was sent; it may or may not have been delivered", { code: "delivery_unknown" })
|
|
42
|
+
: new MailError("The mail server did not answer in time", { code: "timeout", retryable: true })));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
onData(chunk) {
|
|
46
|
+
this.buffer += chunk.toString("latin1");
|
|
47
|
+
let index;
|
|
48
|
+
while ((index = this.buffer.indexOf("\n")) >= 0) {
|
|
49
|
+
const line = this.buffer.slice(0, index).replace(/\r$/, "");
|
|
50
|
+
this.buffer = this.buffer.slice(index + 1);
|
|
51
|
+
this.lines.push(line);
|
|
52
|
+
const match = /^(\d{3})([ -]?)/.exec(line);
|
|
53
|
+
if (!match) continue;
|
|
54
|
+
if (match[2] !== "-") {
|
|
55
|
+
this.queue.push({ code: Number(match[1]), lines: this.lines.map((l) => l.slice(4)), text: this.lines.map((l) => l.slice(4)).join("\n") });
|
|
56
|
+
this.lines = [];
|
|
57
|
+
this.flush();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
flush() {
|
|
63
|
+
if (this.waiter && this.queue.length) {
|
|
64
|
+
const { resolve } = this.waiter;
|
|
65
|
+
this.waiter = null;
|
|
66
|
+
resolve(this.queue.shift());
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fail(error) {
|
|
71
|
+
if (this.failure) return;
|
|
72
|
+
this.failure = error;
|
|
73
|
+
if (this.waiter) {
|
|
74
|
+
const { reject } = this.waiter;
|
|
75
|
+
this.waiter = null;
|
|
76
|
+
reject(error);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
reply() {
|
|
81
|
+
if (this.queue.length) return Promise.resolve(this.queue.shift());
|
|
82
|
+
if (this.failure) return Promise.reject(this.failure);
|
|
83
|
+
return new Promise((resolve, reject) => (this.waiter = { resolve, reject }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
write(data) {
|
|
87
|
+
if (this.failure) return;
|
|
88
|
+
this.socket.write(data, "latin1");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async command(line, expect) {
|
|
92
|
+
this.write(`${line}\r\n`);
|
|
93
|
+
const reply = await this.reply();
|
|
94
|
+
if (expect && !expect.includes(reply.code)) throw replyError(reply, line.split(" ")[0]);
|
|
95
|
+
return reply;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
destroy() {
|
|
99
|
+
this.socket.destroy();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function replyError(reply, command) {
|
|
104
|
+
const transient = reply.code >= 400 && reply.code < 500;
|
|
105
|
+
const auth = reply.code === 535 || reply.code === 534 || reply.code === 530 || reply.code === 454 || command === "AUTH";
|
|
106
|
+
const code = auth ? "auth_failed" : command === "RCPT" ? "recipient_rejected" : command === "MAIL" ? "sender_rejected" : transient ? "temporary_failure" : "rejected";
|
|
107
|
+
return new MailError(`Mail server answered ${reply.code} to ${command}: ${reply.text.slice(0, 200)}`, {
|
|
108
|
+
code,
|
|
109
|
+
retryable: transient && !auth,
|
|
110
|
+
responseCode: reply.code,
|
|
111
|
+
response: reply.text.slice(0, 500),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseCapabilities(reply) {
|
|
116
|
+
const caps = new Map();
|
|
117
|
+
for (const line of reply.lines.slice(1)) {
|
|
118
|
+
const [name, ...rest] = line.trim().split(/\s+/);
|
|
119
|
+
caps.set(name.toUpperCase(), rest.join(" "));
|
|
120
|
+
}
|
|
121
|
+
return caps;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function openSocket(o) {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
const socket =
|
|
127
|
+
o.security === "tls"
|
|
128
|
+
? tlsConnect({ host: o.host, port: o.port, servername: o.tls.servername ?? (LOOPBACK.test(o.host) ? undefined : o.host), ...o.tls })
|
|
129
|
+
: netConnect({ host: o.host, port: o.port });
|
|
130
|
+
const timer = setTimeout(() => {
|
|
131
|
+
socket.destroy();
|
|
132
|
+
reject(new MailError(`Could not connect to ${o.host}:${o.port} in time`, { code: "timeout", retryable: true }));
|
|
133
|
+
}, o.timeoutMs);
|
|
134
|
+
socket.once(o.security === "tls" ? "secureConnect" : "connect", () => {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
socket.removeListener("error", onError);
|
|
137
|
+
resolve(socket);
|
|
138
|
+
});
|
|
139
|
+
const onError = (error) => {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
reject(networkError(error, "connect"));
|
|
142
|
+
};
|
|
143
|
+
socket.once("error", onError);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function upgrade(connection, o) {
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
const plain = connection.socket;
|
|
150
|
+
plain.removeAllListeners();
|
|
151
|
+
plain.setTimeout(0);
|
|
152
|
+
plain.on("error", () => {});
|
|
153
|
+
const secure = tlsConnect({ socket: plain, servername: o.tls.servername ?? (LOOPBACK.test(o.host) ? undefined : o.host), ...o.tls });
|
|
154
|
+
secure.once("secureConnect", () => resolve(secure));
|
|
155
|
+
secure.once("error", (error) => reject(networkError(error, "connect")));
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function openSession(o) {
|
|
160
|
+
const socket = await openSocket(o);
|
|
161
|
+
const c = new Connection(socket, o.timeoutMs);
|
|
162
|
+
try {
|
|
163
|
+
const hello = await c.reply();
|
|
164
|
+
if (hello.code !== 220) throw replyError(hello, "CONNECT");
|
|
165
|
+
let ehlo = await c.command(`EHLO ${o.clientName}`);
|
|
166
|
+
if (ehlo.code >= 500) ehlo = await c.command(`HELO ${o.clientName}`, [250]);
|
|
167
|
+
let caps = parseCapabilities(ehlo);
|
|
168
|
+
let secureNow = o.security === "tls";
|
|
169
|
+
if (o.security === "starttls") {
|
|
170
|
+
if (!caps.has("STARTTLS")) throw new MailError("The mail server does not offer STARTTLS, so the connection would not be encrypted. Use security: \"none\" only if you really want that.", { code: "tls_unavailable" });
|
|
171
|
+
await c.command("STARTTLS", [220]);
|
|
172
|
+
c.bind(await upgrade(c, o));
|
|
173
|
+
secureNow = true;
|
|
174
|
+
ehlo = await c.command(`EHLO ${o.clientName}`, [250]);
|
|
175
|
+
caps = parseCapabilities(ehlo);
|
|
176
|
+
}
|
|
177
|
+
if (o.user !== undefined) {
|
|
178
|
+
if (!secureNow && !o.allowInsecureAuth) throw new MailError("Refusing to send the password over an unencrypted connection", { code: "tls_unavailable" });
|
|
179
|
+
await authenticate(c, caps, o);
|
|
180
|
+
}
|
|
181
|
+
return { c, caps };
|
|
182
|
+
} catch (error) {
|
|
183
|
+
c.destroy();
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function authenticate(c, caps, o) {
|
|
189
|
+
const offered = (caps.get("AUTH") ?? "").toUpperCase().split(/\s+/);
|
|
190
|
+
const b64 = (s) => Buffer.from(s, "utf8").toString("base64");
|
|
191
|
+
if (offered.includes("PLAIN")) {
|
|
192
|
+
await c.command(`AUTH PLAIN ${b64(`\0${o.user}\0${o.pass}`)}`, [235]);
|
|
193
|
+
} else if (offered.includes("LOGIN")) {
|
|
194
|
+
await c.command("AUTH LOGIN", [334]);
|
|
195
|
+
await c.command(b64(o.user), [334]);
|
|
196
|
+
await c.command(b64(o.pass), [235]);
|
|
197
|
+
} else {
|
|
198
|
+
throw new MailError(`The mail server offers no login method this package supports (it offers: ${offered.join(" ") || "none"})`, { code: "auth_failed" });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const dotStuff = (raw) => {
|
|
203
|
+
const text = raw.toString("latin1").replace(/(^|\r\n)\./g, "$1..");
|
|
204
|
+
return text.endsWith("\r\n") ? text : `${text}\r\n`;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Sends through any SMTP server (Zoho, Gmail, your own). Encrypted by default: port 465 uses TLS right away,
|
|
209
|
+
* other ports must offer STARTTLS or the send is refused. No dependencies.
|
|
210
|
+
*/
|
|
211
|
+
function smtpTransport(options = {}) {
|
|
212
|
+
const port = options.port ?? (options.security === "tls" ? 465 : 587);
|
|
213
|
+
const security = options.security ?? (port === 465 ? "tls" : "starttls");
|
|
214
|
+
if (!options.host || typeof options.host !== "string") throw new MailError("smtpTransport needs a host", { code: "invalid_config" });
|
|
215
|
+
if (!["tls", "starttls", "none"].includes(security)) throw new MailError('security must be "tls", "starttls" or "none"', { code: "invalid_config" });
|
|
216
|
+
if ((options.user === undefined) !== (options.pass === undefined)) throw new MailError("Give both user and pass, or neither", { code: "invalid_config" });
|
|
217
|
+
if (security === "none" && options.user !== undefined && !options.allowInsecureAuth) {
|
|
218
|
+
throw new MailError('A login over security: "none" would send the password unencrypted; set allowInsecureAuth: true to allow it', { code: "invalid_config" });
|
|
219
|
+
}
|
|
220
|
+
const o = {
|
|
221
|
+
host: options.host,
|
|
222
|
+
port,
|
|
223
|
+
security,
|
|
224
|
+
user: options.user,
|
|
225
|
+
pass: options.pass,
|
|
226
|
+
allowInsecureAuth: options.allowInsecureAuth === true,
|
|
227
|
+
timeoutMs: options.timeoutMs ?? 20_000,
|
|
228
|
+
clientName: String(options.clientName ?? (hostname().replace(/[^A-Za-z0-9.-]/g, "") || "localhost")),
|
|
229
|
+
tls: { ...options.tls },
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const withSession = async (work) => {
|
|
233
|
+
const session = await openSession(o);
|
|
234
|
+
try {
|
|
235
|
+
return await work(session);
|
|
236
|
+
} finally {
|
|
237
|
+
session.c.write("QUIT\r\n");
|
|
238
|
+
setTimeout(() => session.c.destroy(), 200).unref();
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
name: "smtp",
|
|
244
|
+
/** Opens a connection, encrypts it and logs in, then closes. Use it at startup to catch wrong settings early. */
|
|
245
|
+
verify: () => withSession(async () => true),
|
|
246
|
+
async send(input) {
|
|
247
|
+
const message = input.raw ? input : prepareMessage(input);
|
|
248
|
+
const raw = message.raw;
|
|
249
|
+
return withSession(async ({ c, caps }) => {
|
|
250
|
+
const limit = Number(caps.get("SIZE"));
|
|
251
|
+
if (limit > 0 && raw.length > limit) throw new MailError(`The message is ${raw.length} bytes; the server accepts at most ${limit}`, { code: "too_large" });
|
|
252
|
+
await c.command(`MAIL FROM:<${message.envelope.from}>`, [250]);
|
|
253
|
+
const accepted = [];
|
|
254
|
+
const rejected = [];
|
|
255
|
+
let lastError;
|
|
256
|
+
for (const to of message.envelope.to) {
|
|
257
|
+
const reply = await c.command(`RCPT TO:<${to}>`);
|
|
258
|
+
if (reply.code === 250 || reply.code === 251) accepted.push(to);
|
|
259
|
+
else {
|
|
260
|
+
rejected.push(to);
|
|
261
|
+
lastError = replyError(reply, "RCPT");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (!accepted.length) throw lastError;
|
|
265
|
+
await c.command("DATA", [354]);
|
|
266
|
+
c.stage = "sent";
|
|
267
|
+
c.write(`${dotStuff(raw)}.\r\n`);
|
|
268
|
+
const done = await c.reply();
|
|
269
|
+
c.stage = "done";
|
|
270
|
+
if (done.code !== 250) throw replyError(done, "DATA");
|
|
271
|
+
const queued = /queued as ([\w.@-]+)|\bid[ =:]+([\w.@-]+)/i.exec(done.text);
|
|
272
|
+
return { id: queued?.[1] ?? queued?.[2] ?? message.messageId, messageId: message.messageId, accepted, rejected, response: done.text.slice(0, 500) };
|
|
273
|
+
});
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
exports.smtpTransport = smtpTransport;
|