@blamejs/core 0.11.36 → 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 +4 -0
- package/lib/calendar.js +138 -8
- package/lib/mail-server-jmap.js +347 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ 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
|
+
|
|
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)
|
|
14
|
+
|
|
11
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)
|
|
12
16
|
|
|
13
17
|
- v0.11.35 (2026-05-21) — **`b.calendar` VTODO ↔ JSCalendar Task (RFC 8984 §6).** Closes the v0.11.31 deferral. `b.calendar.fromIcal` now recognises VTODO components and maps them to JSCalendar Task objects per RFC 8984 §6. `b.calendar.toIcal` emits a VTODO envelope (with DUE / STATUS / PERCENT-COMPLETE / COMPLETED iCalendar properties) when the input `@type` is `Task`. `b.calendar.validate` picks up Task-specific shape rules: `due` as LocalDateTime, `estimatedDuration` as PnYnMnDTnHnMnS, `progress` from the RFC 8984 §6.4.3 vocabulary (`needs-action` | `in-process` | `completed` | `cancelled` | `failed`), `percentComplete` ∈ [0..100], `progressUpdated` as UTCDateTime. A VCALENDAR carrying both VEVENT and VTODO components now returns an array of mixed `Event` + `Task` shapes. **Added:** *`b.calendar.fromIcal` maps VTODO → JSCalendar Task* — VTODO components in the VCALENDAR are mapped to `@type: "Task"` objects with the same `uid` / `updated` / `title` / `description` / `start` / `timeZone` / `locations` / `recurrenceRules` slots Event uses, plus the Task-specific fields: `due` (DUE → LocalDateTime), `estimatedDuration` (DURATION), `progress` (STATUS lowercased), `percentComplete` (PERCENT-COMPLETE 0..100), `progressUpdated` (COMPLETED → UTCDateTime). UTC DUE values map to `timeZone: "Etc/UTC"` the same way DTSTART does. · *`b.calendar.toIcal` emits VTODO when `@type === "Task"`* — The envelope is `BEGIN:VTODO` / `END:VTODO` instead of `BEGIN:VEVENT`. Task-specific properties round-trip: `DUE` (with `;TZID=` parameter or `Z` suffix or floating local time per the same RFC 8984 §1.4.4 timeZone mapping as DTSTART), `STATUS` (uppercased), `PERCENT-COMPLETE`, `COMPLETED` (UTC). `estimatedDuration` maps to `DURATION` (RFC 5545 doesn't distinguish Event vs Task duration on the wire). · *`b.calendar.validate` learns Task-specific shape rules (RFC 8984 §6.4)* — `due` must be a LocalDateTime, `estimatedDuration` must match the same PnYnMnDTnHnMnS Duration grammar Event uses, `progress` must be in the `JSCAL_TASK_PROGRESS` catalogue (also exposed as a new top-level export), `percentComplete` must be a finite number in 0..100, `progressUpdated` must be a UTCDateTime. Structured `CalendarError` codes: `calendar/bad-due`, `calendar/bad-progress`, `calendar/bad-percent`, `calendar/bad-progress-updated`. · *Mixed-component VCALENDAR returns an array of Event + Task shapes* — When a VCALENDAR contains both VEVENT and VTODO children, `fromIcal` returns an Array — events first, tasks second, in their declared order. The single-component shortcut (returning the bare object) still applies when only one component is present. · *`calendar/no-vevent` error code renamed to `calendar/no-component`* — The refusal that fires when a VCALENDAR has no parseable child component now uses the more accurate `calendar/no-component` code (since VTODO is also valid). Operators that grep on the prior `calendar/no-vevent` code need to update the match; the human-facing error message also updates. **References:** [RFC 8984 §6 (JSCalendar Task)](https://www.rfc-editor.org/rfc/rfc8984.html#section-6) · [RFC 5545 §3.6.2 (iCalendar VTODO component)](https://www.rfc-editor.org/rfc/rfc5545.html#section-3.6.2) · [RFC 5545 §3.8.2.3 (DUE date-time)](https://www.rfc-editor.org/rfc/rfc5545.html#section-3.8.2.3) · [RFC 5545 §3.8.1.11 (STATUS — needs-action/in-process/completed/cancelled)](https://www.rfc-editor.org/rfc/rfc5545.html#section-3.8.1.11)
|
package/lib/calendar.js
CHANGED
|
@@ -42,6 +42,14 @@ var CalendarError = defineClass("CalendarError", { alwaysPermanent: true });
|
|
|
42
42
|
var JSCAL_TYPES = Object.freeze({
|
|
43
43
|
Event: "Event",
|
|
44
44
|
Task: "Task",
|
|
45
|
+
// Note maps RFC 5545 §3.6.3 VJOURNAL — a dated, free-form journal
|
|
46
|
+
// entry with no duration / progress / due semantics. Not formally
|
|
47
|
+
// defined as an @type in RFC 8984 §1.2 (which only enumerates
|
|
48
|
+
// Event + Task + Group); blamejs adopts `Note` as a recognised
|
|
49
|
+
// extension shape for VJOURNAL round-trip interop with iCalendar
|
|
50
|
+
// sources. Operators interoperating with strict RFC 8984 consumers
|
|
51
|
+
// should map Note → Group or Note → custom-@type before exchange.
|
|
52
|
+
Note: "Note",
|
|
45
53
|
Group: "Group",
|
|
46
54
|
Participant: "Participant",
|
|
47
55
|
Location: "Location",
|
|
@@ -73,6 +81,15 @@ var JSCAL_TASK_PROGRESS = Object.freeze({
|
|
|
73
81
|
"needs-action": 1, "in-process": 1, "completed": 1, "cancelled": 1,
|
|
74
82
|
});
|
|
75
83
|
|
|
84
|
+
// RFC 5545 §3.8.1.11 STATUS for VJOURNAL — Note carries DRAFT / FINAL
|
|
85
|
+
// / CANCELLED. JSCalendar lower-cases them on import/export. Note's
|
|
86
|
+
// `status` is OPTIONAL; absence on the wire maps to `status`
|
|
87
|
+
// unset (rather than defaulting to "draft" — operator intent is
|
|
88
|
+
// ambiguous when STATUS is omitted, per RFC 5545 grammar).
|
|
89
|
+
var JSCAL_NOTE_STATUS = Object.freeze({
|
|
90
|
+
"draft": 1, "final": 1, "cancelled": 1,
|
|
91
|
+
});
|
|
92
|
+
|
|
76
93
|
// Recurrence-expansion caps. Mirror b.safeIcal's RRULE limits so the
|
|
77
94
|
// expand path can't outpace what the parser already permitted.
|
|
78
95
|
var MAX_EXPAND_INSTANCES = 4096; // allow:raw-byte-literal — instance count cap, not bytes
|
|
@@ -106,9 +123,9 @@ function validate(jsCal) {
|
|
|
106
123
|
"b.calendar.validate: input must be a JSCalendar object");
|
|
107
124
|
}
|
|
108
125
|
var t = jsCal["@type"];
|
|
109
|
-
if (t !== JSCAL_TYPES.Event && t !== JSCAL_TYPES.Task) {
|
|
126
|
+
if (t !== JSCAL_TYPES.Event && t !== JSCAL_TYPES.Task && t !== JSCAL_TYPES.Note) {
|
|
110
127
|
throw new CalendarError("calendar/bad-type",
|
|
111
|
-
"b.calendar.validate: @type must be 'Event' or 'Task' (got " + JSON.stringify(t) + ")");
|
|
128
|
+
"b.calendar.validate: @type must be 'Event' or 'Task' or 'Note' (got " + JSON.stringify(t) + ")");
|
|
112
129
|
}
|
|
113
130
|
if (typeof jsCal.uid !== "string" || jsCal.uid.length === 0) {
|
|
114
131
|
throw new CalendarError("calendar/no-uid",
|
|
@@ -170,6 +187,43 @@ function validate(jsCal) {
|
|
|
170
187
|
"b.calendar.validate: Task.progressUpdated MUST be a UTCDateTime");
|
|
171
188
|
}
|
|
172
189
|
}
|
|
190
|
+
if (t === JSCAL_TYPES.Note) {
|
|
191
|
+
// Note (VJOURNAL) — dated free-form journal entry. RFC 5545
|
|
192
|
+
// §3.6.3 permits an optional DTSTART (date or date-time) and
|
|
193
|
+
// SUMMARY / DESCRIPTION; STATUS values are limited to DRAFT /
|
|
194
|
+
// FINAL / CANCELLED. There is no DUE / DURATION / COMPLETED /
|
|
195
|
+
// PERCENT-COMPLETE on VJOURNAL.
|
|
196
|
+
if (jsCal.start !== undefined && (typeof jsCal.start !== "string" || !_isLocalDateTime(jsCal.start))) {
|
|
197
|
+
throw new CalendarError("calendar/bad-start",
|
|
198
|
+
"b.calendar.validate: Note.start MUST be a LocalDateTime (RFC 5545 §3.6.3)");
|
|
199
|
+
}
|
|
200
|
+
if (jsCal.duration !== undefined) {
|
|
201
|
+
throw new CalendarError("calendar/bad-duration",
|
|
202
|
+
"b.calendar.validate: Note.duration MUST NOT be set (RFC 5545 §3.6.3 VJOURNAL has no DURATION)");
|
|
203
|
+
}
|
|
204
|
+
if (jsCal.due !== undefined) {
|
|
205
|
+
throw new CalendarError("calendar/bad-due",
|
|
206
|
+
"b.calendar.validate: Note.due MUST NOT be set (DUE is a VTODO-only property)");
|
|
207
|
+
}
|
|
208
|
+
if (jsCal.progress !== undefined) {
|
|
209
|
+
throw new CalendarError("calendar/bad-progress",
|
|
210
|
+
"b.calendar.validate: Note.progress MUST NOT be set (progress is a Task-only property)");
|
|
211
|
+
}
|
|
212
|
+
if (jsCal.percentComplete !== undefined) {
|
|
213
|
+
throw new CalendarError("calendar/bad-percent",
|
|
214
|
+
"b.calendar.validate: Note.percentComplete MUST NOT be set (percentComplete is a Task-only property)");
|
|
215
|
+
}
|
|
216
|
+
if (jsCal.progressUpdated !== undefined) {
|
|
217
|
+
throw new CalendarError("calendar/bad-progress-updated",
|
|
218
|
+
"b.calendar.validate: Note.progressUpdated MUST NOT be set (progressUpdated is a Task-only property)");
|
|
219
|
+
}
|
|
220
|
+
if (jsCal.status !== undefined &&
|
|
221
|
+
!Object.prototype.hasOwnProperty.call(JSCAL_NOTE_STATUS, jsCal.status)) {
|
|
222
|
+
throw new CalendarError("calendar/bad-note-status",
|
|
223
|
+
"b.calendar.validate: Note.status MUST be one of " +
|
|
224
|
+
Object.keys(JSCAL_NOTE_STATUS).join(" | ") + " (RFC 5545 §3.8.1.11 VJOURNAL STATUS)");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
173
227
|
if (jsCal.recurrenceRules !== undefined) {
|
|
174
228
|
if (!Array.isArray(jsCal.recurrenceRules)) {
|
|
175
229
|
throw new CalendarError("calendar/bad-recurrence",
|
|
@@ -234,14 +288,16 @@ function validate(jsCal) {
|
|
|
234
288
|
*/
|
|
235
289
|
function fromIcal(text, opts) {
|
|
236
290
|
var ast = safeIcal.parse(text, opts || {});
|
|
237
|
-
var events
|
|
238
|
-
var todos
|
|
239
|
-
|
|
291
|
+
var events = (ast && ast.vcalendar && ast.vcalendar.vevent) || [];
|
|
292
|
+
var todos = (ast && ast.vcalendar && ast.vcalendar.vtodo) || [];
|
|
293
|
+
var journals = (ast && ast.vcalendar && ast.vcalendar.vjournal) || [];
|
|
294
|
+
if (events.length === 0 && todos.length === 0 && journals.length === 0) {
|
|
240
295
|
throw new CalendarError("calendar/no-component",
|
|
241
|
-
"b.calendar.fromIcal: VCALENDAR has no VEVENT or
|
|
296
|
+
"b.calendar.fromIcal: VCALENDAR has no VEVENT, VTODO or VJOURNAL components");
|
|
242
297
|
}
|
|
243
298
|
var converted = events.map(_veventToJsCalEvent)
|
|
244
|
-
.concat(todos.map(_vtodoToJsCalTask))
|
|
299
|
+
.concat(todos.map(_vtodoToJsCalTask))
|
|
300
|
+
.concat(journals.map(_vjournalToJsCalNote));
|
|
245
301
|
return converted.length === 1 ? converted[0] : converted;
|
|
246
302
|
}
|
|
247
303
|
|
|
@@ -276,7 +332,9 @@ function toIcal(jsCal, opts) {
|
|
|
276
332
|
// maps to VEVENT. The wrapper + most properties are identical; the
|
|
277
333
|
// wrapping component tag + Task-specific fields (DUE / STATUS /
|
|
278
334
|
// PERCENT-COMPLETE / COMPLETED) diverge.
|
|
279
|
-
var component = jsCal["@type"] === "Task"
|
|
335
|
+
var component = jsCal["@type"] === "Task"
|
|
336
|
+
? "VTODO"
|
|
337
|
+
: jsCal["@type"] === "Note" ? "VJOURNAL" : "VEVENT";
|
|
280
338
|
var lines = [
|
|
281
339
|
"BEGIN:VCALENDAR",
|
|
282
340
|
"VERSION:2.0",
|
|
@@ -324,6 +382,11 @@ function toIcal(jsCal, opts) {
|
|
|
324
382
|
if (component === "VTODO" && jsCal.progress) {
|
|
325
383
|
lines.push("STATUS:" + String(jsCal.progress).toUpperCase());
|
|
326
384
|
}
|
|
385
|
+
// RFC 5545 §3.8.1.11 — VJOURNAL STATUS values are DRAFT / FINAL /
|
|
386
|
+
// CANCELLED. JSCalendar Note carries them lower-cased; emit upper.
|
|
387
|
+
if (component === "VJOURNAL" && jsCal.status) {
|
|
388
|
+
lines.push("STATUS:" + String(jsCal.status).toUpperCase());
|
|
389
|
+
}
|
|
327
390
|
if (component === "VTODO" && typeof jsCal.percentComplete === "number") {
|
|
328
391
|
lines.push("PERCENT-COMPLETE:" + jsCal.percentComplete);
|
|
329
392
|
}
|
|
@@ -664,6 +727,72 @@ function _vtodoToJsCalTask(vt) {
|
|
|
664
727
|
return jsCal;
|
|
665
728
|
}
|
|
666
729
|
|
|
730
|
+
// RFC 5545 §3.6.3 — VJOURNAL maps to a JSCalendar-shaped Note. The
|
|
731
|
+
// VJOURNAL grammar permits MULTIPLE DESCRIPTION properties (the only
|
|
732
|
+
// iCalendar component that does); blamejs joins them with a single
|
|
733
|
+
// blank line to preserve operator-visible separators. STATUS is
|
|
734
|
+
// limited to DRAFT / FINAL / CANCELLED (different from VTODO's
|
|
735
|
+
// vocabulary), with no DUE / DURATION / PERCENT-COMPLETE / COMPLETED.
|
|
736
|
+
function _vjournalToJsCalNote(vj) {
|
|
737
|
+
var props = (vj && vj.properties) || {};
|
|
738
|
+
var jsCal = {
|
|
739
|
+
"@type": "Note",
|
|
740
|
+
uid: _firstValue(props.UID) || "",
|
|
741
|
+
updated: _icalDateTimeToUtc(_firstValue(props.DTSTAMP) || ""),
|
|
742
|
+
};
|
|
743
|
+
var summary = _firstValue(props.SUMMARY);
|
|
744
|
+
if (summary) jsCal.title = _unescapeText(summary);
|
|
745
|
+
// RFC 5545 §3.6.3 — VJOURNAL is the only component that may carry
|
|
746
|
+
// multiple DESCRIPTION properties (one per discrete journal entry).
|
|
747
|
+
// Concatenate them with a blank-line separator so downstream
|
|
748
|
+
// consumers see the operator-visible boundaries.
|
|
749
|
+
var descriptions = _allValues(props.DESCRIPTION);
|
|
750
|
+
if (descriptions.length === 1) {
|
|
751
|
+
jsCal.description = _unescapeText(descriptions[0]);
|
|
752
|
+
} else if (descriptions.length > 1) {
|
|
753
|
+
var parts = [];
|
|
754
|
+
for (var di = 0; di < descriptions.length; di += 1) {
|
|
755
|
+
parts.push(_unescapeText(descriptions[di]));
|
|
756
|
+
}
|
|
757
|
+
jsCal.description = parts.join("\n\n");
|
|
758
|
+
}
|
|
759
|
+
var dtstart = _firstValue(props.DTSTART);
|
|
760
|
+
if (dtstart) jsCal.start = _icalDateTimeToLocal(dtstart);
|
|
761
|
+
var tzid = _firstParamValue(props.DTSTART, "TZID");
|
|
762
|
+
if (tzid) {
|
|
763
|
+
jsCal.timeZone = tzid;
|
|
764
|
+
} else if (typeof dtstart === "string" && /Z$/.test(dtstart)) {
|
|
765
|
+
jsCal.timeZone = "Etc/UTC";
|
|
766
|
+
}
|
|
767
|
+
var status = _firstValue(props.STATUS);
|
|
768
|
+
if (status) {
|
|
769
|
+
var statusLower = String(status).toLowerCase();
|
|
770
|
+
if (Object.prototype.hasOwnProperty.call(JSCAL_NOTE_STATUS, statusLower)) {
|
|
771
|
+
jsCal.status = statusLower;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
var location = _firstValue(props.LOCATION);
|
|
775
|
+
if (location) {
|
|
776
|
+
jsCal.locations = { L1: { "@type": "Location", name: _unescapeText(location) } };
|
|
777
|
+
}
|
|
778
|
+
var rrule3 = _firstValue(props.RRULE);
|
|
779
|
+
if (rrule3) jsCal.recurrenceRules = [_icalRruleToJscal(rrule3)];
|
|
780
|
+
return jsCal;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function _allValues(prop) {
|
|
784
|
+
if (!prop) return [];
|
|
785
|
+
if (Array.isArray(prop)) {
|
|
786
|
+
var out = [];
|
|
787
|
+
for (var i = 0; i < prop.length; i += 1) {
|
|
788
|
+
var item = prop[i];
|
|
789
|
+
if (item && item.value !== undefined) out.push(item.value);
|
|
790
|
+
}
|
|
791
|
+
return out;
|
|
792
|
+
}
|
|
793
|
+
return prop.value !== undefined ? [prop.value] : [];
|
|
794
|
+
}
|
|
795
|
+
|
|
667
796
|
function _firstValue(prop) {
|
|
668
797
|
if (!prop) return null;
|
|
669
798
|
if (Array.isArray(prop)) {
|
|
@@ -809,6 +938,7 @@ module.exports = {
|
|
|
809
938
|
JSCAL_FREQUENCIES: JSCAL_FREQUENCIES,
|
|
810
939
|
JSCAL_ALERT_ACTIONS: JSCAL_ALERT_ACTIONS,
|
|
811
940
|
JSCAL_TASK_PROGRESS: JSCAL_TASK_PROGRESS,
|
|
941
|
+
JSCAL_NOTE_STATUS: JSCAL_NOTE_STATUS,
|
|
812
942
|
MAX_EXPAND_INSTANCES: MAX_EXPAND_INSTANCES,
|
|
813
943
|
MAX_EXPAND_SPAN_MS: MAX_EXPAND_SPAN_MS,
|
|
814
944
|
};
|
package/lib/mail-server-jmap.js
CHANGED
|
@@ -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:
|
|
1219
|
-
|
|
1562
|
+
create: create,
|
|
1563
|
+
emailSubmissionSetHandler: emailSubmissionSetHandler,
|
|
1564
|
+
MailServerJmapError: MailServerJmapError,
|
|
1220
1565
|
};
|
package/package.json
CHANGED
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:
|
|
5
|
+
"serialNumber": "urn:uuid:05d5b9da-ac1d-46ee-8a2c-f1b169e68091",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.11.38",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.11.
|
|
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.
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.11.38",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|