SnappyMail CalDAV: our standards-compliant fork, tested against Cyrus IMAP

· Convergent · 6 min

We run Cyrus IMAP mail systems behind the SnappyMail webmail. The calendar was missing from the browser. A plugin existed — written for one specific hosting provider, unusable anywhere else. We forked it, put it back on the standards, and published it.

The starting point

Mailbux’s CalDAV Auto plugin does what it says… on its author’s infrastructure. Anywhere else the symptom is disorienting: the calendar renders, but stays permanently empty, with no error whatsoever.

The cause isn’t one isolated bug, it’s a series of hardcoded assumptions:

  • the vendor’s hostname is written into the code;
  • the calendar URL is derived by rewriting /dav/card/ into /dav/cal/ — that vendor’s URL scheme, not DAV servers’ in general;
  • every error path returns silently, which makes a broken calendar indistinguishable from an empty one.

Cyrus serves /dav/calendars/user/<user>/<collection>, discoverable through the standard calendar-home-set property (RFC 4791). Nothing exotic: it is what most compliant servers do.

The URL is configuration, not code

The structural change. The URL now comes from the plugin’s own settings page, as a template with {user}, {email}, {login} and {domain} placeholders:

SettingExample
CalDAV URL templatehttps://dav.example.com/dav/calendars/user/{user}/Default/
DAV default domainexample.com — addresses in this domain use the local part only; empty means always the full address
Complete attendees from the directorysee below

The default domain matches Cyrus’s virtdomains: userid: a mailbox in the default domain is addressed by its local part, everything else by the full address.

A deliberate design point: there is no built-in default. With no template configured the plugin logs a notice and declines to act, rather than inventing a URL that cannot work. Collection names are no longer lowercased either — in a URL they are case-sensitive.

Leaving the template empty derives the calendar URL from the CardDAV plugin settings instead, for deployments where both share one DAV root.

What was broken

Static assets never loaded. The plugin loaded its bundled FullCalendar from ?/Plugins/<plugin>/<file>. That is not a route for plugin files: ServiceActions::ServicePlugins() ignores everything after /?/Plugins/ and returns the compiled plugin JS bundle. The browser therefore re-executed the plugin instead of loading the library, and window.FullCalendar was never defined. The file is now served from a dedicated part hook, with a filename whitelist.

The CDN fallback could never work. On failure, upstream fetched FullCalendar from a public CDN — which a script-src 'self' Content-Security-Policy blocks by construction. That fallback could never succeed and only masked the real error, so it is gone. In the same vein, inline onclick attributes, which violate script-src-attr under a nonce-based CSP, were replaced by the event listeners the code already attached.

Recurring events did not recur. RRULE was never expanded and folded lines were never unfolded, so a recurring series only ever appeared on its original start date. Parsing now goes through the Sabre VObject library that already ships with SnappyMail — no extra dependency — and recurrences are expanded over a window around the current date. Non-recurring events bypass expansion so historic events are not truncated.

Reminders reminded nobody. VALARM was ignored entirely, and the reminder control in the event dialog merely appended a marker string to the description — the value was never even sent to the server. Alarms are now resolved to absolute times (DATE-TIME triggers, and durations relative to START or END) and surfaced through the Notification API, with an in-page banner as fallback. Reminders set here are written as real VALARM components, so other CalDAV clients honour them too.

Above all: failures are now reported. That is what turns a half-day investigation into one log line.

Inviting people — without building iTIP

The event dialog has an Invite field taking one or more addresses, separated by commas or semicolons, in either name@example.com or Name <name@example.com> form. Saving the event adds an ORGANIZER and one ATTENDEE per address.

The invitations are neither built nor sent by the plugin. Under RFC 6638 the CalDAV server owns scheduling: it sees the attendees on the stored event, mails each of them, and marks them with SCHEDULE-STATUS. Verified against Cyrus IMAP, which returns SCHEDULE-STATUS=1.1 for a dispatched invitation.

That is an architectural choice as much as a security one: the plugin has no SMTP path at all — nothing is composed, sent or queued here. On the server side it assumes scheduling is enabled: on Cyrus, caldav_allowscheduling plus imipnotifier for delivery by mail.

Editing a saved event re-sends the guest list, so adding or removing someone invites or uninvites them. SEQUENCE is advanced whenever the time or the guest list changes, which is what tells the attendees’ clients to accept the update. Replies update the attendee list as participants answer.

The receiving side — accepting or declining an invitation you were sent — is a separate plugin: Meeting Invitations.

Attendee completion: the setting to understand before deploying

The Invite field completes as you type, from the same sources SnappyMail uses when you address a message. How far it reaches is a deployment decision, exposed as a setting:

  • On (default) — every source available, including an LDAP corporate directory if a suggestions plugin provides one. An organiser can invite any colleague by typing part of their name.
  • Off — the organiser’s own address book only.

The default assumes what a correctly built deployment provides: the configured directory belongs to one organisation. A hosting provider should give each tenant its own directory root rather than pointing everyone at a shared one.

Turn it off where that does not hold — where one directory genuinely is shared by unrelated tenants — because the suggestions chain is global: completion would then let any user enumerate the others’ addresses.

Note that SnappyMail’s LDAP suggestions plugin is configured per instance, not per domain: one base_dn serves every account on that instance. Giving tenants separate roots therefore means separate SnappyMail instances, or a suggestions plugin that scopes the search by the account’s domain.

The setting changes only what is offered: an organiser can always type an address by hand, and delivery remains the CalDAV server’s decision.

Licence and attribution

The fork keeps the original plugin’s licence (© 2025 Mailbux) and keeps the upstream README intact, below ours. A corrective fork does not rewrite the history of the work it builds on: it documents what it changes and why.

https://github.com/FathiBenNasr/SnappyMail-CalDAV-Plugin


Running Cyrus, Postfix or Sendmail and want a self-hosted webmail with calendars and contacts? See our services or let’s talk. And follow us on GitHub.