Export a calendar as an iCAL file #227

Closed
opened 2026-09-02 16:21:32 +00:00 by jcoffey-dev · 0 comments
Owner

Closes #216. The mirror of the import from #173, and the last thing contacts had that calendars did not — an address book could always be exported, a calendar never could.

Written here, not asked for

The import hands parsing to the server because Stalwart has a CalendarEvent/parse and reimplementing an .ics reader in a browser would be foolish. There is no method the other way — not in Stalwart, not in the JMAP calendar drafts — so the file is built from the RFC 8984 objects the server already returns. Most of that is renaming: 8984 was written as a restatement of 5545, and the comments say which way it went wherever the two disagree.

Masters, not occurrences. The query runs without expandRecurrences, so a weekly meeting leaves as one VEVENT carrying its RRULE rather than as a year of identical ones. An export that flattened the rule would import somewhere else as a pile nobody can maintain. A changed occurrence goes out as its own VEVENT with the same UID and a RECURRENCE-ID; a cancelled one becomes an EXDATE.

Three decisions worth stating rather than leaving to be found

No VTIMEZONE components. A TZID names the IANA zone the server holds, with nothing defining it beside it. Defining it means shipping a zone database to describe rules the reader's own system already knows. Every client that matters resolves IANA names; a strict validator will complain. The alternative — converting everything to UTC — would be worse than a complaint: a weekly 09:00 that becomes 08:00 for half the year is a wrong calendar, not a pedantic one.

UNTIL follows DTSTART's kind — a date for an all-day series, a UTC instant otherwise. Sending a local time there is the usual way to make a series stop a day early in another timezone.

Overrides are applied at the top level only. A recurrence override is a JSON patch, and one addressing locations/x/name is not something this flattens. Plain overridden properties — a moved time, a changed title — come across.

Where it lives

"Export iCAL file" in the calendar's own menu, beside the import. No rights check: exporting is reading, and a calendar you cannot read is not in the list to begin with.

Tests

27 on the writer — zones (named, UTC, floating, all-day), UNTIL in both kinds, BYDAY with an ordinal, EXDATE vs a second VEVENT for overrides, escaping, folding at 75 octets, room-vs-video-link, the status/class/transp vocabulary, attendees with PARTSTAT and RSVP, alarms including RELATED=END. Two feed the output back through this file's own parser.

5 on the store side — that it collects this calendar's events and not the account's, doesn't expand recurrences, pages a 1200-event calendar (three queries, not four: the reported total makes the last page known), and refuses an empty calendar rather than handing over a file with nothing in it.

npm run typecheck, npm test (940 web + 122 server), npm run build, i18n:check (no new stale keys) pass.

Not verified by me

The download itself. exportIcs is covered end to end by the store tests, but I did not click the menu item in a browser, because that writes a file to disk. Three new strings are translated in all nine catalogues — mine, not native-reviewed, like the rest.

Merged 2026-09-02 as coffey-labs/ihasmail@85289b0c34

Rebuilt from: git history, session transcript.

Closes #216. The mirror of the import from #173, and the last thing contacts had that calendars did not — an address book could always be exported, a calendar never could. ## Written here, not asked for The import hands parsing to the server because Stalwart has a `CalendarEvent/parse` and reimplementing an `.ics` reader in a browser would be foolish. There is no method the other way — not in Stalwart, not in the JMAP calendar drafts — so the file is built from the RFC 8984 objects the server already returns. Most of that is renaming: 8984 was written as a restatement of 5545, and the comments say which way it went wherever the two disagree. **Masters, not occurrences.** The query runs without `expandRecurrences`, so a weekly meeting leaves as one VEVENT carrying its RRULE rather than as a year of identical ones. An export that flattened the rule would import somewhere else as a pile nobody can maintain. A changed occurrence goes out as its own VEVENT with the same UID and a `RECURRENCE-ID`; a cancelled one becomes an `EXDATE`. ## Three decisions worth stating rather than leaving to be found **No VTIMEZONE components.** A `TZID` names the IANA zone the server holds, with nothing defining it beside it. Defining it means shipping a zone database to describe rules the reader's own system already knows. Every client that matters resolves IANA names; a strict validator will complain. The alternative — converting everything to UTC — would be worse than a complaint: a weekly 09:00 that becomes 08:00 for half the year is a *wrong* calendar, not a pedantic one. **`UNTIL` follows `DTSTART`'s kind** — a date for an all-day series, a UTC instant otherwise. Sending a local time there is the usual way to make a series stop a day early in another timezone. **Overrides are applied at the top level only.** A recurrence override is a JSON patch, and one addressing `locations/x/name` is not something this flattens. Plain overridden properties — a moved time, a changed title — come across. ## Where it lives "Export iCAL file" in the calendar's own menu, beside the import. No rights check: exporting is reading, and a calendar you cannot read is not in the list to begin with. ## Tests **27 on the writer** — zones (named, UTC, floating, all-day), `UNTIL` in both kinds, `BYDAY` with an ordinal, EXDATE vs a second VEVENT for overrides, escaping, folding at 75 octets, room-vs-video-link, the status/class/transp vocabulary, attendees with PARTSTAT and RSVP, alarms including `RELATED=END`. Two feed the output back through this file's own parser. **5 on the store side** — that it collects this calendar's events and not the account's, doesn't expand recurrences, pages a 1200-event calendar (three queries, not four: the reported total makes the last page known), and refuses an empty calendar rather than handing over a file with nothing in it. `npm run typecheck`, `npm test` (940 web + 122 server), `npm run build`, `i18n:check` (no new stale keys) pass. ## Not verified by me The download itself. `exportIcs` is covered end to end by the store tests, but I did not click the menu item in a browser, because that writes a file to disk. Three new strings are translated in all nine catalogues — mine, not native-reviewed, like the rest. **Merged** 2026-09-02 as coffey-labs/ihasmail@85289b0c3490 <sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.