Calendar events created via JMAP are served over CalDAV as invalid iCalendar (no PRODID, no VTIMEZONE, DISPLAY alarm without DESCRIPTION)

Issue Description

I found this with the Bulwark webmail: events created in Bulwark have no PRODID, no VTIMEZONE and an alarm without DESCRIPTION when read via CalDAV. The problem is not specific to Bulwark. It reproduces with a plain CalendarEvent/set request (see Reproduction Steps).

When a calendar event is created or updated via JMAP (CalendarEvent/set), the iCalendar representation that CalDAV returns (GET and calendar-query REPORT) is not a valid RFC 5545 object:

  1. PRODID is missing. RFC 5545 §3.6 requires it in every VCALENDAR. The JSCalendar→iCalendar export adds a fallback VERSION:2.0 but no fallback PRODID.
  2. No VTIMEZONE for the referenced TZID. DTSTART;TZID=Europe/Rome:... is emitted without a matching VTIMEZONE. RFC 5545 §3.2.19 says a VTIMEZONE must be present for each unique TZID parameter value. (JMAP invitation missing VTIMEZONE causes offset in Outlook fixed the same problem for iTIP messages generated from JMAP events. The CalDAV representation is still affected.)
  3. ACTION:DISPLAY VALARM without DESCRIPTION. RFC 5545 §3.6.6 requires DESCRIPTION for display alarms. A JSCalendar alert has no description, and none is added during conversion.

Strict clients reject these objects. For example, go-ical (used by several Go CalDAV clients) refuses to re-encode the fetched object (want exactly one PRODID property, got 0), so a client that fetches an event and PUTs it back fails. Events created via CalDAV are not affected: they keep the PRODID and VTIMEZONE the client sent.

Expected Behavior

The CalDAV representation of a JMAP-created event is valid RFC 5545:

  • it contains a PRODID (e.g. Stalwart’s own, the same way VERSION is already added),
  • it contains a VTIMEZONE for every TZID it references,
  • each ACTION:DISPLAY VALARM contains a DESCRIPTION (e.g. the event title, or a generic “Reminder”).

Actual Behavior

The event from the reproduction below, as returned by GET / calendar-query REPORT:

BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
DTSTART;TZID=Europe/Rome:20261001T120000
SUMMARY:Test
DURATION:PT1H
DTSTAMP:20260928T203943Z
UID:52b36a9f-3529-4461-a2a6-9b73be118fbf
BEGIN:VALARM
TRIGGER:-PT1H
ACTION:DISPLAY
JSID:a1
END:VALARM
END:VEVENT
END:VCALENDAR

The result is the same after a subsequent JMAP update of the event.

An event created in Bulwark (with a 1-hour alert) comes back from CalDAV with the same problems:

BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
DTSTART;TZID=Europe/Rome:20260930T220000
UID:4fcf575e-fbc1-48bb-9c5c-a5092edd9661
SUMMARY:Test
DESCRIPTION:
DURATION:PT1H
STATUS:CONFIRMED
TRANSP:OPAQUE
SHOW-WITHOUT-TIME:FALSE
CLASS:PUBLIC
DTSTAMP:20260928T193239Z
BEGIN:VALARM
TRIGGER;RELATED=START:-PT1H
ACTION:DISPLAY
JSPROP;JSPTR=acknowledged:null
JSPROP;JSPTR=relatedTo:null
JSID:alert1
END:VALARM
END:VEVENT
END:VCALENDAR

Reproduction Steps

  1. Create an event via JMAP:
{
  "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"],
  "methodCalls": [["CalendarEvent/set", {
    "accountId": "<account>",
    "create": {"a": {
      "@type": "Event",
      "calendarIds": {"<calendarId>": true},
      "title": "Stalwart repro (JMAP)",
      "start": "2026-10-01T12:00:00",
      "timeZone": "Europe/Rome",
      "duration": "PT1H",
      "alerts": {"a1": {
        "@type": "Alert",
        "trigger": {"@type": "OffsetTrigger", "offset": "-PT1H"},
        "action": "display"
      }}
    }}
  }, "0"]]
}
  1. Fetch it via CalDAV, e.g. with a calendar-query REPORT filtered by UID, or a GET on its href (in my run: /dav/cal/<account>/default/<uid>).
  2. The returned object has no PRODID, no VTIMEZONE, and a DISPLAY VALARM without DESCRIPTION (see above).

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

PostgreSQL

Blob Storage

PostgreSQL

Search Engine

PostgreSQL

Directory Backend

Internal

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

This isn’t covered in the current docs. A community member or staff member will follow up.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

This has been fixed in the v1.0.0 branch but unfortunately the fix cannot be backported to v0.16. v1.0.0 will be released at the end of this month or early November.