@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.
Files changed (3) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/lib/mail.js +156 -19
  3. 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
- 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;
341
+
342
+ if (attachments.length === 0) {
343
+ headers.push("Content-Type: " + inner.contentType);
344
+ body = inner.body;
242
345
  } else {
243
- headers.push("Content-Type: text/plain; charset=utf-8");
244
- body = message.text || "";
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.4.21",
3
+ "version": "0.4.22",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",