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
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.
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>
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:
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.
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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
EventEditorI first suspected:ParticipantIdentity/getreturns 7 identities on the live server, somyAddressis populated. The actual cause is vocabulary. ihasmail addresses a participant the way RFC 8984 does; Stalwart 0.16 uses a later revision of it:participants[k].sendTo.imip/.emailparticipants[k].calendarAddressreplyTo: { imip }organizerCalendarAddressrecurrenceRules: [rule]recurrenceRule: ruleinvalidProperties(#30)Six participant shapes were tried against the live 0.16.19, down to
sendTo+rolesalone — 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
store/calendar.ts—participantAddresses,participantEmail,isAttendee,eventRule,makeParticipant— rather than being spread across the views again.isAttendeealso acceptsrequired, which is what Stalwart writes for an iCalendarREQ-PARTICIPANT. Without it the invite card counted a real attendee as none.EVENT_PROPSasks forrecurrenceRuleandorganizerCalendarAddress; it is an explicit list, so unrequested properties never arrive.recurrenceIdbut no rule of its own, andbaseEventIdis 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 withnullworks, and participants survive an unrelated update. Every probe ran in a scratch calendar of its own withsendSchedulingMessages: falseand a guest atexample.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: trueand 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.
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: trueand 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:So the whole path works once the participants are addressed the way Stalwart wants them: store, send, reply, apply. Destroying the event with
sendSchedulingMessages: truenotified 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.
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.
rsvp()doesparticipants/me/participationStatuson the base eventparticipationCommenttogetherUpdating synthetic ids is not yet supportedThat last row is the interesting one: it is exactly why
rsvp()resolvesbaseEventIdbefore 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
participantsupdate, and clearing them again withnullworks. 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.