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.
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.
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.
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/parseand reimplementing an.icsreader 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 aRECURRENCE-ID; a cancelled one becomes anEXDATE.Three decisions worth stating rather than leaving to be found
No VTIMEZONE components. A
TZIDnames 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.UNTILfollowsDTSTART'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/nameis 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),
UNTILin both kinds,BYDAYwith 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 includingRELATED=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.
exportIcsis 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.