Say it in the words Stalwart 0.16 answers to #31

Closed
opened 2026-08-25 15:26:53 +00:00 by jcoffey-dev · 2 comments
Owner

Fixes #26 and #30.

Stacked on #28 — based on calendar-recurring-warning, because it touches the same files. Merge that one first and this retargets to main on its own.

What was wrong

Guests added to an event vanished on save, and no invitation was ever sent — with no error anywhere, and nothing in Stalwart's debug log naming the addresses.

It is not the guard in EventEditor I first suspected: ParticipantIdentity/get returns 7 identities on the live server, so myAddress is populated. The actual cause is vocabulary. ihasmail addresses a participant the way RFC 8984 does; Stalwart 0.16 uses a later revision of it:

ihasmail sent Stalwart 0.16.19 wants what the server did
participants[k].sendTo.imip / .email participants[k].calendarAddress kept the event, dropped the whole map, reported success
replyTo: { imip } organizerCalendarAddress ignored
recurrenceRules: [rule] recurrenceRule: rule rejected, invalidProperties (#30)

Six participant shapes were tried against the live 0.16.19, down to sendTo + roles alone — all six dropped. Patching a participant onto an existing event fails outright (Patch operation failed). The rule array was refused in all three forms tried.

What changed

  • Writes use Stalwart's names; reads accept either, since a mailbox can hold events written by other clients. The vocabulary lives in four helpers in store/calendar.tsparticipantAddresses, participantEmail, isAttendee, eventRule, makeParticipant — rather than being spread across the views again.
  • isAttendee also accepts required, which is what Stalwart writes for an iCalendar REQ-PARTICIPANT. Without it the invite card counted a real attendee as none.
  • EVENT_PROPS asks for recurrenceRule and organizerCalendarAddress; it is an explicit list, so unrequested properties never arrive.
  • The mock now refuses what the real server refuses and drops what it drops. It advertised the RFC spelling, which is exactly how this reached a live server unnoticed — the same shape of miss as the capability-placement bug already recorded in the README.
  • README gains a note under known issues, including two things worth knowing before touching this code: an expanded occurrence carries a recurrenceId but no rule of its own, and baseEventId is set on everything an expanded query returns, one-off events included.

Verified

Against the live 0.16.19, using exactly the object the editor now builds: participants (2), organizer and rule all survive a create, an update, and a re-read, with roles stored as sent ({owner, attendee} and {required, attendee}). Clearing the rule with null works, and participants survive an unrelated update. Every probe ran in a scratch calendar of its own with sendSchedulingMessages: false and a guest at example.com, and destroyed everything afterwards.

npm run typecheck, npm test — 191 web + 88 server, all passing, with 7 new cases over the helpers.

Still unproven: that Stalwart actually sends the iTIP invitation once the participants are stored. That needs sendSchedulingMessages: true and a real mailbox to receive it, so it is a deliberate next step rather than something to slip into a probe.

🤖 Generated with Claude Code

Merged 2026-08-25 as coffey-labs/ihasmail@c04bc7a31b

Rebuilt from: git history, session transcript.

Fixes #26 and #30. **Stacked on #28** — based on `calendar-recurring-warning`, because it touches the same files. Merge that one first and this retargets to main on its own. ## What was wrong Guests added to an event vanished on save, and no invitation was ever sent — with no error anywhere, and nothing in Stalwart's debug log naming the addresses. It is not the guard in `EventEditor` I first suspected: `ParticipantIdentity/get` returns 7 identities on the live server, so `myAddress` is populated. The actual cause is vocabulary. ihasmail addresses a participant the way RFC 8984 does; Stalwart 0.16 uses a later revision of it: | ihasmail sent | Stalwart 0.16.19 wants | what the server did | | --- | --- | --- | | `participants[k].sendTo.imip` / `.email` | `participants[k].calendarAddress` | kept the event, **dropped the whole map, reported success** | | `replyTo: { imip }` | `organizerCalendarAddress` | ignored | | `recurrenceRules: [rule]` | `recurrenceRule: rule` | **rejected**, `invalidProperties` (#30) | Six participant shapes were tried against the live 0.16.19, down to `sendTo` + `roles` alone — all six dropped. Patching a participant onto an existing event fails outright (`Patch operation failed`). The rule array was refused in all three forms tried. ## What changed - Writes use Stalwart's names; reads accept either, since a mailbox can hold events written by other clients. The vocabulary lives in four helpers in `store/calendar.ts` — `participantAddresses`, `participantEmail`, `isAttendee`, `eventRule`, `makeParticipant` — rather than being spread across the views again. - `isAttendee` also accepts `required`, which is what Stalwart writes for an iCalendar `REQ-PARTICIPANT`. Without it the invite card counted a real attendee as none. - `EVENT_PROPS` asks for `recurrenceRule` and `organizerCalendarAddress`; it is an explicit list, so unrequested properties never arrive. - **The mock now refuses what the real server refuses** and drops what it drops. It advertised the RFC spelling, which is exactly how this reached a live server unnoticed — the same shape of miss as the capability-placement bug already recorded in the README. - README gains a note under known issues, including two things worth knowing before touching this code: an expanded occurrence carries a `recurrenceId` but no rule of its own, and `baseEventId` is set on everything an expanded query returns, one-off events included. ## Verified Against the live 0.16.19, using exactly the object the editor now builds: participants (2), organizer and rule all survive a create, an update, and a re-read, with roles stored as sent (`{owner, attendee}` and `{required, attendee}`). Clearing the rule with `null` works, and participants survive an unrelated update. Every probe ran in a scratch calendar of its own with `sendSchedulingMessages: false` and a guest at `example.com`, and destroyed everything afterwards. `npm run typecheck`, `npm test` — 191 web + 88 server, all passing, with 7 new cases over the helpers. **Still unproven:** that Stalwart actually *sends* the iTIP invitation once the participants are stored. That needs `sendSchedulingMessages: true` and a real mailbox to receive it, so it is a deliberate next step rather than something to slip into a probe. 🤖 Generated with [Claude Code](https://claude.com/claude-code) **Merged** 2026-08-25 as coffey-labs/ihasmail@c04bc7a31b31 <sub>Rebuilt from: git history, session transcript.</sub>
Author
Owner

Verified end to end against the live 0.16.19, which closes the one gap this PR was still hedging about.

An event was created in a scratch calendar with sendSchedulingMessages: true and an external Gmail guest. The invitation arrived, rendered as an invite card with RSVP buttons. The guest declined, and the reply came back and was applied to the event:

me → mailto:[email protected] | accepted
p1 → mailto:[email protected] | declined
requestStatus: none · sequence: 1

So the whole path works once the participants are addressed the way Stalwart wants them: store, send, reply, apply. Destroying the event with sendSchedulingMessages: true notified the guest as well, and the scratch calendar is gone — the account is back to its single calendar.

README updated in 4fe9835 to record the verification.

Verified end to end against the live 0.16.19, which closes the one gap this PR was still hedging about. An event was created in a scratch calendar with `sendSchedulingMessages: true` and an external Gmail guest. The invitation **arrived**, rendered as an invite card with RSVP buttons. The guest declined, and the reply came back and was applied to the event: ``` me → mailto:[email protected] | accepted p1 → mailto:[email protected] | declined requestStatus: none · sequence: 1 ``` So the whole path works once the participants are addressed the way Stalwart wants them: store, send, reply, apply. Destroying the event with `sendSchedulingMessages: true` notified the guest as well, and the scratch calendar is gone — the account is back to its single calendar. README updated in 4fe9835 to record the verification.
Author
Owner

Checked the RSVP patch path against the live 0.16.19 as well, since adding a participant by patch had failed earlier and left a question over it. No code change needed — it works.

what rsvp() does result
participants/me/participationStatus on the base event ok
status + participationComment together ok
another participant's status ok
the same patch aimed at an expanded instance id refusedUpdating synthetic ids is not yet supported

That last row is the interesting one: it is exactly why rsvp() resolves baseEventId before patching, and the instance does report a base to resolve to (eaaaaavv). Written into the README so the line does not get simplified away later.

Also verified the edit path, which the reporter's flow reaches from the other direction: adding guests to an event created without any works via a whole-map participants update, and clearing them again with null works. Only adding a new participant by nested patch is refused, and nothing in ihasmail does that.

All probes ran in a scratch calendar, torn down afterwards; the account is back to its single calendar.

Checked the RSVP patch path against the live 0.16.19 as well, since adding a participant by patch had failed earlier and left a question over it. **No code change needed — it works.** | what `rsvp()` does | result | | --- | --- | | `participants/me/participationStatus` on the base event | ok | | status + `participationComment` together | ok | | another participant's status | ok | | the same patch aimed at an expanded instance id | **refused** — `Updating synthetic ids is not yet supported` | That last row is the interesting one: it is exactly why `rsvp()` resolves `baseEventId` before patching, and the instance does report a base to resolve to (`eaaaaav` → `v`). Written into the README so the line does not get simplified away later. Also verified the edit path, which the reporter's flow reaches from the other direction: adding guests to an event created without any works via a whole-map `participants` update, and clearing them again with `null` works. Only adding a *new* participant by nested patch is refused, and nothing in ihasmail does that. All probes ran in a scratch calendar, torn down afterwards; the account is back to its single calendar.
This repo is archived. You cannot comment on issues.