Say it in the words Stalwart 0.16 answers to

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
This commit is contained in:
2026-08-25 08:26:34 -07:00
parent a9ff405135
commit cc073693f4
10 changed files with 160 additions and 30 deletions
+4
View File
@@ -615,6 +615,8 @@ export interface JSCalendarParticipant {
email?: string;
description?: string;
sendTo?: Record<string, string>;
/** Where Stalwart 0.16 keeps the address, in place of `sendTo` / `email`. */
calendarAddress?: string;
kind?: "individual" | "group" | "location" | "resource";
roles: Record<string, boolean>;
locationId?: string;
@@ -688,6 +690,8 @@ export interface JSCalendarEvent {
freeBusyStatus?: "free" | "busy";
privacy?: "public" | "private" | "secret";
replyTo?: Record<string, string>;
/** Where Stalwart 0.16 keeps the organizer, in place of `replyTo`. */
organizerCalendarAddress?: string;
sentBy?: string;
participants?: Record<string, JSCalendarParticipant>;
requestStatus?: string;
@@ -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>): JSCalendarParticipant => ({ roles: {}, ...o });
const ev = (o: Partial<CalendarEvent>): 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:[email protected]" }))).toBe("[email protected]");
});
it("still reads the RFC 8984 spellings, for events written by other clients", () => {
expect(participantEmail(p({ sendTo: { imip: "mailto:[email protected]" } }))).toBe("[email protected]");
expect(participantEmail(p({ email: "[email protected]" }))).toBe("[email protected]");
expect(participantAddresses(p({ calendarAddress: "mailto:[email protected]", email: "[email protected]" }))).toEqual(["mailto:[email protected]", "mailto:[email protected]"]);
});
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("[email protected]", "Guest", "attendee");
expect(guest.calendarAddress).toBe("mailto:[email protected]");
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("[email protected]", "John Coffey", "owner");
expect(me.roles).toEqual({ owner: true, attendee: true });
expect(me.participationStatus).toBe("accepted");
expect(me.expectReply).toBe(false);
expect(makeParticipant("[email protected]", 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();
});
});
+49 -4
View File
@@ -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<Id, Calendar>): 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<string>();
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;
}
@@ -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<string, unknown>;
try {
await cal.createEvent({ ...rest, title: `Copy of ${ev.title ?? "event"}`, participants: undefined, replyTo: undefined } as Partial<CalendarEvent>, 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<CalendarEvent>, Object.keys(calendarIds)[0] ?? Object.keys(cal.calendars)[0]!, false);
toast.success("Event duplicated");
} catch (err) {
toast.error((err as Error).message);
+2 -3
View File
@@ -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";
+11 -9
View File
@@ -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<string | null>(ev?.color ?? null);
const categories = useSettings((s) => s.settings.eventCategories);
const [category, setCategory] = useState<string>(() => Object.keys(ev?.categories ?? {}).find((n) => categories.some((c) => c.name.toLowerCase() === n.toLowerCase())) ?? "");
const [rule, setRule] = useState<JSCalendarRecurrenceRule | undefined>(ev?.recurrenceRules?.[0]);
const [preset, setPreset] = useState<RecurrencePreset>(presetFor(ev?.recurrenceRules?.[0]));
const [rule, setRule] = useState<JSCalendarRecurrenceRule | undefined>(ev ? eventRule(ev) : undefined);
const [preset, setPreset] = useState<RecurrencePreset>(presetFor(ev ? eventRule(ev) : undefined));
const [alerts, setAlerts] = useState<number[]>(() => {
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<EmailAddress[]>(() =>
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<string, JSCalendarParticipant> = {};
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<string, JSCalendarAlert> = {};
@@ -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,
+4 -4
View File
@@ -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
</div>
<h3>{ev.title || "(untitled)"}</h3>
<div className="ev-line"><Clock size={15} /><span>{formatTimeRange(inst.start, inst.end, inst.allDay)}{ev.timeZone && !inst.allDay ? <span className="hint"> · {ev.timeZone}</span> : null}</span></div>
{ev.recurrenceRules?.[0] && <div className="ev-line"><Repeat size={15} /><span>{describeRule(ev.recurrenceRules[0])}</span></div>}
{eventRule(ev) && <div className="ev-line"><Repeat size={15} /><span>{describeRule(eventRule(ev)!)}</span></div>}
{location?.name && <div className="ev-line"><MapPin size={15} /><span>{location.name}</span></div>}
{vloc?.uri && <div className="ev-line"><Link2 size={15} /><a href={vloc.uri} target="_blank" rel="noreferrer" className="truncate">{vloc.name || vloc.uri}</a></div>}
{ev.description && <div className="ev-line"><AlignLeft size={15} /><span style={{ whiteSpace: "pre-wrap", maxHeight: 160, overflow: "auto" }}>{ev.description}</span></div>}
@@ -75,12 +75,12 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
<div className="ev-line"><CalIcon size={15} /><span>{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" : ""}</span></div>
{participants.length > 0 && (
<div className="ev-line" style={{ flexDirection: "column", gap: 2 }}>
<div className="row gap-8"><Users size={15} /><span>{participants.length} participant{participants.length === 1 ? "" : "s"}</span><button className="icon-btn xs" title="Email everyone" onClick={() => openCompose({ to: participants.map(([, p]) => ({ name: p.name ?? null, email: p.email ?? Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, "") ?? "" })).filter((a) => a.email), subject: ev.title ?? "" })}><Mail size={13} /></button></div>
<div className="row gap-8"><Users size={15} /><span>{participants.length} participant{participants.length === 1 ? "" : "s"}</span><button className="icon-btn xs" title="Email everyone" onClick={() => openCompose({ to: participants.map(([, p]) => ({ name: p.name ?? null, email: participantEmail(p) })).filter((a) => a.email), subject: ev.title ?? "" })}><Mail size={13} /></button></div>
<div style={{ paddingLeft: 24, maxHeight: 140, overflow: "auto", width: "100%" }}>
{participants.map(([k, p]) => (
<div key={k} className="participant-row">
<span className={`p-status ${p.participationStatus ?? "needs-action"}`} title={p.participationStatus ?? "needs-action"} />
<span className="truncate">{p.name || p.email || Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, "")}</span>
<span className="truncate">{p.name || participantEmail(p)}</span>
{p.roles?.owner && <span className="hint">organizer</span>}
{p.roles?.optional && <span className="hint">optional</span>}
</div>
+4 -4
View File
@@ -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
<h4>{ev.title || "(untitled event)"}</h4>
{inst && <div className="small">{formatTimeRange(inst.start, inst.end, inst.allDay)}{ev.timeZone ? ` (${ev.timeZone})` : ""}</div>}
{location && <div className="small muted row gap-4"><MapPin size={13} /> {location}</div>}
{organizer && <div className="small muted">Organizer: {organizer.name || organizer.email || Object.values(organizer.sendTo ?? {})[0]?.replace("mailto:", "")}</div>}
{organizer && <div className="small muted">Organizer: {organizer.name || participantEmail(organizer)}</div>}
{attendees.length > 0 && <div className="small muted">{attendees.length} attendee{attendees.length === 1 ? "" : "s"}</div>}
{method === "REPLY" && (
<div className="small" style={{ marginTop: 4 }}>
{attendees.map((a) => <div key={a.email ?? a.name}>{a.name || a.email}: <b>{a.participationStatus ?? "unknown"}</b></div>)}
{attendees.map((a) => <div key={participantEmail(a) || a.name}>{a.name || participantEmail(a)}: <b>{a.participationStatus ?? "unknown"}</b></div>)}
</div>
)}
</div>