@blamejs/core 0.11.37 → 0.11.38

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.11.x
10
10
 
11
+ - v0.11.38 (2026-05-21) — **`b.mail.server.jmap.emailSubmissionSetHandler` — reference JMAP EmailSubmission/set composing `b.mail.send.deliver`.** Reference implementation of JMAP `EmailSubmission/set` (RFC 8621 §7.5) that composes `b.mail.send.deliver` (v0.11.24). Operators wire it as a method handler on `b.mail.server.jmap.create({ methods: ... })`. The handler walks `args.create`, validates each EmailSubmission's shape against the RFC 8621 §7.5 vocabulary (`identityId` / `emailId` / `envelope.mailFrom.email` / `envelope.rcptTo[]`), looks up the referenced Email blob via an operator-supplied `lookupEmail(emailId, accountId, actor)`, hands the RFC 822 body to `deliver(envelope)`, and maps the result into JMAP `deliveryStatus` (`recipient → { smtpReply, delivered, displayed }` per RFC 8621 §7.4). Closes the v0.11.24 deferral on a reference JMAP→deliver bridge. **Added:** *`b.mail.server.jmap.emailSubmissionSetHandler(opts)` factory* — Returns an async `(actor, args, ctx) → result` function suitable for the `opts.methods` map on `b.mail.server.jmap.create`. Required opts: `deliver` (a `b.mail.send.deliver` instance), `lookupEmail` (async `(emailId, accountId, actor) → Buffer|null`), `identities` (sync `(accountId) → Array<{ id, email }>`). Optional: `onCreated(subId, submission, accountId)` for persistence, `onDestroyed(subId, accountId)`, `onCancel(subId, accountId) → boolean` for undo support, `maxRecipients` (default 1000). · *Full RFC 8621 §7.5 error vocabulary* — Refusals map to the spec's typed `notCreated` shape with JMAP-namespaced types: `identityNotFound` (unknown identityId), `emailNotFound` (lookupEmail returned null), `forbiddenMailFrom` (envelope.mailFrom doesn't match identity.email), `invalidRecipients` (malformed envelope.rcptTo[i].email), `noRecipients` (empty rcptTo), `tooManyRecipients` (exceeds maxRecipients), `invalidProperties` (missing required keys). Per RFC 8621 §3.6.2, only `undoStatus` is honored in `args.update`; non-canceled values + non-undoStatus keys return `invalidProperties`. · *Delivery-result → JMAP deliveryStatus mapping* — `b.mail.send.deliver`'s `{ delivered, deferred, failed }` outcomes translate into the per-recipient JMAP deliveryStatus shape: `delivered` → `{ smtpReply, delivered: "yes" }`; `deferred` → `{ smtpReply, delivered: "queued" }`; `failed` → `{ smtpReply, delivered: "no" }`. `displayed` is `unknown` until a downstream MDN (RFC 9007) reports otherwise — operators with MDN ingest update via `EmailSubmission/set`'s update branch. · *Undo via `onCancel` hook* — `args.update[subId] = { undoStatus: "canceled" }` invokes `opts.onCancel(subId, accountId) → boolean`. Operators backing the JMAP server with a deferred-send queue (e.g. `b.outbox`) return true when the cancel succeeded; the handler refuses with `cannotUnsend` when `onCancel` isn't configured (operator hasn't wired a queue-based send model). · *Audit event on every set* — Emits `mail.jmap.emailsubmission.set` with `{ accountId, created, notCreated, updated, notUpdated, destroyed, notDestroyed }` counts. The metadata never carries recipient email addresses or RFC 822 body bytes — those stay in the operator's deliver primitive's per-host audit stream. **Security:** *Identity binding refuses spoofed `envelope.mailFrom`* — RFC 8621 §7.5.1.2 — every create gates `envelope.mailFrom.email` against the identity record's `email` field. The reference handler refuses with `forbiddenMailFrom` when they differ. Operators wiring a delegation model populate the identity's `mayDelegate` / per-domain authorized-senders list and supply the corresponding `identities(accountId)` lookup; the reference handler treats the lookup output as the trust source. · *Recipient count cap matches `b.mail.send.deliver`* — Default `maxRecipients = 1000` aligns with `b.mail.send.deliver`'s recipient-fan-out cap; operators reduce it for tighter postures (e.g. 50 for transactional-mail accounts) without weakening the deliver primitive's own gate. **Detectors:** *Two new KNOWN_CLUSTERS family-subset entries* — The new handler's opts-walk shingle structurally resembles `lib/importmap-integrity.js:build` (SRI module-map walk) and `lib/middleware/security-headers.js:create` (header-map walk). Added explanatory family-subset entries documenting why each primitive's per-entry body validates a distinct spec vocabulary (W3C Importmap-Integrity vs RFC 8621 §7.5 EmailSubmission vs HTTP header-value sanitisation) so the dup detector can't surface the false-positive again. **References:** [RFC 8621 §7 (JMAP EmailSubmission)](https://www.rfc-editor.org/rfc/rfc8621.html#section-7) · [RFC 8621 §7.5 (EmailSubmission/set)](https://www.rfc-editor.org/rfc/rfc8621.html#section-7.5) · [RFC 8621 §7.4 (deliveryStatus shape)](https://www.rfc-editor.org/rfc/rfc8621.html#section-7.4) · [RFC 8620 §3.6.1 (JMAP error vocabulary)](https://www.rfc-editor.org/rfc/rfc8620.html#section-3.6.1)
12
+
11
13
  - v0.11.37 (2026-05-21) — **`b.calendar` VJOURNAL ↔ JSCalendar Note (RFC 5545 §3.6.3).** Closes the v0.11.31 deferral on VJOURNAL. `b.calendar.fromIcal` now recognises VJOURNAL components and maps them to JSCalendar-shaped Note objects (`@type: "Note"`). `b.calendar.toIcal` emits a VJOURNAL envelope when the input `@type` is `Note`. `b.calendar.validate` adds Note-specific shape rules: optional `start` LocalDateTime, no `duration` / `due` / `progress` / `percentComplete` / `progressUpdated` (those are Event / Task-only properties), optional `status` from the RFC 5545 §3.8.1.11 VJOURNAL vocabulary (`draft` | `final` | `cancelled`). VJOURNAL is the only iCalendar component that may carry multiple DESCRIPTION properties — Note preserves operator-visible boundaries by joining them with a blank-line separator. A VCALENDAR carrying VEVENT + VTODO + VJOURNAL children now returns a mixed `Event` + `Task` + `Note` array. **Added:** *`b.calendar.fromIcal` maps VJOURNAL → JSCalendar Note* — VJOURNAL components in the VCALENDAR map to `@type: "Note"` objects with `uid` / `updated` / `title` (SUMMARY) / `description` (DESCRIPTION) / `start` (DTSTART → LocalDateTime, optional) / `timeZone` / `status` (lower-cased STATUS) / `locations` / `recurrenceRules`. UTC DTSTART maps to `timeZone: "Etc/UTC"` the same way DTSTART does for Event. · *`b.calendar.toIcal` emits VJOURNAL when `@type === "Note"`* — The envelope is `BEGIN:VJOURNAL` / `END:VJOURNAL` instead of `BEGIN:VEVENT` or `BEGIN:VTODO`. Note's `status` round-trips uppercased per RFC 5545 §3.8.1.11. Note does NOT emit DURATION / DUE / PERCENT-COMPLETE / COMPLETED on the wire (those properties are forbidden on VJOURNAL per RFC 5545 §3.6.3 grammar). · *`b.calendar.validate` learns Note-specific shape rules (RFC 5545 §3.6.3)* — Note's `start` must be a LocalDateTime when present. Setting `duration`, `due`, `progress`, `percentComplete`, or `progressUpdated` on a Note is refused (those are Event / Task-only properties). `status` must be in the `JSCAL_NOTE_STATUS` catalogue (`draft` | `final` | `cancelled` — different from the Task progress vocabulary). Structured `CalendarError` codes: `calendar/bad-note-status` plus reuse of `calendar/bad-due`, `calendar/bad-progress`, `calendar/bad-duration`, `calendar/bad-percent`, `calendar/bad-progress-updated` for the forbidden-property refusals. · *Multiple DESCRIPTION properties preserved* — VJOURNAL is the only iCalendar component permitted to carry multiple DESCRIPTION properties (one per discrete journal entry). `fromIcal` joins them into a single Note.description with `\n\n` between entries, preserving the operator-visible boundary. Single-DESCRIPTION VJOURNALs map to the literal string with no join. · *Mixed-component VCALENDAR returns array of Event + Task + Note shapes* — A VCALENDAR carrying VEVENT + VTODO + VJOURNAL children now returns an Array — events first, tasks second, journals third, in their declared order. The single-component shortcut (returning the bare object) still applies when only one component is present. · *`JSCAL_TYPES.Note` and `JSCAL_NOTE_STATUS` exports* — `b.calendar.JSCAL_TYPES.Note === "Note"` (the discriminator string). `b.calendar.JSCAL_NOTE_STATUS` exposes the `draft` / `final` / `cancelled` vocabulary as a frozen object for operator-side enum lookups. **Changed:** *JSCalendar `Note` is a blamejs-recognised extension* — RFC 8984 §1.2 only enumerates `Event`, `Task`, and `Group` as discriminator values. `Note` is not formally an RFC 8984 type — blamejs adopts it as a recognised extension shape for VJOURNAL round-trip interop. Operators interoperating with strict RFC 8984 consumers should map Note → Group + a vendor-namespaced property before exchange. **References:** [RFC 5545 §3.6.3 (iCalendar VJOURNAL component)](https://www.rfc-editor.org/rfc/rfc5545.html#section-3.6.3) · [RFC 5545 §3.8.1.11 (STATUS — DRAFT/FINAL/CANCELLED on VJOURNAL)](https://www.rfc-editor.org/rfc/rfc5545.html#section-3.8.1.11) · [RFC 8984 §1.2 (JSCalendar @type discriminator)](https://www.rfc-editor.org/rfc/rfc8984.html#section-1.2)
12
14
 
13
15
  - v0.11.36 (2026-05-21) — **`b.calendar.expandRecurrence` picks up BYWEEKNO / BYYEARDAY / BYHOUR / BYMINUTE / BYSECOND filters (RFC 5545 §3.3.10).** Closes the v0.11.31 deferral on the time-of-day and year-relative BY* filters. `expandRecurrence` now honours `byWeekNo` (ISO 8601 1..53 with negative-from-end semantics), `byYearDay` (1..366 with negative-from-end), `byHour` (0..23), `byMinute` (0..59), and `bySecond` (0..60 — covers POSIX leap-second representation). The expansion still operates as a per-step filter (stepping at the rule's FREQ, then dropping candidates that fail the BY* predicates) — BYSETPOS remains deferred-with-condition because it requires expanding ALL candidates within a FREQ interval and picking the Nth, which is a structural restructure of the expand loop. Operators with `last weekday of month`-style needs continue to see the same defer note from v0.11.31. **Added:** *`byWeekNo` filter — ISO 8601 week numbers* — `recurrenceRules[i].byWeekNo: [1, 53, -1]` filters candidates to the named ISO 8601 weeks. Negative values count from the end of the year (`-1` = last ISO week, which may be week 52 or week 53 depending on the year). The ISO week-of-year calculation matches the canonical algorithm — week 1 is the week containing the first Thursday of the year. · *`byYearDay` filter — day-of-year (1..366 / -1..-366)* — `recurrenceRules[i].byYearDay: [1, 366, -1]` filters by ordinal day-of-year. Negative values count from the end of the year, accounting for leap-year length (366 vs 365). With a `frequency: "daily"` rule + `byYearDay: [1]` the expander emits Jan 1 of each year. · *`byHour` / `byMinute` / `bySecond` time-of-day filters* — `byHour: [9, 17]` filters candidates by UTC hour-of-day (0..23). `byMinute: [0, 30]` (0..59). `bySecond: [0, 30, 60]` (0..60 — the leap-second representation in POSIX time). Most useful with sub-day frequencies — `frequency: "hourly"` + `byHour: [9, 17]` emits twice-daily at 9am and 5pm. · *Negative BY* semantics per RFC 5545 §3.3.10* — `byWeekNo: [-1]` matches the last ISO week of the year (computed via the Dec-28 anchor — Dec 28 always falls in the last ISO week). `byYearDay: [-1]` matches the last day of the year (uses the Gregorian leap-year rule to determine whether 365 or 366 is the correct ordinal). Both negative-value paths covered by tests. **Security:** *Same step-budget cap from v0.11.31 applies* — Sparse BY* filters (e.g. `byYearDay: [1]` with `frequency: "daily"` — only 1 in 365 candidates matches) still loop within the `MAX_EXPAND_INSTANCES * 366` step budget; the 10-year `MAX_EXPAND_SPAN_MS` window cap also applies. Operators can't construct a BY*-filter that loops past the bounded budget. · *Integer-range validation on every BY* value* — `byWeekNo` rejects values outside ±53. `byYearDay` rejects outside ±366. `byHour` 0..23. `byMinute` 0..59. `bySecond` 0..60. Adversarial-shape values silently drop from the set (no error — the rule continues with the surviving values per RFC 5545's tolerant grammar). **References:** [RFC 5545 §3.3.10 (RRULE — BYWEEKNO / BYYEARDAY / BYHOUR / BYMINUTE / BYSECOND)](https://www.rfc-editor.org/rfc/rfc5545.html#section-3.3.10) · [RFC 8984 §4.3.2 (JSCalendar RecurrenceRule)](https://www.rfc-editor.org/rfc/rfc8984.html#section-4.3.2) · [ISO 8601 (Week numbering)](https://www.iso.org/iso-8601-date-and-time-format.html)
@@ -1214,7 +1214,352 @@ function create(opts) {
1214
1214
  };
1215
1215
  }
1216
1216
 
1217
+ /**
1218
+ * @primitive b.mail.server.jmap.emailSubmissionSetHandler
1219
+ * @signature b.mail.server.jmap.emailSubmissionSetHandler(opts)
1220
+ * @since 0.11.38
1221
+ * @status stable
1222
+ * @related b.mail.server.jmap.create
1223
+ * @compliance gdpr, soc2
1224
+ *
1225
+ * Reference implementation of JMAP `EmailSubmission/set` (RFC 8621 §7.5)
1226
+ * that composes `b.mail.send.deliver`. Returns an async method-handler
1227
+ * suitable for plumbing into `b.mail.server.jmap.create({ methods: ... })`.
1228
+ *
1229
+ * The handler:
1230
+ *
1231
+ * 1. Walks `args.create` per RFC 8621 §7.5. For each EmailSubmission:
1232
+ * - Refuses `identityId` not registered in `opts.identities(accountId)`.
1233
+ * - Refuses `emailId` absent — calls `opts.lookupEmail(emailId,
1234
+ * accountId, actor)` to fetch the RFC 822 blob (refuses
1235
+ * `emailNotFound` when null).
1236
+ * - Refuses missing or oversize `envelope.rcptTo` (max 1000 per
1237
+ * the same recipient cap `b.mail.send.deliver` enforces).
1238
+ * - Validates `envelope.mailFrom.email` matches the identity's
1239
+ * authorized addresses (`forbiddenMailFrom` per RFC 8621
1240
+ * §7.5.1.2 when not).
1241
+ * 2. Hands the RFC 822 blob to the supplied `opts.deliver(envelope)`
1242
+ * (a `b.mail.send.deliver.create()` instance).
1243
+ * 3. Maps `deliver`'s `{ delivered, deferred, failed }` result into
1244
+ * JMAP `deliveryStatus` (`recipient → { smtpReply, delivered,
1245
+ * displayed }` per RFC 8621 §7.4).
1246
+ * 4. Calls `opts.onCreated(subId, submission, accountId)` so the
1247
+ * operator can persist the EmailSubmission record (state survives
1248
+ * across JMAP requests via `EmailSubmission/get`).
1249
+ *
1250
+ * `args.destroy` removes EmailSubmission records via
1251
+ * `opts.onDestroyed(subId, accountId)` — the delivery itself cannot
1252
+ * be unsent at this point; `destroy` only removes the JMAP-visible
1253
+ * record.
1254
+ *
1255
+ * `args.update` is honored only for the `undoStatus: "canceled"`
1256
+ * transition per RFC 8621 §7.5.2 (operators with a queue-based
1257
+ * deferred-send model wire `opts.onCancel(subId, accountId)`; the
1258
+ * reference handler refuses with `cannotUnsend` when no `onCancel`
1259
+ * is configured).
1260
+ *
1261
+ * @opts
1262
+ * deliver: async function (envelope), // b.mail.send.deliver instance (REQUIRED)
1263
+ * lookupEmail: async function (emailId, accountId, actor) → Buffer|null, (REQUIRED)
1264
+ * identities: function (accountId) → [ { id, email, mayDelegate } ], (REQUIRED)
1265
+ * onCreated: async function (subId, submission, accountId), (optional)
1266
+ * onDestroyed: async function (subId, accountId), (optional)
1267
+ * onCancel: async function (subId, accountId) → boolean, (optional — undo support)
1268
+ * maxRecipients: number, // default 1000
1269
+ *
1270
+ * @example
1271
+ * var deliver = b.mail.send.deliver({ hostname: "mta.example.com" });
1272
+ * var emailSubSet = b.mail.server.jmap.emailSubmissionSetHandler({
1273
+ * deliver: deliver,
1274
+ * lookupEmail: async function (emailId, accountId) {
1275
+ * return mailStore.fetchBlob(accountId, emailId);
1276
+ * },
1277
+ * identities: function (accountId) {
1278
+ * return [{ id: "I1", email: "ops@example.com" }];
1279
+ * },
1280
+ * onCreated: async function (id, sub, accountId) { return; },
1281
+ * });
1282
+ *
1283
+ * var jmap = b.mail.server.jmap.create({
1284
+ * mailStore: store,
1285
+ * accountsFor: async function () { return { primaryAccounts: {}, accounts: {} }; },
1286
+ * methods: { "EmailSubmission/set": emailSubSet },
1287
+ * });
1288
+ */
1289
+ function emailSubmissionSetHandler(opts) {
1290
+ validateOpts.requireObject(opts, "mail.server.jmap.emailSubmissionSetHandler",
1291
+ MailServerJmapError, "mail-server-jmap/bad-opts");
1292
+ if (typeof opts.deliver !== "function") {
1293
+ throw new MailServerJmapError("mail-server-jmap/no-deliver",
1294
+ "emailSubmissionSetHandler: opts.deliver async function is required " +
1295
+ "(compose b.mail.send.deliver.create({ ... }))");
1296
+ }
1297
+ if (typeof opts.lookupEmail !== "function") {
1298
+ throw new MailServerJmapError("mail-server-jmap/no-lookup-email",
1299
+ "emailSubmissionSetHandler: opts.lookupEmail(emailId, accountId, actor) async function is required");
1300
+ }
1301
+ if (typeof opts.identities !== "function") {
1302
+ throw new MailServerJmapError("mail-server-jmap/no-identities",
1303
+ "emailSubmissionSetHandler: opts.identities(accountId) function is required (returns Array<{id,email}>)");
1304
+ }
1305
+ var maxRecipients = opts.maxRecipients || 1000; // allow:raw-byte-literal — recipient cap mirrors b.mail.send.deliver
1306
+ if (typeof maxRecipients !== "number" || !isFinite(maxRecipients) || maxRecipients < 1) {
1307
+ throw new MailServerJmapError("mail-server-jmap/bad-max-recipients",
1308
+ "emailSubmissionSetHandler: opts.maxRecipients MUST be a positive integer");
1309
+ }
1310
+
1311
+ return async function emailSubmissionSet(actor, args, _ctx) {
1312
+ if (!args || typeof args !== "object" || typeof args.accountId !== "string") {
1313
+ throw new MailServerJmapError("urn:ietf:params:jmap:error:invalidArguments",
1314
+ "EmailSubmission/set: accountId is required");
1315
+ }
1316
+ var accountId = args.accountId;
1317
+ var created = {};
1318
+ var notCreated = {};
1319
+ var updated = {};
1320
+ var notUpdated = {};
1321
+ var destroyed = [];
1322
+ var notDestroyed = {};
1323
+
1324
+ // ---- create branch (RFC 8621 §7.5.1) ----------------------------------
1325
+ if (args.create && typeof args.create === "object" && !Array.isArray(args.create)) {
1326
+ var createKeys = Object.keys(args.create);
1327
+ for (var ci = 0; ci < createKeys.length; ci += 1) {
1328
+ var clientId = createKeys[ci];
1329
+ var sub = args.create[clientId];
1330
+ try {
1331
+ var result = await _processCreate(actor, accountId, sub);
1332
+ created[clientId] = result;
1333
+ if (typeof opts.onCreated === "function") {
1334
+ try { await opts.onCreated(result.id, result, accountId); }
1335
+ catch (_e) { /* drop-silent — persistence is operator side-effect */ }
1336
+ }
1337
+ } catch (err) {
1338
+ notCreated[clientId] = _jmapErrorShape(err);
1339
+ }
1340
+ }
1341
+ }
1342
+
1343
+ // ---- update branch (RFC 8621 §7.5.2 — undoStatus="canceled" only) -----
1344
+ if (args.update && typeof args.update === "object" && !Array.isArray(args.update)) {
1345
+ var updateKeys = Object.keys(args.update);
1346
+ for (var ui = 0; ui < updateKeys.length; ui += 1) {
1347
+ var subId = updateKeys[ui];
1348
+ var patch = args.update[subId];
1349
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
1350
+ notUpdated[subId] = { type: "invalidPatch", description: "patch must be an object" };
1351
+ continue;
1352
+ }
1353
+ var patchKeys = Object.keys(patch);
1354
+ // RFC 8621 §7.5.2 — only `undoStatus` is mutable post-create.
1355
+ var nonUndo = patchKeys.filter(function (k) { return k !== "undoStatus"; });
1356
+ if (nonUndo.length > 0) {
1357
+ notUpdated[subId] = {
1358
+ type: "invalidProperties",
1359
+ properties: nonUndo,
1360
+ description: "only undoStatus may be updated on an EmailSubmission",
1361
+ };
1362
+ continue;
1363
+ }
1364
+ if (patch.undoStatus !== "canceled") {
1365
+ notUpdated[subId] = {
1366
+ type: "invalidProperties",
1367
+ properties: ["undoStatus"],
1368
+ description: "only undoStatus='canceled' is honored",
1369
+ };
1370
+ continue;
1371
+ }
1372
+ if (typeof opts.onCancel !== "function") {
1373
+ notUpdated[subId] = {
1374
+ type: "cannotUnsend",
1375
+ description: "undo not supported (opts.onCancel was not configured)",
1376
+ };
1377
+ continue;
1378
+ }
1379
+ try {
1380
+ var ok = await opts.onCancel(subId, accountId);
1381
+ if (ok) updated[subId] = null;
1382
+ else notUpdated[subId] = { type: "cannotUnsend" };
1383
+ } catch (err) {
1384
+ notUpdated[subId] = _jmapErrorShape(err);
1385
+ }
1386
+ }
1387
+ }
1388
+
1389
+ // ---- destroy branch (RFC 8621 §7.5.3) ---------------------------------
1390
+ if (Array.isArray(args.destroy)) {
1391
+ for (var di = 0; di < args.destroy.length; di += 1) {
1392
+ var destroyId = args.destroy[di];
1393
+ if (typeof destroyId !== "string" || destroyId.length === 0) {
1394
+ notDestroyed[String(destroyId)] = { type: "invalidArguments" };
1395
+ continue;
1396
+ }
1397
+ if (typeof opts.onDestroyed === "function") {
1398
+ try {
1399
+ await opts.onDestroyed(destroyId, accountId);
1400
+ destroyed.push(destroyId);
1401
+ } catch (err) {
1402
+ notDestroyed[destroyId] = _jmapErrorShape(err);
1403
+ }
1404
+ } else {
1405
+ // No persistence wired — accept the destroy as a noop so the
1406
+ // operator's clients can clean up client-side state.
1407
+ destroyed.push(destroyId);
1408
+ }
1409
+ }
1410
+ }
1411
+
1412
+ _emit("mail.jmap.emailsubmission.set", {
1413
+ accountId: accountId,
1414
+ created: Object.keys(created).length,
1415
+ notCreated: Object.keys(notCreated).length,
1416
+ updated: Object.keys(updated).length,
1417
+ notUpdated: Object.keys(notUpdated).length,
1418
+ destroyed: destroyed.length,
1419
+ notDestroyed: Object.keys(notDestroyed).length,
1420
+ });
1421
+
1422
+ return {
1423
+ accountId: accountId,
1424
+ oldState: args.ifInState || null,
1425
+ newState: bCrypto.generateToken(16), // allow:raw-byte-literal — opaque state token length
1426
+ created: Object.keys(created).length > 0 ? created : null,
1427
+ notCreated: Object.keys(notCreated).length > 0 ? notCreated : null,
1428
+ updated: Object.keys(updated).length > 0 ? updated : null,
1429
+ notUpdated: Object.keys(notUpdated).length > 0 ? notUpdated : null,
1430
+ destroyed: destroyed.length > 0 ? destroyed : null,
1431
+ notDestroyed: Object.keys(notDestroyed).length > 0 ? notDestroyed : null,
1432
+ };
1433
+ };
1434
+
1435
+ // -------- per-create processing -------------------------------------------
1436
+ async function _processCreate(actor, accountId, sub) {
1437
+ if (!sub || typeof sub !== "object" || Array.isArray(sub)) {
1438
+ throw _err("invalidArguments", "EmailSubmission must be an object");
1439
+ }
1440
+ if (typeof sub.identityId !== "string" || sub.identityId.length === 0) {
1441
+ throw _err("invalidProperties", "identityId is required", ["identityId"]);
1442
+ }
1443
+ if (typeof sub.emailId !== "string" || sub.emailId.length === 0) {
1444
+ throw _err("invalidProperties", "emailId is required", ["emailId"]);
1445
+ }
1446
+ if (!sub.envelope || typeof sub.envelope !== "object" || Array.isArray(sub.envelope)) {
1447
+ throw _err("invalidProperties", "envelope is required", ["envelope"]);
1448
+ }
1449
+ var mailFrom = sub.envelope.mailFrom;
1450
+ if (!mailFrom || typeof mailFrom !== "object" || typeof mailFrom.email !== "string") {
1451
+ throw _err("invalidProperties", "envelope.mailFrom.email is required", ["envelope/mailFrom"]);
1452
+ }
1453
+ if (!Array.isArray(sub.envelope.rcptTo) || sub.envelope.rcptTo.length === 0) {
1454
+ throw _err("noRecipients", "envelope.rcptTo must contain at least one Address");
1455
+ }
1456
+ if (sub.envelope.rcptTo.length > maxRecipients) {
1457
+ throw _err("tooManyRecipients", "rcptTo exceeds " + maxRecipients);
1458
+ }
1459
+ var rcptEmails = [];
1460
+ for (var ri = 0; ri < sub.envelope.rcptTo.length; ri += 1) {
1461
+ var r = sub.envelope.rcptTo[ri];
1462
+ if (!r || typeof r.email !== "string" || r.email.indexOf("@") <= 0) {
1463
+ throw _err("invalidRecipients", "envelope.rcptTo[" + ri + "].email malformed");
1464
+ }
1465
+ rcptEmails.push(r.email);
1466
+ }
1467
+
1468
+ // Identity gate (RFC 8621 §7.5.1.2 forbiddenMailFrom / §7.5.1.3 identityNotFound).
1469
+ var identList = opts.identities(accountId) || [];
1470
+ var identity = null;
1471
+ for (var ii = 0; ii < identList.length; ii += 1) {
1472
+ if (identList[ii].id === sub.identityId) { identity = identList[ii]; break; }
1473
+ }
1474
+ if (!identity) {
1475
+ throw _err("identityNotFound", "no identity " + sub.identityId + " for account " + accountId);
1476
+ }
1477
+ if (identity.email && identity.email !== mailFrom.email) {
1478
+ throw _err("forbiddenMailFrom",
1479
+ "envelope.mailFrom.email does not match identity " + identity.id);
1480
+ }
1481
+
1482
+ // Blob lookup (RFC 8621 §7.5.1.4 emailNotFound).
1483
+ var rfc822 = await opts.lookupEmail(sub.emailId, accountId, actor);
1484
+ if (rfc822 == null) {
1485
+ throw _err("emailNotFound", "emailId " + sub.emailId + " not found");
1486
+ }
1487
+
1488
+ // Hand to b.mail.send.deliver.
1489
+ var deliverResult = await opts.deliver({
1490
+ from: mailFrom.email,
1491
+ to: rcptEmails,
1492
+ rfc822: rfc822,
1493
+ });
1494
+
1495
+ // Map deliver result → JMAP deliveryStatus (RFC 8621 §7.4).
1496
+ var deliveryStatus = Object.create(null);
1497
+ var delivered = deliverResult && deliverResult.delivered ? deliverResult.delivered : [];
1498
+ var deferred = deliverResult && deliverResult.deferred ? deliverResult.deferred : [];
1499
+ var failed = deliverResult && deliverResult.failed ? deliverResult.failed : [];
1500
+ for (var ddi = 0; ddi < delivered.length; ddi += 1) {
1501
+ deliveryStatus[delivered[ddi].recipient] = {
1502
+ smtpReply: delivered[ddi].smtpReply || "250 Accepted",
1503
+ delivered: "yes",
1504
+ displayed: "unknown",
1505
+ };
1506
+ }
1507
+ for (var dfi = 0; dfi < deferred.length; dfi += 1) {
1508
+ deliveryStatus[deferred[dfi].recipient] = {
1509
+ smtpReply: deferred[dfi].smtpReply || "451 Temporary failure",
1510
+ delivered: "queued",
1511
+ displayed: "unknown",
1512
+ };
1513
+ }
1514
+ for (var ffi = 0; ffi < failed.length; ffi += 1) {
1515
+ deliveryStatus[failed[ffi].recipient] = {
1516
+ smtpReply: failed[ffi].smtpReply || "550 Permanent failure",
1517
+ delivered: "no",
1518
+ displayed: "unknown",
1519
+ };
1520
+ }
1521
+
1522
+ var newId = bCrypto.generateToken(12); // allow:raw-byte-literal — JMAP-server-assigned id
1523
+ return {
1524
+ id: newId,
1525
+ identityId: sub.identityId,
1526
+ emailId: sub.emailId,
1527
+ threadId: sub.threadId || null,
1528
+ envelope: sub.envelope,
1529
+ sendAt: new Date().toISOString(),
1530
+ undoStatus: "final",
1531
+ deliveryStatus: deliveryStatus,
1532
+ dsnBlobIds: [],
1533
+ mdnBlobIds: [],
1534
+ };
1535
+ }
1536
+
1537
+ function _err(type, description, properties) {
1538
+ var e = new MailServerJmapError("urn:ietf:params:jmap:error:" + type, description);
1539
+ e._jmapType = type;
1540
+ if (properties) e._jmapProperties = properties;
1541
+ return e;
1542
+ }
1543
+
1544
+ function _jmapErrorShape(err) {
1545
+ if (err && err._jmapType) {
1546
+ var shape = { type: err._jmapType };
1547
+ if (err.message) shape.description = err.message;
1548
+ if (err._jmapProperties) shape.properties = err._jmapProperties;
1549
+ return shape;
1550
+ }
1551
+ return { type: "serverFail", description: (err && err.message) || String(err) };
1552
+ }
1553
+
1554
+ function _emit(action, metadata) {
1555
+ try {
1556
+ audit().safeEmit({ action: action, outcome: "success", metadata: metadata || {} });
1557
+ } catch (_e) { /* drop-silent */ }
1558
+ }
1559
+ }
1560
+
1217
1561
  module.exports = {
1218
- create: create,
1219
- MailServerJmapError: MailServerJmapError,
1562
+ create: create,
1563
+ emailSubmissionSetHandler: emailSubmissionSetHandler,
1564
+ MailServerJmapError: MailServerJmapError,
1220
1565
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.11.37",
3
+ "version": "0.11.38",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:df42b2e1-b180-4b98-a9b7-2bf6b7fe666e",
5
+ "serialNumber": "urn:uuid:05d5b9da-ac1d-46ee-8a2c-f1b169e68091",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-21T22:14:19.947Z",
8
+ "timestamp": "2026-05-21T23:11:16.432Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.11.37",
22
+ "bom-ref": "@blamejs/core@0.11.38",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.11.37",
25
+ "version": "0.11.38",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.11.37",
29
+ "purl": "pkg:npm/%40blamejs/core@0.11.38",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.11.37",
57
+ "ref": "@blamejs/core@0.11.38",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]