@blamejs/core 0.11.33 → 0.11.35
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 +6 -0
- package/lib/calendar.js +151 -7
- package/lib/mail-server-jmap.js +221 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,12 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.11.x
|
|
10
10
|
|
|
11
|
+
- 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)
|
|
12
|
+
|
|
13
|
+
- v0.11.34 (2026-05-21) — **JMAP WebSocket transport (RFC 8887) on `b.mail.server.jmap`.** Closes the v0.11.29 deferral. The JMAP listener now exposes `webSocketHandler(req, socket, head)` — a turnkey RFC 8887 transport built on the framework's `b.websocket.handleUpgrade`. Clients connect with the `jmap` subprotocol; bidirectional JSON frames carry `{ "@type": "Request" }` / `{ "@type": "WebSocketPushEnable" }` / `{ "@type": "WebSocketPushDisable" }` from the client, and `{ "@type": "Response" }` / `{ "@type": "StateChange" }` / `{ "@type": "RequestError" }` from the server. `Request` frames flow through the same `dispatch(actor, request)` path the HTTP `apiHandler` uses; `WebSocketPushEnable` hooks the operator's `mailStore.subscribePush(actor, types, emitFn)` and converts backend StateChange events into outbound WebSocket frames.
|
|
14
|
+
|
|
15
|
+
The session resource picks up `webSocketUrl` so clients discover the endpoint via the same JMAP session-discovery flow they use for `apiUrl` / `eventSourceUrl` / `uploadUrl` / `downloadUrl`. permessage-deflate is OFF by default — same CRIME-class compression-oracle threat model as the v0.11.28 IMAP COMPRESS=DEFLATE intentional skip — operators opt in via `opts.webSocketPermessageDeflate`. **Added:** *`webSocketHandler(req, socket, head)` on the listener handle* — Mount on the operator's HTTP server's `'upgrade'` event. Auth is delegated to the surrounding HTTP middleware (the handler expects `req.user` / `req.actor` to be populated by upstream auth); unauthenticated requests write `HTTP/1.1 401 Unauthorized` to the raw socket per the WebSocket spec's pre-handshake error shape. RFC 8887 §3.1 requires the `jmap` subprotocol; the handler refuses the upgrade with close-code 1002 if `Sec-WebSocket-Protocol: jmap` is missing. · *Bidirectional JSON-framed transport* — Client → server `@type`: `Request` (same body as HTTP POST — `{ using, methodCalls, createdIds? }`), `WebSocketPushEnable` (`{ dataTypes?, pushState? }`), `WebSocketPushDisable`. Server → client `@type`: `Response` (`{ requestId, methodResponses, sessionState, createdIds }`), `StateChange` (`{ changed, pushed? }`), `RequestError` (`{ requestId, type, description }`). Unknown `@type` frames trigger a `RequestError` rather than tearing down the channel. · *Push integration shares the operator hook with EventSource* — `WebSocketPushEnable` forwards `(actor, dataTypes, emitFn)` to `mailStore.subscribePush` — the same backend hook the EventSource handler (v0.11.29) consumes. Operators wire push once and both transports surface the same `StateChange` events. Per-connection `WebSocketPushDisable` calls the unsubscribe function the backend returned from `subscribePush`. Connection close cleans up implicitly. · *Session resource carries `webSocketUrl`* — The session JSON now includes `webSocketUrl: opts.webSocketUrl || "/jmap/ws"` per RFC 8887 §3. Operators discoverable-by-default — clients using a stock JMAP library find the WS endpoint via the session resource the same way they find `apiUrl` today. **Security:** *Binary frames refused* — JMAP is JSON-only over WebSocket. Binary frames trigger a `RequestError` with `type: "notJSON"`; the connection stays open so the next text frame can be valid. Prevents a misbehaving client from sneaking opaque bytes past the JSON parser. · *JSON parse routed through `b.safeJson.parse`* — WebSocket text frames are parsed through `b.safeJson.parse` with the per-connection `maxBytes` cap (default 10 MiB; operator-tunable via `opts.webSocketMaxMessageBytes`). Catches CVE-2020-7660-class prototype-pollution payloads + adversarial-depth JSON before they reach the JMAP method dispatcher. · *permessage-deflate OFF by default (CRIME-class threat)* — RFC 7692 permessage-deflate enables compression-oracle attacks (CVE-2012-4929 CRIME class) when the operator pipes JSON containing both attacker-controlled and confidential data through the same connection. Default is OFF; operators with explicit threat-model justification opt in via `opts.webSocketPermessageDeflate = true`. Mirrors the v0.11.28 IMAP COMPRESS=DEFLATE intentional refusal. · *Subprotocol negotiation refuses non-`jmap` clients* — If the client's `Sec-WebSocket-Protocol` header doesn't include `jmap`, the listener refuses the upgrade with close-code 1002 (protocol error). RFC 8887 §3.1 — the JMAP-WS connection is identified by the subprotocol; refusing here prevents the connection from being misinterpreted as a generic WebSocket channel by middleware downstream. **References:** [RFC 8887 (JMAP over WebSocket)](https://www.rfc-editor.org/rfc/rfc8887.html) · [RFC 8620 (JMAP Core)](https://www.rfc-editor.org/rfc/rfc8620.html) · [RFC 6455 (The WebSocket Protocol)](https://www.rfc-editor.org/rfc/rfc6455.html) · [RFC 7692 (Compression Extensions for WebSocket — NOT enabled)](https://www.rfc-editor.org/rfc/rfc7692.html) · [CVE-2012-4929 (CRIME — compression-oracle attack on TLS)](https://nvd.nist.gov/vuln/detail/CVE-2012-4929)
|
|
16
|
+
|
|
11
17
|
- v0.11.33 (2026-05-21) — **IMAP QRESYNC (RFC 7162 §3.2) — VANISHED responses + SELECT delta on `b.mail.server.imap`.** Closes the v0.11.27 deferral. The IMAP listener now advertises QRESYNC in CAPABILITY, accepts `ENABLE QRESYNC` (which implicitly engages CONDSTORE per §3.2.5), and parses the `(QRESYNC (<uidvalidity> <modseq> [<knownUids>] [<knownSequenceMatchData>]))` parameter list on SELECT / EXAMINE. When the client's UIDVALIDITY matches the backend's, the listener emits a single `* VANISHED (EARLIER) <uid-set>` listing UIDs the server expunged since the client's snapshot — operators implement the actual delta computation via `mailStore.selectFolder(actor, mailbox, { qresync }) → { ..., vanishedEarlier }`. Stale-UIDVALIDITY clients fall through to a full re-SELECT. **Added:** *CAPABILITY advertises `QRESYNC`* — Sits next to the existing `CONDSTORE` advertisement. Both extensions are server-advertised; clients ENABLE before relying on the responses. RFC 7162 §3.2.5 — QRESYNC implies CONDSTORE. · *`ENABLE QRESYNC` engages both flags* — The handler flips `state.enabledQResync = true` AND `state.enabledCondStore = true` and emits `* ENABLED QRESYNC` + `OK ENABLE completed`. Already-engaged QRESYNC re-issues skip the advertisement line (only newly-engaged extensions appear). · *`SELECT mailbox (QRESYNC (<uidvalidity> <modseq> [<knownUids>] [<knownSeq>]))`* — The QRESYNC parameter list is stripped from the SELECT args before the mailbox-name validator runs and forwarded to `mailStore.selectFolder` as `opts.qresync = { uidvalidity, modseq, knownUids, knownSeq }`. Backends compute the delta and return `vanishedEarlier` (sequence-set string) in the existing select-info object. Non-finite uidvalidity / modseq values refuse with `BAD SELECT QRESYNC params must be (<uidvalidity> <modseq> ...) numerics` before the backend call. · *Implicit CONDSTORE+QRESYNC engagement on parameterised SELECT* — Per RFC 7162 §3.2.4, a client that issues `SELECT INBOX (QRESYNC (...))` without a prior `ENABLE QRESYNC` flips both flags implicitly for the session. Subsequent FETCH responses include `MODSEQ (<n>)` just as they would after explicit ENABLE. · *`* VANISHED (EARLIER) <uid-set>` emission* — The listener emits a single VANISHED untagged response between `HIGHESTMODSEQ` and the tagged OK when (a) the client supplied a QRESYNC parameter, (b) the client's UIDVALIDITY matches the backend's, AND (c) the backend supplied a non-empty `vanishedEarlier`. Mismatched UIDVALIDITY suppresses the VANISHED line so the client correctly falls through to a full re-sync (RFC 7162 §3.2.5). **Security:** *QRESYNC parameter parse is bounded* — The regex anchors on `\(\s*QRESYNC\s*\(\s*([^)]+)\)` — `[^)]*` not `.*` — so a malformed parameter list cannot consume the entire SELECT args. Both `uidvalidity` and `modseq` parse strictly as `\d+` via `parseInt(...)` + `isFinite` check; non-numeric values refuse before the backend dispatch. · *VANISHED suppressed on UIDVALIDITY mismatch* — RFC 7162 §3.2.5 — when the client's `uidvalidity` does NOT match the server's current value, the mailbox has been reset (e.g., recreated under the same name) and the client's UID cache is invalid. The listener intentionally drops the VANISHED line so the client forces a fresh full-sync instead of acting on a delta that references a different message set. **References:** [RFC 7162 §3.2 (QRESYNC — Quick Mailbox Resynchronization)](https://www.rfc-editor.org/rfc/rfc7162.html) · [RFC 9051 (IMAP4rev2)](https://www.rfc-editor.org/rfc/rfc9051.html) · [RFC 5161 (IMAP ENABLE Extension)](https://www.rfc-editor.org/rfc/rfc5161.html)
|
|
12
18
|
|
|
13
19
|
- v0.11.32 (2026-05-21) — **`b.mail.crypto.pgp.encrypt` / `.decrypt` / `.wkd` promoted to stable — WKD IDN-homograph defense.** The PGP encrypt / decrypt / Web Key Directory (WKD) primitives that shipped under `b.mail.crypto.pgp.experimental.*` at v0.10.16 are promoted to top-level stable. `b.mail.crypto.pgp.encrypt` / `b.mail.crypto.pgp.decrypt` / `b.mail.crypto.pgp.wkd.fetch` / `b.mail.crypto.pgp.wkd.computeUrl` are now the canonical paths; the v0.10.16 `experimental` aliases continue to work so existing operator code keeps importing through the old path until they migrate at their own pace. WKD picks up a hard IDN-homograph refusal at `wkd.computeUrl` — Cyrillic / Greek / full-width / Han confusable domain characters refuse with `mail-crypto/pgp/bad-domain` before any HTTP fetch runs. Operators with internationalised domains MUST Punycode-encode upstream (RFC 3492 `xn--` form). **Added:** *Top-level `b.mail.crypto.pgp.encrypt` / `.decrypt` / `.wkd.{fetch,computeUrl}`* — Same function bodies that shipped under `b.mail.crypto.pgp.experimental.*` at v0.10.16 are now exported at the top of the namespace. The framework-private envelope (BJ-PGP-PQ magic + version) is unchanged; the IANA-pending RFC 9580bis ML-KEM PKESK codepoints will land as an alternate-encoding option in a follow-up slice. The `experimental` alias keeps the v0.10.16 import paths working — operator migration is opt-in. `b.mail.crypto.pgp.encrypt === b.mail.crypto.pgp.experimental.encrypt` (same reference) so a feature-detection test like `typeof b.mail.crypto.pgp.encrypt === 'function'` is the new operator-facing pattern. **Security:** *WKD IDN-homograph refusal at `wkd.computeUrl`* — `wkd.computeUrl(email)` now refuses any domain containing characters outside the LDH+dot ASCII subset (RFC 952 / RFC 1123 §2). The threat model: Cyrillic `а` (U+0430), Greek `ο` (U+03BF), full-width `A` (U+FF21), and Han homographs visually impersonate Latin letters in `paypa1.com` / `gοogle.com` style phishing host strings; a naive `toLowerCase` + concat into the WKD URL would route the key fetch to the attacker's domain. Operators with legitimate internationalised domains MUST Punycode-encode upstream (the `xn--` form is plain ASCII LDH and passes). The framework's `b.httpClient` already refuses non-ASCII hostnames at the SSRF guard layer; this primitive surfaces the same refusal at the WKD entry point so the error surface is consistent. · *RFC 5321 + RFC 1035 length caps* — Email length capped at 320 octets (RFC 5321 §4.5.3.1 maximum). Domain length capped at 253 octets (RFC 1035 §2.3.4). Empty labels (`bad..example.com`), leading-dot, and trailing-dot domains refuse before any URL construction. Adversarial-length inputs cannot reach the tokenizer / hasher path. **References:** [draft-koch-openpgp-webkey-service (Web Key Directory)](https://datatracker.ietf.org/doc/draft-koch-openpgp-webkey-service/) · [RFC 9580 (OpenPGP)](https://www.rfc-editor.org/rfc/rfc9580.html) · [RFC 3492 (Punycode — IDNA `xn--` form)](https://www.rfc-editor.org/rfc/rfc3492.html) · [RFC 5891 (IDNA 2008)](https://www.rfc-editor.org/rfc/rfc5891.html) · [RFC 5321 §4.5.3.1 (SMTP — local-part + domain octet maximums)](https://www.rfc-editor.org/rfc/rfc5321.html) · [RFC 1035 §2.3.4 (domain label length)](https://www.rfc-editor.org/rfc/rfc1035.html) · [Unicode TR 39 (Security Mechanisms — confusable identifiers)](https://www.unicode.org/reports/tr39/)
|
package/lib/calendar.js
CHANGED
|
@@ -61,6 +61,18 @@ var JSCAL_ALERT_ACTIONS = Object.freeze({
|
|
|
61
61
|
display: 1, email: 1,
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
// RFC 8984 §6.4.3 — Task progress vocabulary. Mirrors RFC 5545 STATUS
|
|
65
|
+
// values for VTODO (`NEEDS-ACTION` / `IN-PROCESS` / `COMPLETED` /
|
|
66
|
+
// `CANCELLED`); JSCalendar lower-cases them. `failed` is NOT included
|
|
67
|
+
// — RFC 5545 STATUS does not define a `FAILED` value, so the iCal
|
|
68
|
+
// round-trip path could not safely emit it (strict consumers refuse
|
|
69
|
+
// the unknown STATUS token). Operators with a "failed" semantic
|
|
70
|
+
// model it via `progress: "cancelled"` + a vendor-namespaced
|
|
71
|
+
// extension property instead.
|
|
72
|
+
var JSCAL_TASK_PROGRESS = Object.freeze({
|
|
73
|
+
"needs-action": 1, "in-process": 1, "completed": 1, "cancelled": 1,
|
|
74
|
+
});
|
|
75
|
+
|
|
64
76
|
// Recurrence-expansion caps. Mirror b.safeIcal's RRULE limits so the
|
|
65
77
|
// expand path can't outpace what the parser already permitted.
|
|
66
78
|
var MAX_EXPAND_INSTANCES = 4096; // allow:raw-byte-literal — instance count cap, not bytes
|
|
@@ -120,6 +132,44 @@ function validate(jsCal) {
|
|
|
120
132
|
"b.calendar.validate: Event.duration MUST be an RFC 8601 PnYnMnDTnHnMnS Duration");
|
|
121
133
|
}
|
|
122
134
|
}
|
|
135
|
+
if (t === JSCAL_TYPES.Task) {
|
|
136
|
+
if (jsCal.start !== undefined && (typeof jsCal.start !== "string" || !_isLocalDateTime(jsCal.start))) {
|
|
137
|
+
throw new CalendarError("calendar/bad-start",
|
|
138
|
+
"b.calendar.validate: Task.start MUST be a LocalDateTime (RFC 8984 §6.4)");
|
|
139
|
+
}
|
|
140
|
+
if (jsCal.due !== undefined && (typeof jsCal.due !== "string" || !_isLocalDateTime(jsCal.due))) {
|
|
141
|
+
throw new CalendarError("calendar/bad-due",
|
|
142
|
+
"b.calendar.validate: Task.due MUST be a LocalDateTime (RFC 8984 §6.4.4)");
|
|
143
|
+
}
|
|
144
|
+
if (jsCal.estimatedDuration !== undefined &&
|
|
145
|
+
(typeof jsCal.estimatedDuration !== "string" || !_isDuration(jsCal.estimatedDuration))) {
|
|
146
|
+
throw new CalendarError("calendar/bad-duration",
|
|
147
|
+
"b.calendar.validate: Task.estimatedDuration MUST be an RFC 8601 PnYnMnDTnHnMnS Duration");
|
|
148
|
+
}
|
|
149
|
+
if (jsCal.progress !== undefined &&
|
|
150
|
+
!Object.prototype.hasOwnProperty.call(JSCAL_TASK_PROGRESS, jsCal.progress)) {
|
|
151
|
+
throw new CalendarError("calendar/bad-progress",
|
|
152
|
+
"b.calendar.validate: Task.progress MUST be one of " +
|
|
153
|
+
Object.keys(JSCAL_TASK_PROGRESS).join(" | ") + " (RFC 8984 §6.4.3)");
|
|
154
|
+
}
|
|
155
|
+
if (jsCal.percentComplete !== undefined) {
|
|
156
|
+
// RFC 8984 §6.4.4 specifies `UnsignedInt` (integer). RFC 5545
|
|
157
|
+
// §3.8.1.16 PERCENT-COMPLETE is also integer-typed. A float
|
|
158
|
+
// would emit as `PERCENT-COMPLETE:12.5` which strict parsers
|
|
159
|
+
// refuse.
|
|
160
|
+
if (typeof jsCal.percentComplete !== "number" || !isFinite(jsCal.percentComplete) ||
|
|
161
|
+
!Number.isInteger(jsCal.percentComplete) ||
|
|
162
|
+
jsCal.percentComplete < 0 || jsCal.percentComplete > 100) { // allow:raw-byte-literal — RFC 8984 §6 percent range
|
|
163
|
+
throw new CalendarError("calendar/bad-percent",
|
|
164
|
+
"b.calendar.validate: Task.percentComplete MUST be an integer in 0..100 (RFC 8984 §6.4.4 UnsignedInt)");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (jsCal.progressUpdated !== undefined &&
|
|
168
|
+
(typeof jsCal.progressUpdated !== "string" || !_isUtcDateTime(jsCal.progressUpdated))) {
|
|
169
|
+
throw new CalendarError("calendar/bad-progress-updated",
|
|
170
|
+
"b.calendar.validate: Task.progressUpdated MUST be a UTCDateTime");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
123
173
|
if (jsCal.recurrenceRules !== undefined) {
|
|
124
174
|
if (!Array.isArray(jsCal.recurrenceRules)) {
|
|
125
175
|
throw new CalendarError("calendar/bad-recurrence",
|
|
@@ -185,11 +235,13 @@ function validate(jsCal) {
|
|
|
185
235
|
function fromIcal(text, opts) {
|
|
186
236
|
var ast = safeIcal.parse(text, opts || {});
|
|
187
237
|
var events = (ast && ast.vcalendar && ast.vcalendar.vevent) || [];
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
238
|
+
var todos = (ast && ast.vcalendar && ast.vcalendar.vtodo) || [];
|
|
239
|
+
if (events.length === 0 && todos.length === 0) {
|
|
240
|
+
throw new CalendarError("calendar/no-component",
|
|
241
|
+
"b.calendar.fromIcal: VCALENDAR has no VEVENT or VTODO components");
|
|
191
242
|
}
|
|
192
|
-
var converted = events.map(_veventToJsCalEvent)
|
|
243
|
+
var converted = events.map(_veventToJsCalEvent)
|
|
244
|
+
.concat(todos.map(_vtodoToJsCalTask));
|
|
193
245
|
return converted.length === 1 ? converted[0] : converted;
|
|
194
246
|
}
|
|
195
247
|
|
|
@@ -220,11 +272,16 @@ function fromIcal(text, opts) {
|
|
|
220
272
|
function toIcal(jsCal, opts) {
|
|
221
273
|
validate(jsCal);
|
|
222
274
|
var prodid = (opts && opts.prodid) || "-//blamejs//Calendar//EN";
|
|
275
|
+
// RFC 8984 §6 — JSCalendar Task maps to RFC 5545 §3.6.2 VTODO; Event
|
|
276
|
+
// maps to VEVENT. The wrapper + most properties are identical; the
|
|
277
|
+
// wrapping component tag + Task-specific fields (DUE / STATUS /
|
|
278
|
+
// PERCENT-COMPLETE / COMPLETED) diverge.
|
|
279
|
+
var component = jsCal["@type"] === "Task" ? "VTODO" : "VEVENT";
|
|
223
280
|
var lines = [
|
|
224
281
|
"BEGIN:VCALENDAR",
|
|
225
282
|
"VERSION:2.0",
|
|
226
283
|
"PRODID:" + prodid,
|
|
227
|
-
"BEGIN:
|
|
284
|
+
"BEGIN:" + component,
|
|
228
285
|
"UID:" + _foldLine(jsCal.uid),
|
|
229
286
|
"DTSTAMP:" + _utcDateTimeToIcal(jsCal.updated),
|
|
230
287
|
];
|
|
@@ -244,7 +301,35 @@ function toIcal(jsCal, opts) {
|
|
|
244
301
|
lines.push("DTSTART:" + dtStartIcal);
|
|
245
302
|
}
|
|
246
303
|
}
|
|
247
|
-
|
|
304
|
+
// RFC 5545 §3.8.2.3 — DUE is Task-only; same TZID/UTC handling as DTSTART.
|
|
305
|
+
if (component === "VTODO" && jsCal.due) {
|
|
306
|
+
var dueIcal = _localDateTimeToIcal(jsCal.due);
|
|
307
|
+
if (jsCal.timeZone === "Etc/UTC" || jsCal.timeZone === "UTC") {
|
|
308
|
+
lines.push("DUE:" + dueIcal + "Z");
|
|
309
|
+
} else if (jsCal.timeZone) {
|
|
310
|
+
lines.push("DUE;TZID=" + jsCal.timeZone + ":" + dueIcal);
|
|
311
|
+
} else {
|
|
312
|
+
lines.push("DUE:" + dueIcal);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// RFC 8984 §6 — Task carries `estimatedDuration` (RFC 5545 DURATION).
|
|
316
|
+
// Event uses `duration`. Both map to the same iCalendar property.
|
|
317
|
+
var icalDuration = component === "VTODO"
|
|
318
|
+
? (jsCal.estimatedDuration || jsCal.duration)
|
|
319
|
+
: jsCal.duration;
|
|
320
|
+
if (icalDuration) lines.push("DURATION:" + icalDuration);
|
|
321
|
+
// RFC 5545 §3.8.1.11 STATUS — Task progress maps directly; the four
|
|
322
|
+
// RFC 8984 §6.4.3 progress values (`needs-action` / `in-process` /
|
|
323
|
+
// `completed` / `cancelled`) are the same wire strings.
|
|
324
|
+
if (component === "VTODO" && jsCal.progress) {
|
|
325
|
+
lines.push("STATUS:" + String(jsCal.progress).toUpperCase());
|
|
326
|
+
}
|
|
327
|
+
if (component === "VTODO" && typeof jsCal.percentComplete === "number") {
|
|
328
|
+
lines.push("PERCENT-COMPLETE:" + jsCal.percentComplete);
|
|
329
|
+
}
|
|
330
|
+
if (component === "VTODO" && jsCal.progressUpdated) {
|
|
331
|
+
lines.push("COMPLETED:" + _utcDateTimeToIcal(jsCal.progressUpdated));
|
|
332
|
+
}
|
|
248
333
|
if (Array.isArray(jsCal.locations) || (jsCal.locations && typeof jsCal.locations === "object")) {
|
|
249
334
|
var locValues = Array.isArray(jsCal.locations) ? jsCal.locations : Object.values(jsCal.locations);
|
|
250
335
|
for (var li = 0; li < locValues.length; li += 1) {
|
|
@@ -259,7 +344,7 @@ function toIcal(jsCal, opts) {
|
|
|
259
344
|
lines.push("RRULE:" + _recurrenceRuleToIcal(jsCal.recurrenceRules[rri]));
|
|
260
345
|
}
|
|
261
346
|
}
|
|
262
|
-
lines.push("END:
|
|
347
|
+
lines.push("END:" + component, "END:VCALENDAR");
|
|
263
348
|
return lines.join("\r\n") + "\r\n";
|
|
264
349
|
}
|
|
265
350
|
|
|
@@ -432,6 +517,64 @@ function _veventToJsCalEvent(ve) {
|
|
|
432
517
|
return jsCal;
|
|
433
518
|
}
|
|
434
519
|
|
|
520
|
+
// RFC 8984 §6 — JSCalendar Task. The VTODO mapping is structurally
|
|
521
|
+
// similar to VEVENT but adds Task-specific properties:
|
|
522
|
+
// DUE → due (LocalDateTime)
|
|
523
|
+
// STATUS → progress ("needs-action"|"in-process"|"completed"|"cancelled")
|
|
524
|
+
// PERCENT-COMPLETE → percentComplete (0..100)
|
|
525
|
+
// COMPLETED → progressUpdated (UTCDateTime)
|
|
526
|
+
function _vtodoToJsCalTask(vt) {
|
|
527
|
+
var props = (vt && vt.properties) || {};
|
|
528
|
+
var jsCal = {
|
|
529
|
+
"@type": "Task",
|
|
530
|
+
uid: _firstValue(props.UID) || "",
|
|
531
|
+
updated: _icalDateTimeToUtc(_firstValue(props.DTSTAMP) || ""),
|
|
532
|
+
};
|
|
533
|
+
var summary = _firstValue(props.SUMMARY);
|
|
534
|
+
if (summary) jsCal.title = _unescapeText(summary);
|
|
535
|
+
var description = _firstValue(props.DESCRIPTION);
|
|
536
|
+
if (description) jsCal.description = _unescapeText(description);
|
|
537
|
+
var dtstart = _firstValue(props.DTSTART);
|
|
538
|
+
if (dtstart) jsCal.start = _icalDateTimeToLocal(dtstart);
|
|
539
|
+
var due = _firstValue(props.DUE);
|
|
540
|
+
if (due) jsCal.due = _icalDateTimeToLocal(due);
|
|
541
|
+
var duration = _firstValue(props.DURATION);
|
|
542
|
+
if (duration) jsCal.estimatedDuration = duration;
|
|
543
|
+
var tzid = _firstParamValue(props.DTSTART, "TZID") ||
|
|
544
|
+
_firstParamValue(props.DUE, "TZID");
|
|
545
|
+
if (tzid) {
|
|
546
|
+
jsCal.timeZone = tzid;
|
|
547
|
+
} else if ((typeof dtstart === "string" && /Z$/.test(dtstart)) ||
|
|
548
|
+
(typeof due === "string" && /Z$/.test(due))) {
|
|
549
|
+
jsCal.timeZone = "Etc/UTC";
|
|
550
|
+
}
|
|
551
|
+
var status = _firstValue(props.STATUS);
|
|
552
|
+
if (status) {
|
|
553
|
+
var statusLower = String(status).toLowerCase();
|
|
554
|
+
var statusMap = {
|
|
555
|
+
"needs-action": "needs-action",
|
|
556
|
+
"in-process": "in-process",
|
|
557
|
+
"completed": "completed",
|
|
558
|
+
"cancelled": "cancelled",
|
|
559
|
+
};
|
|
560
|
+
if (statusMap[statusLower]) jsCal.progress = statusMap[statusLower];
|
|
561
|
+
}
|
|
562
|
+
var percent = _firstValue(props["PERCENT-COMPLETE"]);
|
|
563
|
+
if (percent !== null && percent !== undefined) {
|
|
564
|
+
var pn = parseInt(percent, 10);
|
|
565
|
+
if (isFinite(pn) && pn >= 0 && pn <= 100) jsCal.percentComplete = pn; // allow:raw-byte-literal — RFC 8984 §6 percent range
|
|
566
|
+
}
|
|
567
|
+
var completed = _firstValue(props.COMPLETED);
|
|
568
|
+
if (completed) jsCal.progressUpdated = _icalDateTimeToUtc(completed);
|
|
569
|
+
var location = _firstValue(props.LOCATION);
|
|
570
|
+
if (location) {
|
|
571
|
+
jsCal.locations = { L1: { "@type": "Location", name: _unescapeText(location) } };
|
|
572
|
+
}
|
|
573
|
+
var rrule2 = _firstValue(props.RRULE);
|
|
574
|
+
if (rrule2) jsCal.recurrenceRules = [_icalRruleToJscal(rrule2)];
|
|
575
|
+
return jsCal;
|
|
576
|
+
}
|
|
577
|
+
|
|
435
578
|
function _firstValue(prop) {
|
|
436
579
|
if (!prop) return null;
|
|
437
580
|
if (Array.isArray(prop)) {
|
|
@@ -576,6 +719,7 @@ module.exports = {
|
|
|
576
719
|
JSCAL_TYPES: JSCAL_TYPES,
|
|
577
720
|
JSCAL_FREQUENCIES: JSCAL_FREQUENCIES,
|
|
578
721
|
JSCAL_ALERT_ACTIONS: JSCAL_ALERT_ACTIONS,
|
|
722
|
+
JSCAL_TASK_PROGRESS: JSCAL_TASK_PROGRESS,
|
|
579
723
|
MAX_EXPAND_INSTANCES: MAX_EXPAND_INSTANCES,
|
|
580
724
|
MAX_EXPAND_SPAN_MS: MAX_EXPAND_SPAN_MS,
|
|
581
725
|
};
|
package/lib/mail-server-jmap.js
CHANGED
|
@@ -122,6 +122,7 @@ var C = require("./constants");
|
|
|
122
122
|
var bCrypto = require("./crypto");
|
|
123
123
|
var safeJson = require("./safe-json");
|
|
124
124
|
var safeBuffer = require("./safe-buffer");
|
|
125
|
+
var websocket = require("./websocket");
|
|
125
126
|
var validateOpts = require("./validate-opts");
|
|
126
127
|
var guardJmap = require("./guard-jmap");
|
|
127
128
|
var mailServerRegistry = require("./mail-server-registry");
|
|
@@ -476,8 +477,21 @@ function create(opts) {
|
|
|
476
477
|
Promise.resolve().then(function () { return opts.accountsFor(actor); })
|
|
477
478
|
.then(function (accountInfo) {
|
|
478
479
|
var info = accountInfo || { primaryAccounts: {}, accounts: {} };
|
|
480
|
+
// RFC 8887 §3 — advertise the WebSocket transport in the
|
|
481
|
+
// capabilities object so RFC-compliant clients discover the
|
|
482
|
+
// endpoint via the canonical session-discovery flow rather
|
|
483
|
+
// than depending on the top-level `webSocketUrl` alias.
|
|
484
|
+
var defaultCaps = { "urn:ietf:params:jmap:core": {} };
|
|
485
|
+
var hasOperatorWsCap = Object.prototype.hasOwnProperty.call(
|
|
486
|
+
serverCapabilities, "urn:ietf:params:jmap:websocket");
|
|
487
|
+
if (!hasOperatorWsCap) {
|
|
488
|
+
defaultCaps["urn:ietf:params:jmap:websocket"] = {
|
|
489
|
+
url: opts.webSocketUrl || "/jmap/ws",
|
|
490
|
+
supportsPush: true,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
479
493
|
var session = {
|
|
480
|
-
capabilities: Object.assign({},
|
|
494
|
+
capabilities: Object.assign({}, defaultCaps, serverCapabilities),
|
|
481
495
|
accounts: info.accounts || {},
|
|
482
496
|
primaryAccounts: info.primaryAccounts || {},
|
|
483
497
|
username: actor.username || actor.id || "unknown",
|
|
@@ -485,6 +499,13 @@ function create(opts) {
|
|
|
485
499
|
downloadUrl: opts.downloadUrl || "/jmap/download/{accountId}/{blobId}/{name}?accept={type}",
|
|
486
500
|
uploadUrl: opts.uploadUrl || "/jmap/upload/{accountId}",
|
|
487
501
|
eventSourceUrl: opts.eventSourceUrl || "/jmap/eventsource?types={types}&closeafter={closeafter}&ping={ping}",
|
|
502
|
+
// RFC 8887 §3 — `webSocketUrl` advertises the JMAP WS
|
|
503
|
+
// endpoint. Operator overrides via opts.webSocketUrl; default
|
|
504
|
+
// mounts at `/jmap/ws`.
|
|
505
|
+
urlEndpointResolution: serverCapabilities["urn:ietf:params:jmap:websocket"]
|
|
506
|
+
? { useEndpoint: opts.webSocketUrl || "/jmap/ws", urlPrefix: "" }
|
|
507
|
+
: undefined,
|
|
508
|
+
webSocketUrl: opts.webSocketUrl || "/jmap/ws",
|
|
488
509
|
state: sessionState,
|
|
489
510
|
};
|
|
490
511
|
res.statusCode = 200;
|
|
@@ -971,6 +992,204 @@ function create(opts) {
|
|
|
971
992
|
});
|
|
972
993
|
}
|
|
973
994
|
|
|
995
|
+
// RFC 8887 — JMAP over WebSocket. The session-resource's `webSocketUrl`
|
|
996
|
+
// points at this handler. Client opens a WS connection with the `jmap`
|
|
997
|
+
// subprotocol; bidirectional JSON frames carry `{ "@type": "Request" }`
|
|
998
|
+
// / `{ "@type": "WebSocketPushEnable" }` / `{ "@type":
|
|
999
|
+
// "WebSocketPushDisable" }` from the client, and `{ "@type":
|
|
1000
|
+
// "Response" }` / `{ "@type": "StateChange" }` / `{ "@type":
|
|
1001
|
+
// "RequestError" }` from the server.
|
|
1002
|
+
function webSocketHandler(req, socket, head) {
|
|
1003
|
+
var actor = req.user || (req.actor || null);
|
|
1004
|
+
if (!actor) {
|
|
1005
|
+
try { socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); socket.destroy(); }
|
|
1006
|
+
catch (_e) { /* silent-catch: socket already torn down */ }
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
var conn = websocket.handleUpgrade(req, socket, head, {
|
|
1010
|
+
subprotocols: ["jmap"],
|
|
1011
|
+
origins: opts.webSocketOrigins || null,
|
|
1012
|
+
maxMessageBytes: opts.webSocketMaxMessageBytes || (10 * 1024 * 1024), // allow:raw-byte-literal — 10 MiB JMAP WS message cap
|
|
1013
|
+
// permessage-deflate is off by default — same CRIME-class threat
|
|
1014
|
+
// model as the IMAP COMPRESS=DEFLATE intentional skip in v0.11.28.
|
|
1015
|
+
// Operators opt in via opts.webSocketPermessageDeflate.
|
|
1016
|
+
permessageDeflate: opts.webSocketPermessageDeflate === true,
|
|
1017
|
+
});
|
|
1018
|
+
if (!conn) return null;
|
|
1019
|
+
// RFC 8887 §3.1 — server MUST select `jmap` if offered. Refuse the
|
|
1020
|
+
// connection cleanly if subprotocol negotiation came back null.
|
|
1021
|
+
if (conn.subprotocol !== "jmap") {
|
|
1022
|
+
try { conn.close(1002, "RFC 8887 requires Sec-WebSocket-Protocol: jmap"); } // allow:raw-byte-literal — RFC 6455 protocol-error close code
|
|
1023
|
+
catch (_e) { /* silent-catch: closed */ }
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
var pushUnsubscribe = null;
|
|
1028
|
+
var pushEnabled = false;
|
|
1029
|
+
var pushSetupPromise = null;
|
|
1030
|
+
var connClosed = false;
|
|
1031
|
+
|
|
1032
|
+
function _sendJson(obj) {
|
|
1033
|
+
try { conn.send(JSON.stringify(obj)); }
|
|
1034
|
+
catch (_e) { /* silent-catch: socket already torn down */ }
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function _sendRequestError(requestId, type, description) {
|
|
1038
|
+
_sendJson({
|
|
1039
|
+
"@type": "RequestError",
|
|
1040
|
+
requestId: requestId || null,
|
|
1041
|
+
type: type,
|
|
1042
|
+
description: description,
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
conn.on("message", function (data, isBinary) {
|
|
1047
|
+
if (isBinary) {
|
|
1048
|
+
_sendRequestError(null,
|
|
1049
|
+
"urn:ietf:params:jmap:error:notJSON",
|
|
1050
|
+
"WebSocket frame must be a JSON text frame (RFC 8887 §4)");
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
var text = data.toString("utf8");
|
|
1054
|
+
if (text.length > (opts.webSocketMaxMessageBytes || (10 * 1024 * 1024))) { // allow:raw-byte-literal — mirrors handleUpgrade cap
|
|
1055
|
+
_sendRequestError(null,
|
|
1056
|
+
"urn:ietf:params:jmap:error:limit",
|
|
1057
|
+
"WebSocket message exceeds maxSizeRequest");
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
var parsed;
|
|
1061
|
+
try { parsed = safeJson.parse(text, { maxBytes: opts.webSocketMaxMessageBytes || (10 * 1024 * 1024) }); } // allow:raw-byte-literal — mirrors handleUpgrade cap
|
|
1062
|
+
catch (_e) {
|
|
1063
|
+
_sendRequestError(null,
|
|
1064
|
+
"urn:ietf:params:jmap:error:notJSON",
|
|
1065
|
+
"WebSocket frame is not valid JSON");
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
var type = parsed && parsed["@type"];
|
|
1069
|
+
var requestId = parsed && parsed.id;
|
|
1070
|
+
|
|
1071
|
+
if (type === "Request") {
|
|
1072
|
+
// RFC 8887 §4 — `Request` carries the same body as the HTTP
|
|
1073
|
+
// POST: `{ using, methodCalls, createdIds? }`. Dispatch
|
|
1074
|
+
// through the existing dispatch path; response is wrapped in
|
|
1075
|
+
// `{ "@type": "Response", requestId, methodResponses, createdIds? }`.
|
|
1076
|
+
// EXCEPT when dispatch returns a refusal shape — request-
|
|
1077
|
+
// level validation failure (`{ type, description,
|
|
1078
|
+
// methodResponses: [] }`) — those MUST surface as
|
|
1079
|
+
// `{ "@type": "RequestError" }` so the client can distinguish
|
|
1080
|
+
// an invalid request from a valid empty-result Response.
|
|
1081
|
+
Promise.resolve()
|
|
1082
|
+
.then(function () { return dispatch(actor, parsed); })
|
|
1083
|
+
.then(function (rv) {
|
|
1084
|
+
if (rv && typeof rv.type === "string" && typeof rv.description === "string") {
|
|
1085
|
+
_sendRequestError(requestId, rv.type, rv.description);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
_sendJson({
|
|
1089
|
+
"@type": "Response",
|
|
1090
|
+
requestId: requestId,
|
|
1091
|
+
methodResponses: rv.methodResponses,
|
|
1092
|
+
sessionState: rv.sessionState,
|
|
1093
|
+
createdIds: rv.createdIds,
|
|
1094
|
+
});
|
|
1095
|
+
})
|
|
1096
|
+
.catch(function (err) {
|
|
1097
|
+
_sendRequestError(requestId,
|
|
1098
|
+
(err && err.code) || "urn:ietf:params:jmap:error:serverFail",
|
|
1099
|
+
(err && err.message) || "Dispatch failed");
|
|
1100
|
+
});
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
if (type === "WebSocketPushEnable") {
|
|
1105
|
+
if (typeof opts.mailStore.subscribePush !== "function") {
|
|
1106
|
+
_sendRequestError(null,
|
|
1107
|
+
"urn:ietf:params:jmap:error:serverUnavailable",
|
|
1108
|
+
"Push subscribe backend not configured (mailStore.subscribePush)");
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
// RFC 8887 §5 — duplicate enable is a no-op. Also refuse
|
|
1112
|
+
// mid-subscription concurrency: if a previous PushEnable hasn't
|
|
1113
|
+
// resolved yet, the second is treated as no-op. Without this
|
|
1114
|
+
// gate a fast-firing enable/disable/enable sequence could
|
|
1115
|
+
// leak duplicate backend subscriptions OR end up with an
|
|
1116
|
+
// un-unsubscribed handle when connection closes.
|
|
1117
|
+
if (pushEnabled) return;
|
|
1118
|
+
// SYNC flip: gate concurrent PushEnable calls before the
|
|
1119
|
+
// async subscribePush resolves. PushDisable / close that
|
|
1120
|
+
// arrive in the gap see pushEnabled=true + a pending
|
|
1121
|
+
// pushSetupPromise; the setup promise's `then` handles the
|
|
1122
|
+
// late-cleanup case via the connClosed flag.
|
|
1123
|
+
pushEnabled = true;
|
|
1124
|
+
var dataTypes = Array.isArray(parsed.dataTypes) && parsed.dataTypes.length > 0
|
|
1125
|
+
? parsed.dataTypes : null;
|
|
1126
|
+
pushSetupPromise = Promise.resolve()
|
|
1127
|
+
.then(function () {
|
|
1128
|
+
return opts.mailStore.subscribePush(actor, dataTypes, function (event) {
|
|
1129
|
+
if (!event || connClosed) return;
|
|
1130
|
+
if (event.kind === "StateChange") {
|
|
1131
|
+
_sendJson({
|
|
1132
|
+
"@type": "StateChange",
|
|
1133
|
+
changed: event.changed || {},
|
|
1134
|
+
pushed: event.pushed,
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
})
|
|
1139
|
+
.then(function (unsub) {
|
|
1140
|
+
pushUnsubscribe = typeof unsub === "function" ? unsub : null;
|
|
1141
|
+
// Late cleanup: if Disable / close arrived in the setup
|
|
1142
|
+
// gap, run the unsubscribe immediately.
|
|
1143
|
+
if ((connClosed || !pushEnabled) && typeof pushUnsubscribe === "function") {
|
|
1144
|
+
try { pushUnsubscribe(); }
|
|
1145
|
+
catch (_e) { /* silent-catch: drop-silent — unsubscribe is best-effort */ }
|
|
1146
|
+
pushUnsubscribe = null;
|
|
1147
|
+
}
|
|
1148
|
+
})
|
|
1149
|
+
.catch(function (err) {
|
|
1150
|
+
// Setup failed — roll back the sync flip so a retry can
|
|
1151
|
+
// succeed, and surface the error to the operator client.
|
|
1152
|
+
pushEnabled = false;
|
|
1153
|
+
_sendRequestError(null,
|
|
1154
|
+
"urn:ietf:params:jmap:error:serverFail",
|
|
1155
|
+
(err && err.message) || "subscribePush threw");
|
|
1156
|
+
});
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (type === "WebSocketPushDisable") {
|
|
1161
|
+
// Mark disabled first so an in-flight subscribePush sees
|
|
1162
|
+
// pushEnabled=false in its late-cleanup branch.
|
|
1163
|
+
pushEnabled = false;
|
|
1164
|
+
if (typeof pushUnsubscribe === "function") {
|
|
1165
|
+
try { pushUnsubscribe(); }
|
|
1166
|
+
catch (_e) { /* silent-catch: drop-silent — unsubscribe is best-effort */ }
|
|
1167
|
+
}
|
|
1168
|
+
pushUnsubscribe = null;
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
_sendRequestError(requestId,
|
|
1173
|
+
"urn:ietf:params:jmap:error:unknownDataType",
|
|
1174
|
+
"Unknown WebSocket frame @type '" + type + "' (RFC 8887 §4)");
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
conn.on("close", function () {
|
|
1178
|
+
// SYNC flip — an in-flight subscribePush.then() observes
|
|
1179
|
+
// connClosed=true and runs the late-cleanup unsubscribe path.
|
|
1180
|
+
connClosed = true;
|
|
1181
|
+
pushEnabled = false;
|
|
1182
|
+
if (typeof pushUnsubscribe === "function") {
|
|
1183
|
+
try { pushUnsubscribe(); }
|
|
1184
|
+
catch (_e) { /* silent-catch: drop-silent */ }
|
|
1185
|
+
}
|
|
1186
|
+
pushUnsubscribe = null;
|
|
1187
|
+
});
|
|
1188
|
+
void pushSetupPromise;
|
|
1189
|
+
|
|
1190
|
+
return conn;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
974
1193
|
function discoveryHandler(req, res) {
|
|
975
1194
|
// RFC 8620 §2.2 — well-known endpoint redirects (or directly returns)
|
|
976
1195
|
// the session URL. We redirect to /jmap/session per the most common
|
|
@@ -990,6 +1209,7 @@ function create(opts) {
|
|
|
990
1209
|
eventSourceHandler: eventSourceHandler,
|
|
991
1210
|
uploadHandler: uploadHandler,
|
|
992
1211
|
downloadHandler: downloadHandler,
|
|
1212
|
+
webSocketHandler: webSocketHandler,
|
|
993
1213
|
MailServerJmapError: MailServerJmapError,
|
|
994
1214
|
};
|
|
995
1215
|
}
|
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:ce80c06c-78db-449e-871f-1b1da1e018c7",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-21T20:42:06.128Z",
|
|
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.35",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.11.
|
|
25
|
+
"version": "0.11.35",
|
|
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.35",
|
|
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.35",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|