From cc073693f4f9a60c21a37dd0ab7791bc13dc8c46 Mon Sep 17 00:00:00 2001 From: John Ellis Date: Tue, 25 Aug 2026 08:26:34 -0700 Subject: [PATCH 1/3] Say it in the words Stalwart 0.16 answers to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guests added to an event vanished on save and no invitation was ever sent. Not a guard in the editor, and nothing the server complained about: ihasmail addresses a participant the way RFC 8984 does, with sendTo and email, and Stalwart 0.16 keeps that address under calendarAddress. Handed the RFC's spelling it stores the event, drops the entire participant map, and reports success. Six shapes were tried against a live 0.16.19, down to sendTo and roles alone; all six were dropped, and patching a participant onto an existing event fails outright with "Patch operation failed". The same disagreement runs through two more properties. The organizer is organizerCalendarAddress, not replyTo. A recurrence is a single recurrenceRule, not a recurrenceRules array — and that one Stalwart refuses honestly, with invalidProperties, so no recurring event could be created at all and existing ones showed no repeat. So writes now use Stalwart's names and reads accept either, since a mailbox may hold events written by other clients. The mock now refuses what the real server refuses and drops what it drops: advertising the RFC spelling is exactly how this reached a live server unnoticed, the same way the capability-placement bug did. Verified against 0.16.19: participants, organizer and rule all survive a create, an update and a re-read, with the roles kept as sent. Fixes #26 Fixes #30 --- README.md | 1 + server/src/mock/index.ts | 31 ++++++++-- web/src/jmap/types.ts | 4 ++ web/src/store/__tests__/participants.test.ts | 58 +++++++++++++++++++ web/src/store/calendar.ts | 53 +++++++++++++++-- .../views/calendar/CalendarContextMenu.tsx | 2 +- web/src/views/calendar/CalendarView.tsx | 5 +- web/src/views/calendar/EventEditor.tsx | 20 ++++--- web/src/views/calendar/EventPopover.tsx | 8 +-- web/src/views/mail/InviteCard.tsx | 8 +-- 10 files changed, 160 insertions(+), 30 deletions(-) create mode 100644 web/src/store/__tests__/participants.test.ts diff --git a/README.md b/README.md index ae7c9fd..6d418d9 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,7 @@ the registry path is **awaiting live re-verification**. - **Files on Stalwart before 0.16** — three things differ there, none of which the server reports as an error. (Confirmed live on 0.15.5 before the upgrade. The live instance now runs 0.16.19, where folder creation, upload, rename, move and delete were also exercised — but under the capability-placement bug below, which means what ran there was this older path against a 0.16 server, not the 0.16 path. Files now takes the 0.16 path and wants checking again on its own terms. The older path is kept for anyone still on 0.15.x and covered by `npm run dev:mock:legacy`.) `FileNode/query` masks its results to non-containers, so it returns files and **never folders**; `nodeType` does not exist, and sending it fails the create outright (a directory is instead a node with no file properties at all); and rights are only `mayRead`/`mayWrite`/`mayShare`, so the finer-grained `mayDelete`/`mayRename` the UI gates on are absent. ihasmail detects the older server by the absence of `urn:stalwart:jmap` — looked for in `primaryAccounts` and `accountCapabilities` as well as the session capabilities, since that is where 0.16 actually advertises it — lists the tree through `FileNode/get` instead of query, shapes creates accordingly, and widens the old rights. Upload, folder creation, listing, rename, move and delete are all confirmed live on 0.15.5 (2026-08-24). - **Self-service credentials** — the **0.15.x REST path was confirmed live** against Stalwart 0.15.5 (2026-08-24): password change, app passwords, and enabling and disabling 2FA, on a real mailbox. The **0.16 registry path is confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The mock enforces the same rules either way (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens. - **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honours a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, cancelled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. **Not yet exercised against the live server** — verified end to end against the mock only. +- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/LINUXexpert-org/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/LINUXexpert-org/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25: participants, organizer and rule all survive a create, an update, and a re-read. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence. - Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet). - Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented. - The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation. diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 6113103..f6c7266 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -151,8 +151,8 @@ const events: Obj[] = []; const d = (dayOff: number, h: number) => { const x = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOff, h, 0, 0); return x; }; const local = (x: Date) => `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}T${String(x.getHours()).padStart(2, "0")}:00:00`; const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; - events.push({ id: "ev1", calendarIds: { c1: true }, "@type": "Event", uid: "ev1", title: "Standup", start: local(d(0, 9)), timeZone: tz, duration: "PT30M", recurrenceRules: [{ "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] }], showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" }); - events.push({ id: "ev2", calendarIds: { c2: true }, "@type": "Event", uid: "ev2", title: "Design review", start: local(d(1, 14)), timeZone: tz, duration: "PT1H30M", showWithoutTime: false, locations: { l: { "@type": "Location", name: "Room 2" } }, participants: { me: { "@type": "Participant", name: "Demo User", email: USER, sendTo: { imip: `mailto:${USER}` }, roles: { owner: true, attendee: true }, participationStatus: "accepted" }, p2: { "@type": "Participant", name: "Ada Lovelace", email: "ada@example.org", sendTo: { imip: "mailto:ada@example.org" }, roles: { attendee: true }, participationStatus: "needs-action", expectReply: true } }, replyTo: { imip: `mailto:${USER}` } }); + events.push({ id: "ev1", calendarIds: { c1: true }, "@type": "Event", uid: "ev1", title: "Standup", start: local(d(0, 9)), timeZone: tz, duration: "PT30M", recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] }, showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" }); + events.push({ id: "ev2", calendarIds: { c2: true }, "@type": "Event", uid: "ev2", title: "Design review", start: local(d(1, 14)), timeZone: tz, duration: "PT1H30M", showWithoutTime: false, locations: { l: { "@type": "Location", name: "Room 2" } }, participants: { me: { "@type": "Participant", name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { owner: true, attendee: true }, participationStatus: "accepted" }, p2: { "@type": "Participant", name: "Ada Lovelace", calendarAddress: "mailto:ada@example.org", roles: { attendee: true, required: true }, participationStatus: "needs-action", expectReply: true } }, organizerCalendarAddress: `mailto:${USER}` }); events.push({ id: "ev3", calendarIds: { c1: true }, "@type": "Event", uid: "ev3", title: "Conference", start: local(d(3, 0)).slice(0, 10) + "T00:00:00", duration: "P2D", showWithoutTime: true, timeZone: null }); events.push({ id: "ev4", calendarIds: { c1: true }, "@type": "Event", uid: "ev4", title: "Lunch with Grace", start: local(d(2, 12)), timeZone: tz, duration: "PT1H", showWithoutTime: false, color: "#db2777" }); } @@ -323,6 +323,12 @@ function genericGet(list: Obj[]) { return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] }; }; } +/** Thrown from an onCreate hook to refuse a create the way a real server would. */ +class SetError extends Error { + constructor(readonly type: string, readonly description: string, readonly properties?: string[]) { super(description); } + toJSON(): Obj { return { type: this.type, description: this.description, ...(this.properties ? { properties: this.properties } : {}) }; } +} + function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) { return (a: Obj) => { const created: Obj = {}; @@ -332,7 +338,13 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) { for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) { const id = `${prefix}${randomUUID().slice(0, 6)}`; const o = { ...(obj as Obj), id }; - onCreate?.(o); + try { + onCreate?.(o); + } catch (err) { + if (!(err instanceof SetError)) throw err; + notCreated[cid] = err.toJSON(); + continue; + } list.push(o); created[cid] = { id }; } @@ -581,8 +593,17 @@ const handlers: Record = { "Calendar/set": genericSet(calendars, "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o })), "CalendarEvent/query": (a) => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: events.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: events.length }), "CalendarEvent/get": genericGet(events), - "CalendarEvent/set": genericSet(events, "ev", (o) => Object.assign(o, { uid: o.uid ?? randomUUID() })), - "CalendarEvent/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const blob = blobs.get(b); if (!blob) continue; const t = blob.data.toString(); const g = (k: string) => new RegExp(`^${k}[^:]*:(.*)$`, "m").exec(t)?.[1]?.trim(); const ds = g("DTSTART") ?? "20260101T000000Z"; const de = g("DTEND") ?? ds; const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`; const start = new Date(`${toLocal(ds)}Z`); const end = new Date(`${toLocal(de)}Z`); parsed[b] = { "@type": "Event", uid: g("UID"), title: g("SUMMARY"), start: toLocal(ds), timeZone: "Etc/UTC", duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`, method: g("METHOD"), locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined, participants: { org: { name: "Ada Lovelace", email: "ada@example.org", sendTo: { imip: "mailto:ada@example.org" }, roles: { owner: true } }, me: { name: "Demo User", email: USER, sendTo: { imip: `mailto:${USER}` }, roles: { attendee: true }, participationStatus: "needs-action" } } }; } return { accountId: ACCOUNT, parsed, notParsable: [] }; }, + // Stalwart 0.16 rejects the RFC 8984 array outright and silently discards + // participants addressed the RFC 8984 way. The mock did neither, which is how + // #26 and #30 reached a live server unnoticed — so it now does both. + "CalendarEvent/set": genericSet(events, "ev", (o) => { + if (o.recurrenceRules) throw new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]); + const parts = o.participants as Record | undefined; + if (parts && Object.values(parts).some((p) => !p.calendarAddress)) delete o.participants; + if (o.replyTo && !o.organizerCalendarAddress) delete o.replyTo; + return Object.assign(o, { uid: o.uid ?? randomUUID() }); + }), + "CalendarEvent/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const blob = blobs.get(b); if (!blob) continue; const t = blob.data.toString(); const g = (k: string) => new RegExp(`^${k}[^:]*:(.*)$`, "m").exec(t)?.[1]?.trim(); const ds = g("DTSTART") ?? "20260101T000000Z"; const de = g("DTEND") ?? ds; const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`; const start = new Date(`${toLocal(ds)}Z`); const end = new Date(`${toLocal(de)}Z`); parsed[b] = { "@type": "Event", uid: g("UID"), title: g("SUMMARY"), start: toLocal(ds), timeZone: "Etc/UTC", duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`, method: g("METHOD"), locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined, participants: { org: { name: "Ada Lovelace", calendarAddress: "mailto:ada@example.org", roles: { owner: true } }, me: { name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { attendee: true, required: true }, participationStatus: "needs-action" } } }; } return { accountId: ACCOUNT, parsed, notParsable: [] }; }, "ParticipantIdentity/get": genericGet(participantIdentities), "Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }), "Principal/get": genericGet(principals), diff --git a/web/src/jmap/types.ts b/web/src/jmap/types.ts index b25cee1..16577f1 100644 --- a/web/src/jmap/types.ts +++ b/web/src/jmap/types.ts @@ -615,6 +615,8 @@ export interface JSCalendarParticipant { email?: string; description?: string; sendTo?: Record; + /** Where Stalwart 0.16 keeps the address, in place of `sendTo` / `email`. */ + calendarAddress?: string; kind?: "individual" | "group" | "location" | "resource"; roles: Record; locationId?: string; @@ -688,6 +690,8 @@ export interface JSCalendarEvent { freeBusyStatus?: "free" | "busy"; privacy?: "public" | "private" | "secret"; replyTo?: Record; + /** Where Stalwart 0.16 keeps the organizer, in place of `replyTo`. */ + organizerCalendarAddress?: string; sentBy?: string; participants?: Record; requestStatus?: string; diff --git a/web/src/store/__tests__/participants.test.ts b/web/src/store/__tests__/participants.test.ts new file mode 100644 index 0000000..9796ed9 --- /dev/null +++ b/web/src/store/__tests__/participants.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { participantAddresses, participantEmail, isAttendee, eventRule, makeParticipant } from "@/store/calendar"; +import type { CalendarEvent, JSCalendarParticipant } from "@/jmap/types"; + +/** + * Stalwart 0.16.19 and RFC 8984 disagree about where a participant's address + * lives. Sent the RFC's way, Stalwart keeps the event and drops the participant + * map without a word — guests vanished and no invitation was ever sent (#26). + * Shapes below are what a live 0.16.19 returned. + */ +const p = (o: Partial): JSCalendarParticipant => ({ roles: {}, ...o }); +const ev = (o: Partial): CalendarEvent => ({ id: "e1", "@type": "Event", uid: "u1", calendarIds: { c1: true }, start: "2030-01-01T10:00:00", ...o } as CalendarEvent); + +describe("participant addresses", () => { + it("reads Stalwart's calendarAddress", () => { + expect(participantEmail(p({ calendarAddress: "mailto:guest@example.com" }))).toBe("guest@example.com"); + }); + it("still reads the RFC 8984 spellings, for events written by other clients", () => { + expect(participantEmail(p({ sendTo: { imip: "mailto:ada@example.org" } }))).toBe("ada@example.org"); + expect(participantEmail(p({ email: "ada@example.org" }))).toBe("ada@example.org"); + expect(participantAddresses(p({ calendarAddress: "mailto:A@b.com", email: "c@d.com" }))).toEqual(["mailto:a@b.com", "mailto:c@d.com"]); + }); + it("has no address to offer when the participant carries none", () => { + expect(participantEmail(p({ name: "Nameless" }))).toBe(""); + }); + it("counts a participant as attending under either role name", () => { + expect(isAttendee(p({ roles: { attendee: true } }))).toBe(true); + expect(isAttendee(p({ roles: { required: true } }))).toBe(true); // what Stalwart writes for REQ-PARTICIPANT + expect(isAttendee(p({ roles: { optional: true } }))).toBe(true); + expect(isAttendee(p({ roles: { owner: true } }))).toBe(false); + }); +}); + +describe("makeParticipant", () => { + it("addresses a guest the way Stalwart stores them", () => { + const guest = makeParticipant("guest@example.com", "Guest", "attendee"); + expect(guest.calendarAddress).toBe("mailto:guest@example.com"); + expect(guest.sendTo).toBeUndefined(); + expect(guest.roles).toEqual({ attendee: true, required: true }); + expect(guest.participationStatus).toBe("needs-action"); + expect(guest.expectReply).toBe(true); + }); + it("marks the organizer as owner and keeps a status already given", () => { + const me = makeParticipant("john@linuxexperts.net", "John Coffey", "owner"); + expect(me.roles).toEqual({ owner: true, attendee: true }); + expect(me.participationStatus).toBe("accepted"); + expect(me.expectReply).toBe(false); + expect(makeParticipant("g@example.com", null, "attendee", "declined").participationStatus).toBe("declined"); + }); +}); + +describe("eventRule", () => { + it("reads Stalwart's singular rule and the RFC's array", () => { + expect(eventRule(ev({ recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly" } }))?.frequency).toBe("weekly"); + expect(eventRule(ev({ recurrenceRules: [{ "@type": "RecurrenceRule", frequency: "daily" }] }))?.frequency).toBe("daily"); + expect(eventRule(ev({}))).toBeUndefined(); + }); +}); diff --git a/web/src/store/calendar.ts b/web/src/store/calendar.ts index e9f5138..3204e38 100644 --- a/web/src/store/calendar.ts +++ b/web/src/store/calendar.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import { CAP, client, setErrorMessage } from "@/jmap/client"; -import type { BusyPeriod, Calendar, CalendarEvent, GetResponse, Id, ParticipantIdentity, QueryResponse, SetResponse } from "@/jmap/types"; +import type { BusyPeriod, Calendar, CalendarEvent, GetResponse, Id, JSCalendarParticipant, JSCalendarRecurrenceRule, ParticipantIdentity, QueryResponse, SetResponse } from "@/jmap/types"; import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browserTimeZone } from "@/lib/dates"; import { settings } from "./settings"; import { useSession } from "./session"; @@ -57,7 +57,7 @@ const EVENT_PROPS = [ "id", "baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd", "useDefaultAlerts", "mayInviteSelf", "mayInviteOthers", "hideAttendees", "uid", "relatedTo", "prodId", "created", "updated", "sequence", "title", "description", "descriptionContentType", "showWithoutTime", "locations", "virtualLocations", "links", "locale", "keywords", "categories", "color", "recurrenceId", "recurrenceIdTimeZone", - "recurrenceRules", "excludedRecurrenceRules", "recurrenceOverrides", "excluded", "priority", "freeBusyStatus", "privacy", "replyTo", + "recurrenceRules", "recurrenceRule", "excludedRecurrenceRules", "recurrenceOverrides", "excluded", "priority", "freeBusyStatus", "privacy", "replyTo", "organizerCalendarAddress", "sentBy", "participants", "requestStatus", "alerts", "timeZone", "start", "duration", "status", ]; @@ -338,6 +338,52 @@ export function toInstance(e: CalendarEvent, calendars: Record): E return { key: e.id, event: e, start, end, allDay, calendar: calId ? calendars[calId] : undefined }; } +/** + * Every address a participant answers to, as lowercase `mailto:` URIs. + * + * Stalwart 0.16 keeps one address under `calendarAddress`; RFC 8984 spreads it + * over `sendTo` and `email`. Reading has to accept all three — a mailbox may + * hold events written by either, and by other clients besides. + */ +export function participantAddresses(p: JSCalendarParticipant): string[] { + return [p.calendarAddress ?? "", ...Object.values(p.sendTo ?? {}), p.email ? `mailto:${p.email}` : ""] + .filter(Boolean) + .map((a) => a.toLowerCase()); +} + +/** The address to show or write to, without the `mailto:`. */ +export function participantEmail(p: JSCalendarParticipant): string { + return (participantAddresses(p)[0] ?? "").replace(/^mailto:/i, ""); +} + +/** Whether this participant is attending, under any of the role names in use. */ +export function isAttendee(p: JSCalendarParticipant): boolean { + return Boolean(p.roles?.attendee || p.roles?.required || p.roles?.optional || p.roles?.chair); +} + +/** The event's recurrence rule, under either spelling. */ +export function eventRule(ev: CalendarEvent): JSCalendarRecurrenceRule | undefined { + return ev.recurrenceRule ?? ev.recurrenceRules?.[0]; +} + +/** + * Builds a participant the way Stalwart 0.16 stores them: the address under + * `calendarAddress`. Sent under RFC 8984's `sendTo`/`email` instead, the server + * keeps the event and drops the whole participant map without saying so — which + * is how invitations came to vanish (#26). + */ +export function makeParticipant(email: string, name: string | null | undefined, role: "owner" | "attendee", status?: string): JSCalendarParticipant { + return { + "@type": "Participant", + name: name || undefined, + calendarAddress: `mailto:${email}`, + kind: "individual", + roles: role === "owner" ? { owner: true, attendee: true } : { attendee: true, required: true }, + participationStatus: (status as JSCalendarParticipant["participationStatus"]) ?? (role === "owner" ? "accepted" : "needs-action"), + expectReply: role !== "owner", + }; +} + export function myParticipantKeys(ev: CalendarEvent, identities: ParticipantIdentity[]): string[] { const mine = new Set(); for (const i of identities) { @@ -348,8 +394,7 @@ export function myParticipantKeys(ev: CalendarEvent, identities: ParticipantIden if (session?.username?.includes("@")) mine.add(`mailto:${session.username.toLowerCase()}`); const keys: string[] = []; for (const [k, p] of Object.entries(ev.participants ?? {})) { - const addrs = [...Object.values(p.sendTo ?? {}), p.email ? `mailto:${p.email}` : ""].map((a) => a.toLowerCase()); - if (addrs.some((a) => mine.has(a))) keys.push(k); + if (participantAddresses(p).some((a) => mine.has(a))) keys.push(k); } return keys; } diff --git a/web/src/views/calendar/CalendarContextMenu.tsx b/web/src/views/calendar/CalendarContextMenu.tsx index a4d179b..a544594 100644 --- a/web/src/views/calendar/CalendarContextMenu.tsx +++ b/web/src/views/calendar/CalendarContextMenu.tsx @@ -81,7 +81,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }: const duplicate = async () => { const { id: _i, baseEventId: _b, uid: _u, utcStart: _s, utcEnd: _e, isOrigin: _o, calendarIds, created: _c, updated: _up, sequence: _sq, recurrenceId: _ri, recurrenceIdTimeZone: _rt, ...rest } = ev as CalendarEvent & Record; try { - await cal.createEvent({ ...rest, title: `Copy of ${ev.title ?? "event"}`, participants: undefined, replyTo: undefined } as Partial, Object.keys(calendarIds)[0] ?? Object.keys(cal.calendars)[0]!, false); + await cal.createEvent({ ...rest, title: `Copy of ${ev.title ?? "event"}`, participants: undefined, replyTo: undefined, organizerCalendarAddress: undefined } as Partial, Object.keys(calendarIds)[0] ?? Object.keys(cal.calendars)[0]!, false); toast.success("Event duplicated"); } catch (err) { toast.error((err as Error).message); diff --git a/web/src/views/calendar/CalendarView.tsx b/web/src/views/calendar/CalendarView.tsx index 9ba6522..f3da618 100644 --- a/web/src/views/calendar/CalendarView.tsx +++ b/web/src/views/calendar/CalendarView.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLocation } from "wouter"; import { ChevronLeft, ChevronRight, Plus, Calendar as CalIcon } from "lucide-react"; -import { useCalendar, type EventInstance } from "@/store/calendar"; +import { useCalendar, participantAddresses, type EventInstance } from "@/store/calendar"; import { useSettings } from "@/store/settings"; import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays } from "@/lib/dates"; import { formatMonthYear, formatTime } from "@/lib/format"; @@ -184,8 +184,7 @@ function statusClass(i: EventInstance): string { const ids = mine.flatMap((m) => [m.calendarAddress.toLowerCase(), ...Object.values(m.sendTo ?? {}).map((x) => x.toLowerCase())]); let my: string | undefined; for (const p of Object.values(ev.participants ?? {})) { - const addrs = [...Object.values(p.sendTo ?? {}), p.email ? `mailto:${p.email}` : ""].map((a) => a.toLowerCase()); - if (addrs.some((a) => ids.includes(a))) my = p.participationStatus; + if (participantAddresses(p).some((a) => ids.includes(a))) my = p.participationStatus; } if (ev.status === "cancelled") return "cancelled"; if (my === "declined") return "declined"; diff --git a/web/src/views/calendar/EventEditor.tsx b/web/src/views/calendar/EventEditor.tsx index af9c765..4602596 100644 --- a/web/src/views/calendar/EventEditor.tsx +++ b/web/src/views/calendar/EventEditor.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { Plus, Trash2, Users } from "lucide-react"; import type { BusyPeriod, CalendarEvent, EmailAddress, JSCalendarAlert, JSCalendarParticipant, JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types"; -import { useCalendar, myParticipantKeys, isRecurring } from "@/store/calendar"; +import { useCalendar, myParticipantKeys, isRecurring, eventRule, makeParticipant, participantEmail } from "@/store/calendar"; import { useSettings } from "@/store/settings"; import { useSession } from "@/store/session"; import { useContacts } from "@/store/contacts"; @@ -67,8 +67,8 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE const [color, setColor] = useState(ev?.color ?? null); const categories = useSettings((s) => s.settings.eventCategories); const [category, setCategory] = useState(() => Object.keys(ev?.categories ?? {}).find((n) => categories.some((c) => c.name.toLowerCase() === n.toLowerCase())) ?? ""); - const [rule, setRule] = useState(ev?.recurrenceRules?.[0]); - const [preset, setPreset] = useState(presetFor(ev?.recurrenceRules?.[0])); + const [rule, setRule] = useState(ev ? eventRule(ev) : undefined); + const [preset, setPreset] = useState(presetFor(ev ? eventRule(ev) : undefined)); const [alerts, setAlerts] = useState(() => { const a = Object.values(ev?.alerts ?? {}).map((x) => ("offset" in x.trigger ? -parseDuration(x.trigger.offset) / 60 : 0)).filter((n) => n >= 0); if (ev) return a; @@ -78,7 +78,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE const [attendees, setAttendees] = useState(() => Object.entries(ev?.participants ?? {}) .filter(([k, p]) => !myKeys.includes(k) && !(p.roles?.owner && !p.roles?.attendee)) - .map(([, p]) => ({ name: p.name ?? null, email: p.email ?? Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, "") ?? "" })) + .map(([, p]) => ({ name: p.name ?? null, email: participantEmail(p) })) .filter((a) => a.email), ); const [sendInvites, setSendInvites] = useState(true); @@ -142,11 +142,11 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE if (allDay && e <= s) e = new Date(s.getTime() + DAY_MS); const participants: Record = {}; if (attendees.length && myAddress) { - participants.me = { "@type": "Participant", name: identity?.name || undefined, email: myPlainEmail, sendTo: { imip: myAddress }, kind: "individual", roles: { owner: true, attendee: true }, participationStatus: "accepted", expectReply: false }; + participants.me = makeParticipant(myPlainEmail, identity?.name, "owner"); for (const a of attendees) { // preserve existing status if the attendee was already there - const existing = Object.values(ev?.participants ?? {}).find((p) => (p.email ?? Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, ""))?.toLowerCase() === a.email.toLowerCase()); - participants[newKey("p")] = { "@type": "Participant", name: a.name ?? undefined, email: a.email, sendTo: { imip: `mailto:${a.email}` }, kind: "individual", roles: { attendee: true }, participationStatus: existing?.participationStatus ?? "needs-action", expectReply: true }; + const existing = Object.values(ev?.participants ?? {}).find((p) => participantEmail(p).toLowerCase() === a.email.toLowerCase()); + participants[newKey("p")] = makeParticipant(a.email, a.name, "attendee", existing?.participationStatus); } } const alertObj: Record = {}; @@ -161,10 +161,12 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE locations: location.trim() ? { [newKey("l")]: { "@type": "Location", name: location.trim() } } : undefined, virtualLocations: vurl.trim() ? { [newKey("v")]: { "@type": "VirtualLocation", uri: vurl.trim(), name: "Online meeting" } } : undefined, participants: Object.keys(participants).length ? participants : undefined, - replyTo: Object.keys(participants).length && myAddress ? { imip: myAddress } : undefined, + // Stalwart 0.16 names the organizer here; RFC 8984's replyTo is ignored. + organizerCalendarAddress: Object.keys(participants).length && myAddress ? myAddress : undefined, alerts: Object.keys(alertObj).length ? alertObj : undefined, useDefaultAlerts: false, - recurrenceRules: rule ? [rule] : undefined, + // Singular, and no array: Stalwart 0.16 rejects `recurrenceRules` outright (#30). + recurrenceRule: rule ?? undefined, status, privacy, freeBusyStatus: freeBusy, diff --git a/web/src/views/calendar/EventPopover.tsx b/web/src/views/calendar/EventPopover.tsx index 160d338..564d5bd 100644 --- a/web/src/views/calendar/EventPopover.tsx +++ b/web/src/views/calendar/EventPopover.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { AlignLeft, Bell, Calendar as CalIcon, Check, Clock, HelpCircle, Link2, MapPin, Pencil, Repeat, Trash2, Users, X, Mail } from "lucide-react"; -import { useCalendar, myParticipantKeys, isRecurring, type EventInstance } from "@/store/calendar"; +import { useCalendar, myParticipantKeys, isRecurring, eventRule, participantEmail, type EventInstance } from "@/store/calendar"; import { Popover, type Anchor } from "@/ui/popover"; import { confirmDialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; @@ -66,7 +66,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns

