@blamejs/core 0.4.21 → 0.4.22
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 +1 -0
- package/lib/mail.js +156 -19
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,7 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- **0.4.21** (2026-04-30) — b.queue: repeat-in-queue (cron) + parent-child Flows
|
|
11
12
|
- **0.4.20** (2026-04-30) — b.queue + b.jobs: priority, rate-limit, progress
|
|
12
13
|
- **0.4.19** (2026-04-30) — b.router: schema-validated routes + OpenAPI gen
|
|
13
14
|
- **0.4.18** (2026-04-30) — cookieJar forensic-test strengthening (real crypto, replay, nonce)
|
package/lib/mail.js
CHANGED
|
@@ -38,9 +38,24 @@
|
|
|
38
38
|
* text: "plain body" (at least one of text/html)
|
|
39
39
|
* html: "<p>...</p>"
|
|
40
40
|
* headers: { "X-Custom": "v" } (merged with defaults)
|
|
41
|
+
* attachments: [{
|
|
42
|
+
* filename: "report.pdf", // required
|
|
43
|
+
* content: buf, // Buffer or string
|
|
44
|
+
* contentType: "application/pdf", // default application/octet-stream
|
|
45
|
+
* contentDisposition: "attachment", // or "inline"
|
|
46
|
+
* cid: "logo-1", // for inline images:
|
|
47
|
+
* // <img src="cid:logo-1">
|
|
48
|
+
* }, ...]
|
|
41
49
|
* }
|
|
42
50
|
* → whatever the transport returned
|
|
43
51
|
*
|
|
52
|
+
* When attachments are present the SMTP transport wraps the body in
|
|
53
|
+
* multipart/mixed; text+html bodies still use multipart/alternative
|
|
54
|
+
* inside. Resend's http preset forwards attachments via the Resend API
|
|
55
|
+
* shape (base64 content + content_id for inline). Operators wiring
|
|
56
|
+
* other vendors against httpTransport include attachments in their
|
|
57
|
+
* own serialize() per-vendor.
|
|
58
|
+
*
|
|
44
59
|
* Validation surface uses MailError (FrameworkError subclass) with
|
|
45
60
|
* permanent flag. Distinct codes per failure: missing-to, missing-from,
|
|
46
61
|
* missing-body, invalid-recipient, transport-failed, smtp-*, http-*,
|
|
@@ -128,6 +143,52 @@ function _validateMessage(message) {
|
|
|
128
143
|
throw new MailError("mail/missing-body",
|
|
129
144
|
"message must include at least one of text or html", true);
|
|
130
145
|
}
|
|
146
|
+
|
|
147
|
+
if (message.attachments !== undefined) {
|
|
148
|
+
if (!Array.isArray(message.attachments)) {
|
|
149
|
+
throw new MailError("mail/invalid-attachments",
|
|
150
|
+
"message.attachments must be an array", true);
|
|
151
|
+
}
|
|
152
|
+
for (var i = 0; i < message.attachments.length; i++) {
|
|
153
|
+
var att = message.attachments[i];
|
|
154
|
+
if (!att || typeof att !== "object") {
|
|
155
|
+
throw new MailError("mail/invalid-attachment",
|
|
156
|
+
"attachments[" + i + "] must be an object", true);
|
|
157
|
+
}
|
|
158
|
+
if (typeof att.filename !== "string" || att.filename.length === 0) {
|
|
159
|
+
throw new MailError("mail/invalid-attachment",
|
|
160
|
+
"attachments[" + i + "].filename must be a non-empty string", true);
|
|
161
|
+
}
|
|
162
|
+
if (/[\r\n\0]/.test(att.filename)) {
|
|
163
|
+
throw new MailError("mail/invalid-attachment",
|
|
164
|
+
"attachments[" + i + "].filename contains forbidden control characters", true);
|
|
165
|
+
}
|
|
166
|
+
if (att.content === undefined || att.content === null) {
|
|
167
|
+
throw new MailError("mail/invalid-attachment",
|
|
168
|
+
"attachments[" + i + "].content is required (Buffer or string)", true);
|
|
169
|
+
}
|
|
170
|
+
if (!Buffer.isBuffer(att.content) && typeof att.content !== "string") {
|
|
171
|
+
throw new MailError("mail/invalid-attachment",
|
|
172
|
+
"attachments[" + i + "].content must be a Buffer or string", true);
|
|
173
|
+
}
|
|
174
|
+
if (att.contentType !== undefined &&
|
|
175
|
+
(typeof att.contentType !== "string" || /[\r\n\0]/.test(att.contentType))) {
|
|
176
|
+
throw new MailError("mail/invalid-attachment",
|
|
177
|
+
"attachments[" + i + "].contentType must be a clean string", true);
|
|
178
|
+
}
|
|
179
|
+
if (att.contentDisposition !== undefined &&
|
|
180
|
+
att.contentDisposition !== "attachment" &&
|
|
181
|
+
att.contentDisposition !== "inline") {
|
|
182
|
+
throw new MailError("mail/invalid-attachment",
|
|
183
|
+
"attachments[" + i + "].contentDisposition must be 'attachment' or 'inline'", true);
|
|
184
|
+
}
|
|
185
|
+
if (att.cid !== undefined &&
|
|
186
|
+
(typeof att.cid !== "string" || /[\r\n\0<>]/.test(att.cid))) {
|
|
187
|
+
throw new MailError("mail/invalid-attachment",
|
|
188
|
+
"attachments[" + i + "].cid must be a clean string (no <>)", true);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
131
192
|
}
|
|
132
193
|
|
|
133
194
|
function _mergeMessage(defaults, message) {
|
|
@@ -201,6 +262,59 @@ function memoryTransport() {
|
|
|
201
262
|
// cleartext port the transport always issues STARTTLS and refuses
|
|
202
263
|
// to send AUTH or DATA in cleartext if the upgrade is rejected.
|
|
203
264
|
|
|
265
|
+
function _newBoundary(label) {
|
|
266
|
+
return "blamejs-" + label + "-" + Date.now() + "-" + Math.floor(Math.random() * 1e9);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// base64-encode the buffer with line wrapping at 76 chars (RFC 2045
|
|
270
|
+
// §6.8). Most clients tolerate longer lines but the spec maximum is
|
|
271
|
+
// 998 octets per line; sticking to 76 keeps everyone happy.
|
|
272
|
+
function _base64Wrap(buf) {
|
|
273
|
+
var b64 = buf.toString("base64");
|
|
274
|
+
var lines = [];
|
|
275
|
+
for (var i = 0; i < b64.length; i += 76) lines.push(b64.slice(i, i + 76));
|
|
276
|
+
return lines.join("\r\n");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function _buildAttachmentPart(att) {
|
|
280
|
+
var content = Buffer.isBuffer(att.content) ? att.content : Buffer.from(String(att.content), "utf8");
|
|
281
|
+
var contentType = att.contentType || "application/octet-stream";
|
|
282
|
+
var disposition = att.contentDisposition || (att.cid ? "inline" : "attachment");
|
|
283
|
+
var lines = [];
|
|
284
|
+
lines.push("Content-Type: " + contentType + '; name="' + att.filename + '"');
|
|
285
|
+
lines.push("Content-Transfer-Encoding: base64");
|
|
286
|
+
lines.push("Content-Disposition: " + disposition + '; filename="' + att.filename + '"');
|
|
287
|
+
if (att.cid) lines.push("Content-ID: <" + att.cid + ">");
|
|
288
|
+
lines.push("");
|
|
289
|
+
lines.push(_base64Wrap(content));
|
|
290
|
+
return lines.join("\r\n");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function _buildBodyPart(message) {
|
|
294
|
+
// Text + html → multipart/alternative; otherwise single-part.
|
|
295
|
+
if (message.text && message.html) {
|
|
296
|
+
var altBoundary = _newBoundary("alt");
|
|
297
|
+
return {
|
|
298
|
+
contentType: 'multipart/alternative; boundary="' + altBoundary + '"',
|
|
299
|
+
body: [
|
|
300
|
+
"--" + altBoundary,
|
|
301
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
302
|
+
"",
|
|
303
|
+
message.text,
|
|
304
|
+
"--" + altBoundary,
|
|
305
|
+
"Content-Type: text/html; charset=utf-8",
|
|
306
|
+
"",
|
|
307
|
+
message.html,
|
|
308
|
+
"--" + altBoundary + "--",
|
|
309
|
+
].join("\r\n"),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (message.html) {
|
|
313
|
+
return { contentType: "text/html; charset=utf-8", body: message.html };
|
|
314
|
+
}
|
|
315
|
+
return { contentType: "text/plain; charset=utf-8", body: message.text || "" };
|
|
316
|
+
}
|
|
317
|
+
|
|
204
318
|
function _buildRfc822(message) {
|
|
205
319
|
var headers = [];
|
|
206
320
|
headers.push("From: " + message.from);
|
|
@@ -221,27 +335,32 @@ function _buildRfc822(message) {
|
|
|
221
335
|
}
|
|
222
336
|
}
|
|
223
337
|
|
|
338
|
+
var attachments = Array.isArray(message.attachments) ? message.attachments : [];
|
|
339
|
+
var inner = _buildBodyPart(message);
|
|
224
340
|
var body;
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
headers.push(
|
|
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;
|
|
341
|
+
|
|
342
|
+
if (attachments.length === 0) {
|
|
343
|
+
headers.push("Content-Type: " + inner.contentType);
|
|
344
|
+
body = inner.body;
|
|
242
345
|
} else {
|
|
243
|
-
|
|
244
|
-
|
|
346
|
+
// multipart/mixed: first part is the body (single or alternative),
|
|
347
|
+
// subsequent parts are the attachments. Inline disposition + Content-ID
|
|
348
|
+
// is interpreted correctly by every major client even inside mixed;
|
|
349
|
+
// operators with strict-RFC-2387 multipart/related needs subscribe
|
|
350
|
+
// to a future patch when demand surfaces.
|
|
351
|
+
var mixedBoundary = _newBoundary("mixed");
|
|
352
|
+
headers.push('Content-Type: multipart/mixed; boundary="' + mixedBoundary + '"');
|
|
353
|
+
var parts = [];
|
|
354
|
+
parts.push("--" + mixedBoundary);
|
|
355
|
+
parts.push("Content-Type: " + inner.contentType);
|
|
356
|
+
parts.push("");
|
|
357
|
+
parts.push(inner.body);
|
|
358
|
+
for (var ai = 0; ai < attachments.length; ai++) {
|
|
359
|
+
parts.push("--" + mixedBoundary);
|
|
360
|
+
parts.push(_buildAttachmentPart(attachments[ai]));
|
|
361
|
+
}
|
|
362
|
+
parts.push("--" + mixedBoundary + "--");
|
|
363
|
+
body = parts.join("\r\n");
|
|
245
364
|
}
|
|
246
365
|
|
|
247
366
|
// Normalize line endings then dot-stuff per SMTP transparency.
|
|
@@ -587,6 +706,21 @@ function resendTransport(opts) {
|
|
|
587
706
|
if (message.html) payload.html = message.html;
|
|
588
707
|
if (message.text) payload.text = message.text;
|
|
589
708
|
if (message.headers) payload.headers = message.headers;
|
|
709
|
+
// Resend attachments shape: [{ filename, content (base64 string),
|
|
710
|
+
// contentType?, content_id? }]. Inline images via cid go through
|
|
711
|
+
// the content_id field (Resend renders <img src="cid:...">).
|
|
712
|
+
if (Array.isArray(message.attachments) && message.attachments.length > 0) {
|
|
713
|
+
payload.attachments = message.attachments.map(function (att) {
|
|
714
|
+
var buf = Buffer.isBuffer(att.content) ? att.content : Buffer.from(String(att.content), "utf8");
|
|
715
|
+
var entry = {
|
|
716
|
+
filename: att.filename,
|
|
717
|
+
content: buf.toString("base64"),
|
|
718
|
+
};
|
|
719
|
+
if (att.contentType) entry.contentType = att.contentType;
|
|
720
|
+
if (att.cid) entry.content_id = att.cid;
|
|
721
|
+
return entry;
|
|
722
|
+
});
|
|
723
|
+
}
|
|
590
724
|
return { body: JSON.stringify(payload) };
|
|
591
725
|
},
|
|
592
726
|
interpret: function (res) {
|
|
@@ -694,6 +828,9 @@ function create(opts) {
|
|
|
694
828
|
module.exports = {
|
|
695
829
|
create: create,
|
|
696
830
|
MailError: MailError,
|
|
831
|
+
// Test-only export: lets unit tests inspect the wire format without
|
|
832
|
+
// standing up a TLS-capable SMTP fixture. Operators don't call this.
|
|
833
|
+
_buildRfc822ForTest: _buildRfc822,
|
|
697
834
|
transports: {
|
|
698
835
|
console: consoleTransport,
|
|
699
836
|
memory: memoryTransport,
|