@blamejs/core 0.4.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/CHANGELOG.md +230 -0
- package/LICENSE +201 -0
- package/LTS-CALENDAR.md +29 -0
- package/MIGRATING.md +7 -0
- package/NOTICE +59 -0
- package/README.md +100 -0
- package/bin/blamejs.js +13 -0
- package/index.js +253 -0
- package/lib/api-key.js +705 -0
- package/lib/api-snapshot.js +335 -0
- package/lib/app-shutdown.js +381 -0
- package/lib/app.js +364 -0
- package/lib/atomic-file.js +525 -0
- package/lib/audit-chain.js +168 -0
- package/lib/audit-sign.js +319 -0
- package/lib/audit-tools.js +682 -0
- package/lib/audit.js +753 -0
- package/lib/auth/jwt.js +280 -0
- package/lib/auth/oauth.js +691 -0
- package/lib/auth/passkey.js +185 -0
- package/lib/auth/password.js +139 -0
- package/lib/auth/totp.js +17 -0
- package/lib/auth-header.js +81 -0
- package/lib/backup/bundle.js +219 -0
- package/lib/backup/crypto.js +174 -0
- package/lib/backup/index.js +490 -0
- package/lib/backup/manifest.js +275 -0
- package/lib/bundler.js +295 -0
- package/lib/cache.js +819 -0
- package/lib/chain-writer.js +234 -0
- package/lib/cli-helpers.js +201 -0
- package/lib/cli.js +1377 -0
- package/lib/cluster-provider-db.js +245 -0
- package/lib/cluster-storage.js +166 -0
- package/lib/cluster.js +691 -0
- package/lib/consent.js +222 -0
- package/lib/constants.js +186 -0
- package/lib/cookies.js +293 -0
- package/lib/credential-hash.js +303 -0
- package/lib/crypto-field.js +159 -0
- package/lib/crypto.js +250 -0
- package/lib/db-query.js +297 -0
- package/lib/db-schema.js +250 -0
- package/lib/db.js +1054 -0
- package/lib/deprecate.js +226 -0
- package/lib/dev.js +324 -0
- package/lib/error-page.js +424 -0
- package/lib/events.js +135 -0
- package/lib/external-db.js +422 -0
- package/lib/forms.js +378 -0
- package/lib/framework-error.js +189 -0
- package/lib/framework-schema.js +604 -0
- package/lib/handlers.js +350 -0
- package/lib/html-balance.js +227 -0
- package/lib/http-client.js +615 -0
- package/lib/i18n.js +780 -0
- package/lib/jobs.js +181 -0
- package/lib/lazy-require.js +48 -0
- package/lib/log-stream-local.js +137 -0
- package/lib/log-stream-webhook.js +170 -0
- package/lib/log-stream.js +211 -0
- package/lib/log.js +355 -0
- package/lib/mail-bounce.js +507 -0
- package/lib/mail.js +701 -0
- package/lib/metrics.js +647 -0
- package/lib/middleware/api-encrypt.js +553 -0
- package/lib/middleware/attach-user.js +156 -0
- package/lib/middleware/body-parser.js +883 -0
- package/lib/middleware/bot-guard.js +148 -0
- package/lib/middleware/compression.js +436 -0
- package/lib/middleware/cors.js +236 -0
- package/lib/middleware/csp-nonce.js +332 -0
- package/lib/middleware/csrf-protect.js +275 -0
- package/lib/middleware/error-handler.js +46 -0
- package/lib/middleware/health.js +358 -0
- package/lib/middleware/index.js +52 -0
- package/lib/middleware/rate-limit.js +319 -0
- package/lib/middleware/request-id.js +53 -0
- package/lib/middleware/require-auth.js +95 -0
- package/lib/middleware/security-headers.js +91 -0
- package/lib/migrations.js +353 -0
- package/lib/mtls-ca.js +333 -0
- package/lib/mtls-engine-default.js +285 -0
- package/lib/nonce-store.js +177 -0
- package/lib/notify.js +643 -0
- package/lib/ntp-check.js +178 -0
- package/lib/object-store/azure-blob.js +467 -0
- package/lib/object-store/gcs.js +469 -0
- package/lib/object-store/http-put.js +153 -0
- package/lib/object-store/index.js +140 -0
- package/lib/object-store/local.js +163 -0
- package/lib/object-store/retry.js +15 -0
- package/lib/object-store/sigv4.js +535 -0
- package/lib/observability.js +114 -0
- package/lib/pagination.js +371 -0
- package/lib/parsers/index.js +64 -0
- package/lib/parsers/safe-csv.js +224 -0
- package/lib/parsers/safe-env.js +614 -0
- package/lib/parsers/safe-toml.js +745 -0
- package/lib/parsers/safe-xml.js +379 -0
- package/lib/parsers/safe-yaml.js +977 -0
- package/lib/permissions.js +430 -0
- package/lib/pqc-agent.js +85 -0
- package/lib/pqc-gate.js +266 -0
- package/lib/protocol-dispatcher.js +144 -0
- package/lib/queue-local.js +327 -0
- package/lib/queue.js +430 -0
- package/lib/redact.js +192 -0
- package/lib/render.js +193 -0
- package/lib/request-helpers.js +178 -0
- package/lib/restore-bundle.js +239 -0
- package/lib/restore-rollback.js +254 -0
- package/lib/restore.js +301 -0
- package/lib/retry.js +329 -0
- package/lib/router.js +437 -0
- package/lib/safe-async.js +520 -0
- package/lib/safe-buffer.js +162 -0
- package/lib/safe-json.js +532 -0
- package/lib/safe-schema.js +1176 -0
- package/lib/safe-sql.js +157 -0
- package/lib/safe-url.js +109 -0
- package/lib/scheduler.js +680 -0
- package/lib/seeders.js +622 -0
- package/lib/session.js +304 -0
- package/lib/slug.js +243 -0
- package/lib/static.js +268 -0
- package/lib/storage.js +470 -0
- package/lib/subject.js +281 -0
- package/lib/template.js +781 -0
- package/lib/testing.js +621 -0
- package/lib/totp.js +285 -0
- package/lib/tracing.js +484 -0
- package/lib/validate-opts.js +56 -0
- package/lib/vault/index.js +299 -0
- package/lib/vault/passphrase-ops.js +311 -0
- package/lib/vault/passphrase-source.js +198 -0
- package/lib/vault/rotate.js +761 -0
- package/lib/vault/wrap.js +289 -0
- package/lib/vendor/MANIFEST.json +84 -0
- package/lib/vendor/argon2/argon2.cjs +466 -0
- package/lib/vendor/argon2/argon2.d.cts +62 -0
- package/lib/vendor/argon2/package.json +1 -0
- package/lib/vendor/argon2/prebuilds/darwin-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/darwin-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/freebsd-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/freebsd-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-x64/argon2.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/win32-x64/argon2.glibc.node +0 -0
- package/lib/vendor/noble-ciphers.cjs +9 -0
- package/lib/vendor/pki.cjs +181 -0
- package/lib/vendor/simplewebauthn-server.cjs +328 -0
- package/lib/webhook.js +632 -0
- package/lib/websocket-channels.js +413 -0
- package/lib/websocket.js +833 -0
- package/package.json +39 -0
package/lib/mail.js
ADDED
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* mail — message contract + pluggable transports.
|
|
4
|
+
*
|
|
5
|
+
* Both the contract and the transport surface ship together. Operators
|
|
6
|
+
* can also pass any function or `{ send }` object as a custom transport.
|
|
7
|
+
*
|
|
8
|
+
* mail.transports.console — logs message to stderr (dev default)
|
|
9
|
+
* mail.transports.memory — captures into a `sent[]` array (tests)
|
|
10
|
+
* mail.transports.smtp — raw RFC 5321 over net/tls with STARTTLS,
|
|
11
|
+
* AUTH LOGIN, and PQC-friendly TLS opts
|
|
12
|
+
* mail.transports.http — generic HTTP-API transport: operator
|
|
13
|
+
* supplies endpoint, headers, serialize(),
|
|
14
|
+
* and interpret() — works with any vendor
|
|
15
|
+
* that speaks JSON-over-HTTPS (Postmark,
|
|
16
|
+
* Mailgun, SES HTTP, SendGrid, Resend, …)
|
|
17
|
+
* mail.transports.resend — thin preset that wires http to the
|
|
18
|
+
* Resend API (illustrates the pattern)
|
|
19
|
+
*
|
|
20
|
+
* Public API:
|
|
21
|
+
*
|
|
22
|
+
* mail.create({ transport?, defaults?, audit? }) → instance
|
|
23
|
+
*
|
|
24
|
+
* transport — function(message) | { send(message) }; default: console.
|
|
25
|
+
* defaults — { from, replyTo, headers, ... } merged into every
|
|
26
|
+
* message unless the message overrides.
|
|
27
|
+
* audit — emit mail.send.success / .failure audit events
|
|
28
|
+
* (default true).
|
|
29
|
+
*
|
|
30
|
+
* await instance.send(message)
|
|
31
|
+
* message: {
|
|
32
|
+
* to: "x@y" | ["x@y", ...]
|
|
33
|
+
* cc: string | string[]
|
|
34
|
+
* bcc: string | string[]
|
|
35
|
+
* from: "Name <noreply@app>" (or instance default)
|
|
36
|
+
* replyTo: "..."
|
|
37
|
+
* subject: "..."
|
|
38
|
+
* text: "plain body" (at least one of text/html)
|
|
39
|
+
* html: "<p>...</p>"
|
|
40
|
+
* headers: { "X-Custom": "v" } (merged with defaults)
|
|
41
|
+
* }
|
|
42
|
+
* → whatever the transport returned
|
|
43
|
+
*
|
|
44
|
+
* Validation surface uses MailError (FrameworkError subclass) with
|
|
45
|
+
* permanent flag. Distinct codes per failure: missing-to, missing-from,
|
|
46
|
+
* missing-body, invalid-recipient, transport-failed, smtp-*, http-*,
|
|
47
|
+
* resend-*. Vendor-specific presets carry their own code prefix so
|
|
48
|
+
* diagnostic logs identify the provider that rejected the message.
|
|
49
|
+
*/
|
|
50
|
+
var lazyRequire = require("./lazy-require");
|
|
51
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
52
|
+
var httpClient = lazyRequire(function () { return require("./http-client"); });
|
|
53
|
+
var net = lazyRequire(function () { return require("net"); });
|
|
54
|
+
var tls = lazyRequire(function () { return require("tls"); });
|
|
55
|
+
var validateOpts = require("./validate-opts");
|
|
56
|
+
var { FrameworkError } = require("./framework-error");
|
|
57
|
+
|
|
58
|
+
class MailError extends FrameworkError {
|
|
59
|
+
constructor(code, message, permanent, statusCode) {
|
|
60
|
+
super(message, code);
|
|
61
|
+
this.name = "MailError";
|
|
62
|
+
this.permanent = !!permanent;
|
|
63
|
+
this.isMailError = true;
|
|
64
|
+
if (typeof statusCode === "number") this.statusCode = statusCode;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Pragmatic email check — same shape as forms.validate. RFC 5322 in a
|
|
69
|
+
// regex is a fool's errand; this catches obvious nonsense and lets
|
|
70
|
+
// real-world addresses through.
|
|
71
|
+
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
72
|
+
|
|
73
|
+
function _normalizeRecipientList(value, label) {
|
|
74
|
+
if (value === undefined || value === null) return [];
|
|
75
|
+
var arr = Array.isArray(value) ? value : [value];
|
|
76
|
+
for (var i = 0; i < arr.length; i++) {
|
|
77
|
+
if (typeof arr[i] !== "string" || arr[i].length === 0) {
|
|
78
|
+
throw new MailError("mail/invalid-recipient",
|
|
79
|
+
label + "[" + i + "] must be a non-empty string", true);
|
|
80
|
+
}
|
|
81
|
+
// CRLF/NUL in addresses → header injection. Reject hard.
|
|
82
|
+
if (/[\r\n\0]/.test(arr[i])) {
|
|
83
|
+
throw new MailError("mail/invalid-recipient",
|
|
84
|
+
label + "[" + i + "] contains forbidden control characters", true);
|
|
85
|
+
}
|
|
86
|
+
// Accept "Name <email@addr>" form too — extract the angle-bracket
|
|
87
|
+
// address for validation; preserve the full string in the message.
|
|
88
|
+
var bracket = arr[i].match(/<([^>]+)>/);
|
|
89
|
+
var addr = bracket ? bracket[1] : arr[i];
|
|
90
|
+
if (!EMAIL_RE.test(addr.trim())) {
|
|
91
|
+
throw new MailError("mail/invalid-recipient",
|
|
92
|
+
label + " '" + arr[i] + "' is not a valid email address", true);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return arr;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function _validateMessage(message) {
|
|
99
|
+
if (!message || typeof message !== "object") {
|
|
100
|
+
throw new MailError("mail/missing-message", "send() requires a message object", true);
|
|
101
|
+
}
|
|
102
|
+
var to = _normalizeRecipientList(message.to, "to");
|
|
103
|
+
if (to.length === 0) {
|
|
104
|
+
throw new MailError("mail/missing-to", "message.to is required (one or more recipients)", true);
|
|
105
|
+
}
|
|
106
|
+
_normalizeRecipientList(message.cc, "cc");
|
|
107
|
+
_normalizeRecipientList(message.bcc, "bcc");
|
|
108
|
+
|
|
109
|
+
if (!message.from || typeof message.from !== "string") {
|
|
110
|
+
throw new MailError("mail/missing-from", "message.from is required", true);
|
|
111
|
+
}
|
|
112
|
+
if (/[\r\n\0]/.test(message.from)) {
|
|
113
|
+
throw new MailError("mail/invalid-from",
|
|
114
|
+
"message.from contains forbidden control characters", true);
|
|
115
|
+
}
|
|
116
|
+
var fromBracket = message.from.match(/<([^>]+)>/);
|
|
117
|
+
var fromAddr = fromBracket ? fromBracket[1] : message.from;
|
|
118
|
+
if (!EMAIL_RE.test(fromAddr.trim())) {
|
|
119
|
+
throw new MailError("mail/invalid-from",
|
|
120
|
+
"message.from '" + message.from + "' is not a valid email address", true);
|
|
121
|
+
}
|
|
122
|
+
if (message.subject && /[\r\n]/.test(message.subject)) {
|
|
123
|
+
throw new MailError("mail/invalid-subject",
|
|
124
|
+
"message.subject contains forbidden CRLF", true);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!message.text && !message.html) {
|
|
128
|
+
throw new MailError("mail/missing-body",
|
|
129
|
+
"message must include at least one of text or html", true);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function _mergeMessage(defaults, message) {
|
|
134
|
+
// Per-message values override defaults; headers merged shallow.
|
|
135
|
+
var merged = Object.assign({}, defaults || {}, message);
|
|
136
|
+
if (defaults && defaults.headers && message.headers) {
|
|
137
|
+
merged.headers = Object.assign({}, defaults.headers, message.headers);
|
|
138
|
+
}
|
|
139
|
+
return merged;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function _extractAddr(s) {
|
|
143
|
+
if (s === undefined || s === null) return s;
|
|
144
|
+
var m = String(s).match(/<([^>]+)>/);
|
|
145
|
+
return m ? m[1].trim() : String(s).trim();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function _toArray(v) {
|
|
149
|
+
if (v === undefined || v === null) return [];
|
|
150
|
+
return Array.isArray(v) ? v.slice() : [v];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---- Built-in transports: console + memory (dev / tests) ----
|
|
154
|
+
|
|
155
|
+
function consoleTransport(opts) {
|
|
156
|
+
opts = opts || {};
|
|
157
|
+
var stream = opts.stream || process.stderr;
|
|
158
|
+
return {
|
|
159
|
+
name: "console",
|
|
160
|
+
send: async function (message) {
|
|
161
|
+
var lines = [
|
|
162
|
+
"[mail.console] To: " + (Array.isArray(message.to) ? message.to.join(", ") : message.to),
|
|
163
|
+
"[mail.console] From: " + message.from,
|
|
164
|
+
"[mail.console] Subject: " + (message.subject || ""),
|
|
165
|
+
];
|
|
166
|
+
if (message.cc) lines.push("[mail.console] Cc: " + (Array.isArray(message.cc) ? message.cc.join(", ") : message.cc));
|
|
167
|
+
if (message.bcc) lines.push("[mail.console] Bcc: " + (Array.isArray(message.bcc) ? message.bcc.join(", ") : message.bcc));
|
|
168
|
+
var body = message.text || (message.html ? "(html body, " + message.html.length + " bytes)" : "");
|
|
169
|
+
lines.push("");
|
|
170
|
+
lines.push(body);
|
|
171
|
+
lines.push("");
|
|
172
|
+
stream.write(lines.join("\n") + "\n");
|
|
173
|
+
return { transport: "console", deliveredAt: Date.now() };
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function memoryTransport() {
|
|
179
|
+
var sent = [];
|
|
180
|
+
return {
|
|
181
|
+
name: "memory",
|
|
182
|
+
sent: sent,
|
|
183
|
+
send: async function (message) {
|
|
184
|
+
sent.push(message);
|
|
185
|
+
return { transport: "memory", deliveredAt: Date.now(), index: sent.length - 1 };
|
|
186
|
+
},
|
|
187
|
+
reset: function () { sent.length = 0; },
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---- SMTP transport ----
|
|
192
|
+
//
|
|
193
|
+
// Raw RFC 5321 state machine over net/tls. Multi-recipient (loops
|
|
194
|
+
// RCPT TO over to + cc + bcc), builds an RFC 5322 message with
|
|
195
|
+
// multipart/alternative when both text and html are supplied, and
|
|
196
|
+
// dot-stuffs body lines beginning with "." per SMTP transparency.
|
|
197
|
+
//
|
|
198
|
+
// PQC posture: TLS opts default to TLSv1.3 minimum and accept an
|
|
199
|
+
// `ecdhCurve` string (set to a hybrid PQC group such as
|
|
200
|
+
// "X25519MLKEM768" when peer + Node version support it). On a
|
|
201
|
+
// cleartext port the transport always issues STARTTLS and refuses
|
|
202
|
+
// to send AUTH or DATA in cleartext if the upgrade is rejected.
|
|
203
|
+
|
|
204
|
+
function _buildRfc822(message) {
|
|
205
|
+
var headers = [];
|
|
206
|
+
headers.push("From: " + message.from);
|
|
207
|
+
headers.push("To: " + (Array.isArray(message.to) ? message.to.join(", ") : message.to));
|
|
208
|
+
if (message.cc) headers.push("Cc: " + (Array.isArray(message.cc) ? message.cc.join(", ") : message.cc));
|
|
209
|
+
if (message.replyTo) headers.push("Reply-To: " + message.replyTo);
|
|
210
|
+
if (message.subject) headers.push("Subject: " + message.subject);
|
|
211
|
+
headers.push("MIME-Version: 1.0");
|
|
212
|
+
headers.push("Date: " + new Date().toUTCString());
|
|
213
|
+
if (message.headers) {
|
|
214
|
+
for (var k in message.headers) {
|
|
215
|
+
if (Object.prototype.hasOwnProperty.call(message.headers, k)) {
|
|
216
|
+
// Strip CRLF defensively even though we already validated the
|
|
217
|
+
// message; custom headers go straight onto the wire.
|
|
218
|
+
var v = String(message.headers[k]).replace(/[\r\n]/g, "");
|
|
219
|
+
headers.push(k + ": " + v);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
var body;
|
|
225
|
+
if (message.text && message.html) {
|
|
226
|
+
var boundary = "blamejs-mail-" + Date.now() + "-" + Math.floor(Math.random() * 1e9);
|
|
227
|
+
headers.push('Content-Type: multipart/alternative; boundary="' + boundary + '"');
|
|
228
|
+
body = [
|
|
229
|
+
"--" + boundary,
|
|
230
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
231
|
+
"",
|
|
232
|
+
message.text,
|
|
233
|
+
"--" + boundary,
|
|
234
|
+
"Content-Type: text/html; charset=utf-8",
|
|
235
|
+
"",
|
|
236
|
+
message.html,
|
|
237
|
+
"--" + boundary + "--",
|
|
238
|
+
].join("\r\n");
|
|
239
|
+
} else if (message.html) {
|
|
240
|
+
headers.push("Content-Type: text/html; charset=utf-8");
|
|
241
|
+
body = message.html;
|
|
242
|
+
} else {
|
|
243
|
+
headers.push("Content-Type: text/plain; charset=utf-8");
|
|
244
|
+
body = message.text || "";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Normalize line endings then dot-stuff per SMTP transparency.
|
|
248
|
+
body = body.replace(/\r?\n/g, "\r\n");
|
|
249
|
+
body = body.split("\r\n").map(function (l) { return l.charAt(0) === "." ? "." + l : l; }).join("\r\n");
|
|
250
|
+
|
|
251
|
+
return headers.join("\r\n") + "\r\n\r\n" + body;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function smtpTransport(opts) {
|
|
255
|
+
opts = opts || {};
|
|
256
|
+
if (!opts.host) {
|
|
257
|
+
throw new MailError("mail/smtp-misconfigured",
|
|
258
|
+
"smtp transport requires opts.host", true);
|
|
259
|
+
}
|
|
260
|
+
var port = opts.port || 587;
|
|
261
|
+
var useImplicitTLS = port === 465 || opts.implicitTls === true;
|
|
262
|
+
var rejectUnauthorized = opts.rejectUnauthorized !== false;
|
|
263
|
+
var ehloName = opts.ehloName || "blamejs";
|
|
264
|
+
var timeoutMs = opts.timeoutMs || 15000;
|
|
265
|
+
var tlsOpts = {
|
|
266
|
+
rejectUnauthorized: rejectUnauthorized,
|
|
267
|
+
minVersion: opts.minTlsVersion || "TLSv1.3",
|
|
268
|
+
};
|
|
269
|
+
if (opts.ecdhCurve) tlsOpts.ecdhCurve = opts.ecdhCurve;
|
|
270
|
+
if (opts.ca) tlsOpts.ca = opts.ca;
|
|
271
|
+
|
|
272
|
+
var cfg = {
|
|
273
|
+
host: opts.host,
|
|
274
|
+
port: port,
|
|
275
|
+
user: opts.user,
|
|
276
|
+
pass: opts.pass,
|
|
277
|
+
useImplicitTLS: useImplicitTLS,
|
|
278
|
+
ehloName: ehloName,
|
|
279
|
+
timeoutMs: timeoutMs,
|
|
280
|
+
tlsOpts: tlsOpts,
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
name: "smtp",
|
|
285
|
+
send: function (message) { return _smtpSend(message, cfg); },
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function _smtpSend(message, cfg) {
|
|
290
|
+
return new Promise(function (resolve, reject) {
|
|
291
|
+
var socket;
|
|
292
|
+
var step = 0;
|
|
293
|
+
var buffer = "";
|
|
294
|
+
var upgradedToTLS = false;
|
|
295
|
+
var settled = false;
|
|
296
|
+
var rcptIndex = 0;
|
|
297
|
+
|
|
298
|
+
var fromAddr = _extractAddr(message.from);
|
|
299
|
+
var toList = _toArray(message.to).map(_extractAddr);
|
|
300
|
+
var ccList = _toArray(message.cc).map(_extractAddr);
|
|
301
|
+
var bccList = _toArray(message.bcc).map(_extractAddr);
|
|
302
|
+
var rcpts = toList.concat(ccList, bccList);
|
|
303
|
+
var dataMessage = _buildRfc822(message);
|
|
304
|
+
|
|
305
|
+
function fail(reason) {
|
|
306
|
+
if (settled) return;
|
|
307
|
+
settled = true;
|
|
308
|
+
try { if (socket) socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
|
|
309
|
+
reject(new MailError("mail/smtp-failed",
|
|
310
|
+
"SMTP send failed: " + reason, false));
|
|
311
|
+
}
|
|
312
|
+
function done(ok, code) {
|
|
313
|
+
if (settled) return;
|
|
314
|
+
settled = true;
|
|
315
|
+
try { socket.end(); } catch (_e) { /* socket may already be torn down */ }
|
|
316
|
+
if (ok) resolve({ transport: "smtp", deliveredAt: Date.now(), code: code });
|
|
317
|
+
else reject(new MailError("mail/smtp-rejected",
|
|
318
|
+
"SMTP rejected message (code " + code + ")", false));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function send(cmd) {
|
|
322
|
+
try { socket.write(cmd + "\r\n"); }
|
|
323
|
+
catch (e) { fail(e.message || String(e)); }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function onData(data) {
|
|
327
|
+
buffer += data;
|
|
328
|
+
var lines = buffer.split("\r\n");
|
|
329
|
+
buffer = lines.pop();
|
|
330
|
+
for (var i = 0; i < lines.length; i++) {
|
|
331
|
+
var line = lines[i];
|
|
332
|
+
if (!line) continue;
|
|
333
|
+
var code = parseInt(line.slice(0, 3), 10);
|
|
334
|
+
if (line[3] === "-") continue; // continuation line
|
|
335
|
+
try { handleResponse(code); }
|
|
336
|
+
catch (e) { fail(e.message || String(e)); return; }
|
|
337
|
+
if (settled) return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function attachSocket(s) {
|
|
342
|
+
socket = s;
|
|
343
|
+
socket.setEncoding("utf8");
|
|
344
|
+
socket.setTimeout(cfg.timeoutMs);
|
|
345
|
+
socket.on("data", onData);
|
|
346
|
+
socket.on("error", function (err) { fail(err.message || String(err)); });
|
|
347
|
+
socket.on("timeout", function () { fail("timeout"); });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function connect() {
|
|
351
|
+
if (cfg.useImplicitTLS) {
|
|
352
|
+
var tlsConnectOpts = Object.assign({ servername: cfg.host }, cfg.tlsOpts);
|
|
353
|
+
attachSocket(tls().connect(cfg.port, cfg.host, tlsConnectOpts));
|
|
354
|
+
} else {
|
|
355
|
+
attachSocket(net().createConnection(cfg.port, cfg.host));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function handleResponse(code) {
|
|
360
|
+
if (step === 0) {
|
|
361
|
+
if (code !== 220) { fail("greeting-rejected (code " + code + ")"); return; }
|
|
362
|
+
send("EHLO " + cfg.ehloName); step = 1;
|
|
363
|
+
}
|
|
364
|
+
else if (step === 1) {
|
|
365
|
+
if (code < 200 || code >= 300) { fail("ehlo-rejected (code " + code + ")"); return; }
|
|
366
|
+
if (!cfg.useImplicitTLS && !upgradedToTLS) { send("STARTTLS"); step = 10; }
|
|
367
|
+
else if (cfg.user) { send("AUTH LOGIN"); step = 2; }
|
|
368
|
+
else { send("MAIL FROM:<" + fromAddr + ">"); step = 5; }
|
|
369
|
+
}
|
|
370
|
+
else if (step === 10) {
|
|
371
|
+
if (code !== 220) { fail("starttls-rejected (code " + code + ")"); return; }
|
|
372
|
+
var tlsConnectOpts = Object.assign({ socket: socket, servername: cfg.host }, cfg.tlsOpts);
|
|
373
|
+
var tlsSocket = tls().connect(tlsConnectOpts, function () {
|
|
374
|
+
upgradedToTLS = true;
|
|
375
|
+
try { socket.removeAllListeners("data"); } catch (_e) { /* listeners migrate to upgraded socket */ }
|
|
376
|
+
attachSocket(tlsSocket);
|
|
377
|
+
send("EHLO " + cfg.ehloName);
|
|
378
|
+
step = 1;
|
|
379
|
+
});
|
|
380
|
+
tlsSocket.on("error", function (err) {
|
|
381
|
+
fail("tls-upgrade: " + (err.message || String(err)));
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
else if (step === 2) {
|
|
385
|
+
if (code !== 334) { fail("auth-username-rejected (code " + code + ")"); return; }
|
|
386
|
+
send(Buffer.from(cfg.user || "").toString("base64")); step = 3;
|
|
387
|
+
}
|
|
388
|
+
else if (step === 3) {
|
|
389
|
+
if (code !== 334) { fail("auth-password-rejected (code " + code + ")"); return; }
|
|
390
|
+
send(Buffer.from(cfg.pass || "").toString("base64")); step = 4;
|
|
391
|
+
}
|
|
392
|
+
else if (step === 4) {
|
|
393
|
+
if (code !== 235) { fail("auth-failed (code " + code + ")"); return; }
|
|
394
|
+
send("MAIL FROM:<" + fromAddr + ">"); step = 5;
|
|
395
|
+
}
|
|
396
|
+
else if (step === 5) {
|
|
397
|
+
if (code < 200 || code >= 300) { fail("mail-from-rejected (code " + code + ")"); return; }
|
|
398
|
+
send("RCPT TO:<" + rcpts[rcptIndex++] + ">"); step = 6;
|
|
399
|
+
}
|
|
400
|
+
else if (step === 6) {
|
|
401
|
+
if (code < 200 || code >= 300) { fail("rcpt-rejected (code " + code + ")"); return; }
|
|
402
|
+
if (rcptIndex < rcpts.length) {
|
|
403
|
+
send("RCPT TO:<" + rcpts[rcptIndex++] + ">");
|
|
404
|
+
} else {
|
|
405
|
+
send("DATA"); step = 7;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
else if (step === 7) {
|
|
409
|
+
if (code !== 354) { fail("data-rejected (code " + code + ")"); return; }
|
|
410
|
+
send(dataMessage + "\r\n.");
|
|
411
|
+
step = 8;
|
|
412
|
+
}
|
|
413
|
+
else if (step === 8) {
|
|
414
|
+
var ok = code === 250;
|
|
415
|
+
done(ok, code);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
try { connect(); }
|
|
420
|
+
catch (e) { fail(e.message || String(e)); }
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---- Generic HTTP transport ----
|
|
425
|
+
//
|
|
426
|
+
// Vendor-agnostic transport for any mail API that speaks HTTP. Operators
|
|
427
|
+
// supply three things: an endpoint, a serialize() that turns the
|
|
428
|
+
// framework-shaped message into the vendor's request body + headers,
|
|
429
|
+
// and an interpret() that reads the vendor's response and decides
|
|
430
|
+
// success vs failure. Uses lib/http-client so PQC TLS, response caps,
|
|
431
|
+
// and timeout handling come for free.
|
|
432
|
+
//
|
|
433
|
+
// httpTransport({
|
|
434
|
+
// name: "postmark", // appears in result + error codes
|
|
435
|
+
// endpoint: "https://...", // POST target
|
|
436
|
+
// method: "POST", // default POST
|
|
437
|
+
// headers: { ... }, // base headers (auth, content-type, ...)
|
|
438
|
+
// timeoutMs: 15000,
|
|
439
|
+
// allowedProtocols: safeUrl.ALLOW_HTTP_TLS, // default HTTPS-only
|
|
440
|
+
// serialize: function (message) {
|
|
441
|
+
// // → { headers?: {...}, body: string | Buffer }
|
|
442
|
+
// },
|
|
443
|
+
// interpret: function (res, message) {
|
|
444
|
+
// // res = { statusCode, headers, body: Buffer }
|
|
445
|
+
// // → { ok: true, id?: "..." } | { ok: false, reason: "..." }
|
|
446
|
+
// // throw a MailError for permanent / structural failures
|
|
447
|
+
// },
|
|
448
|
+
// })
|
|
449
|
+
//
|
|
450
|
+
// Errors carry a `mail/<name>-*` code so logs identify which provider
|
|
451
|
+
// rejected which message (mail/postmark-failed, mail/resend-rejected,
|
|
452
|
+
// etc.). HTTPS-only is the default — pass safeUrl.ALLOW_HTTP_ALL via
|
|
453
|
+
// opts.allowedProtocols only for local test fixtures.
|
|
454
|
+
|
|
455
|
+
function httpTransport(opts) {
|
|
456
|
+
opts = opts || {};
|
|
457
|
+
if (!opts.endpoint || typeof opts.endpoint !== "string") {
|
|
458
|
+
throw new MailError("mail/http-misconfigured",
|
|
459
|
+
"http transport requires opts.endpoint", true);
|
|
460
|
+
}
|
|
461
|
+
if (typeof opts.serialize !== "function") {
|
|
462
|
+
throw new MailError("mail/http-misconfigured",
|
|
463
|
+
"http transport requires opts.serialize(message) → { headers?, body }", true);
|
|
464
|
+
}
|
|
465
|
+
var name = opts.name || "http";
|
|
466
|
+
var method = (opts.method || "POST").toUpperCase();
|
|
467
|
+
var endpoint = opts.endpoint;
|
|
468
|
+
var baseHeaders = opts.headers || {};
|
|
469
|
+
var timeoutMs = opts.timeoutMs || 15000;
|
|
470
|
+
var allowedProtocols = opts.allowedProtocols || null;
|
|
471
|
+
var interpret = typeof opts.interpret === "function" ? opts.interpret : null;
|
|
472
|
+
var serialize = opts.serialize;
|
|
473
|
+
var codePrefix = "mail/" + name;
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
name: name,
|
|
477
|
+
send: async function (message) {
|
|
478
|
+
var serialized = serialize(message);
|
|
479
|
+
if (!serialized || typeof serialized !== "object") {
|
|
480
|
+
throw new MailError(codePrefix + "-bad-serializer",
|
|
481
|
+
"serialize() must return { headers?, body }", false);
|
|
482
|
+
}
|
|
483
|
+
var body = serialized.body;
|
|
484
|
+
if (typeof body === "string") body = Buffer.from(body, "utf8");
|
|
485
|
+
if (!Buffer.isBuffer(body)) {
|
|
486
|
+
throw new MailError(codePrefix + "-bad-serializer",
|
|
487
|
+
"serialize() body must be a string or Buffer", false);
|
|
488
|
+
}
|
|
489
|
+
var headers = Object.assign({}, baseHeaders, serialized.headers || {});
|
|
490
|
+
// Default Content-Length when caller hasn't asserted chunked
|
|
491
|
+
// transfer; keeps small JSON payloads from being chunked needlessly.
|
|
492
|
+
var hasLen = false;
|
|
493
|
+
for (var hk in headers) {
|
|
494
|
+
if (Object.prototype.hasOwnProperty.call(headers, hk) &&
|
|
495
|
+
hk.toLowerCase() === "content-length") { hasLen = true; break; }
|
|
496
|
+
}
|
|
497
|
+
if (!hasLen) headers["Content-Length"] = body.length;
|
|
498
|
+
|
|
499
|
+
var reqOpts = {
|
|
500
|
+
method: method,
|
|
501
|
+
url: endpoint,
|
|
502
|
+
headers: headers,
|
|
503
|
+
body: body,
|
|
504
|
+
timeoutMs: timeoutMs,
|
|
505
|
+
errorClass: MailError, // http-client constructs (code, message, permanent, statusCode)
|
|
506
|
+
};
|
|
507
|
+
if (allowedProtocols) reqOpts.allowedProtocols = allowedProtocols;
|
|
508
|
+
|
|
509
|
+
var res;
|
|
510
|
+
try {
|
|
511
|
+
res = await httpClient().request(reqOpts);
|
|
512
|
+
} catch (e) {
|
|
513
|
+
// http-client constructs a MailError via opts.errorClass on
|
|
514
|
+
// non-2xx / network / timeout, with its own code domain
|
|
515
|
+
// (HTTP_ERROR, ETIMEDOUT, ...). Rewrap into mail/<name>-failed
|
|
516
|
+
// so the consumer-facing code identifies the provider while
|
|
517
|
+
// preserving the original as `cause` and the HTTP statusCode.
|
|
518
|
+
var wrapped = new MailError(codePrefix + "-failed",
|
|
519
|
+
name + " request failed: " + ((e && e.message) || String(e)),
|
|
520
|
+
false,
|
|
521
|
+
e && typeof e.statusCode === "number" ? e.statusCode : undefined);
|
|
522
|
+
wrapped.cause = e;
|
|
523
|
+
throw wrapped;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
var info = { transport: name, deliveredAt: Date.now() };
|
|
527
|
+
if (typeof res.statusCode === "number") info.statusCode = res.statusCode;
|
|
528
|
+
|
|
529
|
+
if (!interpret) return info;
|
|
530
|
+
|
|
531
|
+
var verdict;
|
|
532
|
+
try { verdict = interpret(res, message); }
|
|
533
|
+
catch (e) {
|
|
534
|
+
if (e && e.isMailError) throw e;
|
|
535
|
+
throw new MailError(codePrefix + "-interpret-failed",
|
|
536
|
+
"interpret() threw: " + ((e && e.message) || String(e)), false);
|
|
537
|
+
}
|
|
538
|
+
if (!verdict || verdict.ok === false) {
|
|
539
|
+
var reason = (verdict && verdict.reason) || "rejected";
|
|
540
|
+
var err = new MailError(codePrefix + "-rejected",
|
|
541
|
+
name + " rejected message: " + reason, false);
|
|
542
|
+
if (verdict && typeof verdict.statusCode === "number") err.statusCode = verdict.statusCode;
|
|
543
|
+
throw err;
|
|
544
|
+
}
|
|
545
|
+
if (verdict.id) info.id = verdict.id;
|
|
546
|
+
if (verdict.extra) Object.assign(info, verdict.extra);
|
|
547
|
+
return info;
|
|
548
|
+
},
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ---- Resend preset ----
|
|
553
|
+
//
|
|
554
|
+
// Thin convenience wrapper that wires httpTransport to Resend's API.
|
|
555
|
+
// Operators wanting Postmark / Mailgun / SES HTTP / SendGrid build the
|
|
556
|
+
// same shape against httpTransport directly — this preset exists to
|
|
557
|
+
// document the pattern, not to privilege any single vendor.
|
|
558
|
+
|
|
559
|
+
function resendTransport(opts) {
|
|
560
|
+
opts = opts || {};
|
|
561
|
+
if (!opts.apiKey || typeof opts.apiKey !== "string") {
|
|
562
|
+
throw new MailError("mail/resend-misconfigured",
|
|
563
|
+
"resend transport requires opts.apiKey", true);
|
|
564
|
+
}
|
|
565
|
+
return httpTransport({
|
|
566
|
+
name: "resend",
|
|
567
|
+
endpoint: opts.endpoint || "https://api.resend.com/emails",
|
|
568
|
+
method: "POST",
|
|
569
|
+
timeoutMs: opts.timeoutMs || 15000,
|
|
570
|
+
allowedProtocols: opts.allowedProtocols || null,
|
|
571
|
+
headers: {
|
|
572
|
+
"Authorization": "Bearer " + opts.apiKey,
|
|
573
|
+
"Content-Type": "application/json",
|
|
574
|
+
},
|
|
575
|
+
serialize: function (message) {
|
|
576
|
+
var payload = {
|
|
577
|
+
from: message.from,
|
|
578
|
+
to: Array.isArray(message.to) ? message.to : [message.to],
|
|
579
|
+
subject: message.subject || "",
|
|
580
|
+
};
|
|
581
|
+
if (message.cc) payload.cc = Array.isArray(message.cc) ? message.cc : [message.cc];
|
|
582
|
+
if (message.bcc) payload.bcc = Array.isArray(message.bcc) ? message.bcc : [message.bcc];
|
|
583
|
+
if (message.replyTo) payload.reply_to = message.replyTo;
|
|
584
|
+
if (message.html) payload.html = message.html;
|
|
585
|
+
if (message.text) payload.text = message.text;
|
|
586
|
+
if (message.headers) payload.headers = message.headers;
|
|
587
|
+
return { body: JSON.stringify(payload) };
|
|
588
|
+
},
|
|
589
|
+
interpret: function (res) {
|
|
590
|
+
var text = res.body ? res.body.toString("utf8") : "";
|
|
591
|
+
var data;
|
|
592
|
+
try { data = JSON.parse(text); }
|
|
593
|
+
catch (_e) {
|
|
594
|
+
throw new MailError("mail/resend-bad-response",
|
|
595
|
+
"resend response was not JSON: " + text.slice(0, 200), false);
|
|
596
|
+
}
|
|
597
|
+
if (!data.id) {
|
|
598
|
+
return {
|
|
599
|
+
ok: false,
|
|
600
|
+
reason: data.message || JSON.stringify(data).slice(0, 200),
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
return { ok: true, id: data.id };
|
|
604
|
+
},
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ---- Engine instance ----
|
|
609
|
+
|
|
610
|
+
function create(opts) {
|
|
611
|
+
opts = opts || {};
|
|
612
|
+
validateOpts(opts, [
|
|
613
|
+
"transport", "defaults", "audit",
|
|
614
|
+
], "mail");
|
|
615
|
+
var transport = opts.transport || consoleTransport();
|
|
616
|
+
if (typeof transport === "function") {
|
|
617
|
+
transport = { send: transport, name: "anonymous" };
|
|
618
|
+
}
|
|
619
|
+
if (!transport || typeof transport.send !== "function") {
|
|
620
|
+
throw new MailError("mail/bad-transport",
|
|
621
|
+
"opts.transport must be a function or an object with .send(message)", true);
|
|
622
|
+
}
|
|
623
|
+
var defaults = opts.defaults || {};
|
|
624
|
+
var auditOn = opts.audit !== false;
|
|
625
|
+
|
|
626
|
+
function _emit(action, info) {
|
|
627
|
+
if (!auditOn) return;
|
|
628
|
+
audit().safeEmit({
|
|
629
|
+
action: action,
|
|
630
|
+
outcome: info.outcome || (action.endsWith(".failure") ? "failure" : "success"),
|
|
631
|
+
actor: info.actor || {},
|
|
632
|
+
// Recipient COUNT, not addresses — addresses can be PII; the
|
|
633
|
+
// framework's audit chain shouldn't carry them by default.
|
|
634
|
+
// Operators who need full address logging set their own audit
|
|
635
|
+
// hook with whatever PII discipline they want.
|
|
636
|
+
metadata: {
|
|
637
|
+
transport: transport.name || "custom",
|
|
638
|
+
subject: info.subject || "",
|
|
639
|
+
toCount: info.toCount,
|
|
640
|
+
ccCount: info.ccCount,
|
|
641
|
+
bccCount: info.bccCount,
|
|
642
|
+
durationMs: info.durationMs,
|
|
643
|
+
},
|
|
644
|
+
reason: info.reason || null,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function send(message) {
|
|
649
|
+
var merged = _mergeMessage(defaults, message);
|
|
650
|
+
_validateMessage(merged);
|
|
651
|
+
|
|
652
|
+
var t0 = Date.now();
|
|
653
|
+
try {
|
|
654
|
+
var result = await transport.send(merged);
|
|
655
|
+
_emit("mail.send.success", {
|
|
656
|
+
subject: merged.subject,
|
|
657
|
+
toCount: Array.isArray(merged.to) ? merged.to.length : 1,
|
|
658
|
+
ccCount: Array.isArray(merged.cc) ? merged.cc.length : (merged.cc ? 1 : 0),
|
|
659
|
+
bccCount: Array.isArray(merged.bcc) ? merged.bcc.length : (merged.bcc ? 1 : 0),
|
|
660
|
+
durationMs: Date.now() - t0,
|
|
661
|
+
});
|
|
662
|
+
return result;
|
|
663
|
+
} catch (e) {
|
|
664
|
+
_emit("mail.send.failure", {
|
|
665
|
+
subject: merged.subject,
|
|
666
|
+
toCount: Array.isArray(merged.to) ? merged.to.length : 1,
|
|
667
|
+
ccCount: Array.isArray(merged.cc) ? merged.cc.length : (merged.cc ? 1 : 0),
|
|
668
|
+
bccCount: Array.isArray(merged.bcc) ? merged.bcc.length : (merged.bcc ? 1 : 0),
|
|
669
|
+
durationMs: Date.now() - t0,
|
|
670
|
+
outcome: "failure",
|
|
671
|
+
reason: (e && e.message) || String(e),
|
|
672
|
+
});
|
|
673
|
+
// Re-throw as MailError when the upstream wasn't already one,
|
|
674
|
+
// preserving the cause for diagnostic chains.
|
|
675
|
+
if (e && e.isMailError) throw e;
|
|
676
|
+
var wrapped = new MailError("mail/transport-failed",
|
|
677
|
+
"transport '" + (transport.name || "custom") + "' failed: " + ((e && e.message) || String(e)),
|
|
678
|
+
false);
|
|
679
|
+
wrapped.cause = e;
|
|
680
|
+
throw wrapped;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return {
|
|
685
|
+
send: send,
|
|
686
|
+
transport: transport,
|
|
687
|
+
defaults: defaults,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
module.exports = {
|
|
692
|
+
create: create,
|
|
693
|
+
MailError: MailError,
|
|
694
|
+
transports: {
|
|
695
|
+
console: consoleTransport,
|
|
696
|
+
memory: memoryTransport,
|
|
697
|
+
smtp: smtpTransport,
|
|
698
|
+
http: httpTransport,
|
|
699
|
+
resend: resendTransport,
|
|
700
|
+
},
|
|
701
|
+
};
|