{ev.title || "(untitled)"}

{formatTimeRange(inst.start, inst.end, inst.allDay)}{ev.timeZone && !inst.allDay ? · {ev.timeZone} : null}
- {ev.recurrenceRules?.[0] &&
{describeRule(ev.recurrenceRules[0])}
} + {eventRule(ev) &&
{describeRule(eventRule(ev)!)}
} {location?.name &&
{location.name}
} {vloc?.uri && } {ev.description &&
{ev.description}
} @@ -75,12 +75,12 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
{inst.calendar?.name ?? "Calendar"}{ev.status === "cancelled" ? " · cancelled" : ev.status === "tentative" ? " · tentative" : ""}{ev.privacy && ev.privacy !== "public" ? ` · ${ev.privacy}` : ""}{ev.freeBusyStatus === "free" ? " · shown as free" : ""}
{participants.length > 0 && (
-
{participants.length} participant{participants.length === 1 ? "" : "s"}
+
{participants.length} participant{participants.length === 1 ? "" : "s"}
{participants.map(([k, p]) => (
- {p.name || p.email || Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, "")} + {p.name || participantEmail(p)} {p.roles?.owner && organizer} {p.roles?.optional && optional}
diff --git a/web/src/views/mail/InviteCard.tsx b/web/src/views/mail/InviteCard.tsx index db4deba..937576f 100644 --- a/web/src/views/mail/InviteCard.tsx +++ b/web/src/views/mail/InviteCard.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { Calendar, Check, HelpCircle, MapPin, X } from "lucide-react"; import { useLocation } from "wouter"; import type { CalendarEvent, Email, EmailBodyPart } from "@/jmap/types"; -import { useCalendar, toInstance, myParticipantKeys } from "@/store/calendar"; +import { useCalendar, toInstance, myParticipantKeys, isAttendee, participantEmail } from "@/store/calendar"; import { formatTimeRange } from "@/lib/dates"; import { toast } from "@/ui/toast"; @@ -41,7 +41,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart const organizer = Object.values(ev.participants ?? {}).find((p) => p.roles?.owner); const location = Object.values(ev.locations ?? {})[0]?.name; const myStatus = existing ? (myParticipantKeys(existing, cal.identities).map((k) => existing.participants?.[k]?.participationStatus)[0] ?? null) : null; - const attendees = Object.values(ev.participants ?? {}).filter((p) => p.roles?.attendee); + const attendees = Object.values(ev.participants ?? {}).filter(isAttendee); const respond = async (status: "accepted" | "tentative" | "declined") => { setBusy(status); @@ -90,11 +90,11 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart

{ev.title || "(untitled event)"}

{inst &&
{formatTimeRange(inst.start, inst.end, inst.allDay)}{ev.timeZone ? ` (${ev.timeZone})` : ""}
} {location &&
{location}
} - {organizer &&
Organizer: {organizer.name || organizer.email || Object.values(organizer.sendTo ?? {})[0]?.replace("mailto:", "")}
} + {organizer &&
Organizer: {organizer.name || participantEmail(organizer)}
} {attendees.length > 0 &&
{attendees.length} attendee{attendees.length === 1 ? "" : "s"}
} {method === "REPLY" && (
- {attendees.map((a) =>
{a.name || a.email}: {a.participationStatus ?? "unknown"}
)} + {attendees.map((a) =>
{a.name || participantEmail(a)}: {a.participationStatus ?? "unknown"}
)}
)}
From 27dbc8ac417e96d7ccd67d8642a8c9f47fb514e9 Mon Sep 17 00:00:00 2001 From: John Ellis Date: Tue, 25 Aug 2026 08:31:47 -0700 Subject: [PATCH 2/3] Record that the invitation reached a real guest and came back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store side was proven earlier; the sending side had only been reasoned about. An invitation went to an external Gmail address from the live 0.16.19: it arrived as an invite card, the decline came back, and Stalwart applied it to the event — needs-action to declined, sequence 1. Cancelling notified the guest as well. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6d418d9..4a836df 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ the registry path is **awaiting live re-verification**. - **Files on Stalwart before 0.16** — three things differ there, none of which the server reports as an error. (Confirmed live on 0.15.5 before the upgrade. The live instance now runs 0.16.19, where folder creation, upload, rename, move and delete were also exercised — but under the capability-placement bug below, which means what ran there was this older path against a 0.16 server, not the 0.16 path. Files now takes the 0.16 path and wants checking again on its own terms. The older path is kept for anyone still on 0.15.x and covered by `npm run dev:mock:legacy`.) `FileNode/query` masks its results to non-containers, so it returns files and **never folders**; `nodeType` does not exist, and sending it fails the create outright (a directory is instead a node with no file properties at all); and rights are only `mayRead`/`mayWrite`/`mayShare`, so the finer-grained `mayDelete`/`mayRename` the UI gates on are absent. ihasmail detects the older server by the absence of `urn:stalwart:jmap` — looked for in `primaryAccounts` and `accountCapabilities` as well as the session capabilities, since that is where 0.16 actually advertises it — lists the tree through `FileNode/get` instead of query, shapes creates accordingly, and widens the old rights. Upload, folder creation, listing, rename, move and delete are all confirmed live on 0.15.5 (2026-08-24). - **Self-service credentials** — the **0.15.x REST path was confirmed live** against Stalwart 0.15.5 (2026-08-24): password change, app passwords, and enabling and disabling 2FA, on a real mailbox. The **0.16 registry path is confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The mock enforces the same rules either way (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens. - **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honours a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, cancelled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. **Not yet exercised against the live server** — verified end to end against the mock only. -- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/LINUXexpert-org/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/LINUXexpert-org/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25: participants, organizer and rule all survive a create, an update, and a re-read. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence. +- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/LINUXexpert-org/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/LINUXexpert-org/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action` → `declined`, sequence 1). Cancelling the event notified the guest too. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence. - Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet). - Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented. - The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation. From 377e3aba2acc4236a9d36f9434826b81acdedd82 Mon Sep 17 00:00:00 2001 From: John Ellis Date: Tue, 25 Aug 2026 08:39:40 -0700 Subject: [PATCH 3/3] Record that RSVP and the edit path hold up too Adding a participant by patch had failed earlier, which left a question over RSVP, since that patches participants/{key}/participationStatus. It works, comment and all, and so does adding guests to an event that had none and clearing them again with null. The patch has to name the base event: a synthetic id is refused with "Updating synthetic ids is not yet supported", which is exactly why rsvp resolves baseEventId first. Worth writing down before someone simplifies that line away. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4a836df..c451c99 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ the registry path is **awaiting live re-verification**. - **Files on Stalwart before 0.16** — three things differ there, none of which the server reports as an error. (Confirmed live on 0.15.5 before the upgrade. The live instance now runs 0.16.19, where folder creation, upload, rename, move and delete were also exercised — but under the capability-placement bug below, which means what ran there was this older path against a 0.16 server, not the 0.16 path. Files now takes the 0.16 path and wants checking again on its own terms. The older path is kept for anyone still on 0.15.x and covered by `npm run dev:mock:legacy`.) `FileNode/query` masks its results to non-containers, so it returns files and **never folders**; `nodeType` does not exist, and sending it fails the create outright (a directory is instead a node with no file properties at all); and rights are only `mayRead`/`mayWrite`/`mayShare`, so the finer-grained `mayDelete`/`mayRename` the UI gates on are absent. ihasmail detects the older server by the absence of `urn:stalwart:jmap` — looked for in `primaryAccounts` and `accountCapabilities` as well as the session capabilities, since that is where 0.16 actually advertises it — lists the tree through `FileNode/get` instead of query, shapes creates accordingly, and widens the old rights. Upload, folder creation, listing, rename, move and delete are all confirmed live on 0.15.5 (2026-08-24). - **Self-service credentials** — the **0.15.x REST path was confirmed live** against Stalwart 0.15.5 (2026-08-24): password change, app passwords, and enabling and disabling 2FA, on a real mailbox. The **0.16 registry path is confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The mock enforces the same rules either way (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens. - **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honours a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, cancelled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. **Not yet exercised against the live server** — verified end to end against the mock only. -- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/LINUXexpert-org/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/LINUXexpert-org/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action` → `declined`, sequence 1). Cancelling the event notified the guest too. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence. +- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/LINUXexpert-org/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/LINUXexpert-org/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action` → `declined`, sequence 1). Cancelling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch has to be aimed at the base event: `CalendarEvent/set` refuses a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence. - Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet). - Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented. - The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation.