CalendarEvent/set silently drops the invitation when the account lacks calendar-scheduling-send

Issue Description

On 0.16.20, CalendarEvent/set with sendSchedulingMessages: true returns a normal success response when the authenticated credential does not hold calendar-scheduling-send. The event is created, the participants are stored, a UID is assigned, and no iMIP message is sent. Nothing in the response, the stored event, or the log indicates the scheduling request was dropped. Deleting the event under the same credential likewise sends no cancellation.

This is not a configuration problem, and I checked before posting. Enable Scheduling is on and the same server sends invitations correctly for the same account over an unrestricted credential, seconds apart. The only difference is the permission on the app password.

The permission itself does exactly what it should. My problem is that the caller cannot tell it fired.

Environment: 0.16.20, self-hosted Docker, PostgreSQL, single node. The credential is an app password on a normal user account created through the admin UI with “Disable some permissions”, disabling “Calendar: Send calendar invitations and updates via email”, “Email: Send emails” and “JMAP: Create new email submissions for sending”.

Why it matters to me: the client on that credential is automated. Told to invite someone, it creates the event, lists the attendee, and reports success. My calendar then shows an attendee who was never notified and nothing anywhere says so. A refusal would be harmless. Silence turns a permission boundary into a data integrity problem.

Expected Behavior

CalendarEvent/set should tell the caller the scheduling message was not sent. Two mechanisms for that already exist in the codebase and both fire for a different failure in the same block:

  • SetError::no_supported_schedule_methods (crates/jmap-proto/src/error/set.rs:267), raised at crates/jmap/src/calendar_event/set.rs:443 and :785 when a recipient is unreachable.
  • SCHEDULE-STATUS on the ATTENDEE property, stamped by itip_set_unreachable_status (crates/groupware/src/scheduling/itip.rs:197) with iTIP status 3.7.

Either would work. iTIP status 5.3, “no scheduling support for user”, looks like the right code for a permission denial, and RFC 8984 §4.4.6 gives JSCalendar Participant.scheduleStatus as the carrier so a JMAP client can read it back.

A SetError is the clearer signal, since it fails the request that was actually made rather than annotating one that appeared to succeed. Whether that is too strict for existing clients is your call. Failing to send silently is the part I am asking you to change; which of the two you pick matters less to me.

Actual Behavior

The response is an ordinary created object with an id and a UID. CalendarEvent/get returns the event with every participant present and no scheduleStatus on any of them. No iMIP message is delivered. No error, no warning, and no log line at INFO.

The deviation from expected is specifically that there is no signal of any kind, rather than a different or unclear one. The write half succeeds and the scheduling half is skipped without comment.

Cause, from the source at crates/jmap/src/calendar_event/set.rs:779, is that the permission is one term of a five-term if with no else:

let mut itip_messages = None;
if send_scheduling_messages
&& self.core.groupware.itip_enabled
&& !account_info.addresses().is_empty()
&& access_token.has_permission(Permission::CalendarSchedulingSend)
&& event.data.event_range_end() > now() as i64
{

When the permission term is false the block is skipped, itip_messages stays None, and nothing downstream distinguishes “no messages were needed” from “messages were suppressed”. The update branch at set.rs:434 and the CalDAV paths at crates/dav/src/calendar/update.rs:230 and :402 have the same shape, so this is not specific to JMAP.

Note the last term as well: an event whose end is in the past drops the invitation the same silent way, on any credential, permission or not. I have not reproduced that one, it is read from the code, but it is the same missing else.

Reproduction Steps

  1. In the admin UI, create an app password on a normal user account. Choose “Disable some permissions” and disable “Calendar: Send calendar invitations and updates via email”.
  2. Authenticate to JMAP with that credential.
  3. Call CalendarEvent/set with sendSchedulingMessages: true and a create carrying a future event with one participant other than the account itself, each participant having a calendarAddress.
  4. Observe the response: a normal created object, no error, no partial failure.
    Call CalendarEvent/get on the new id. The participant is stored. No scheduleStatus is set.
  5. Check the participant’s mailbox. Nothing arrives.
  6. Repeat steps 2 to 6 on a credential that holds the permission. The invitation is delivered and calendar.itip-message-sent appears in the log.

Step 6 was verified as a real absence rather than a broken search: the same mailbox search returned the invitations the unrestricted credential sent minutes earlier.

Relevant Log Output

Nothing is emitted at INFO. Below is one full reproduction, timestamped, followed by every log code in that window. The create ran at 10:51:04Z and the event was destroyed at 10:51:07Z.

$ grep -E “^2026-09-02T10:5[0-3]” stalwart.log.2026-09-02 | grep -cE “(calendar.”
0

$ grep -E “^2026-09-02T10:5[0-3]” stalwart.log.2026-09-02 | grep -oE “([a-z-]+.[a-z-]+)” | sort | uniq -c
2 (auth.success)

The two auth.success lines are the credential authenticating, so the log is live and recording in that window. There is simply no calendar.* code of any kind for a create that asked to send.

For contrast, the same log file on the same day, where sends did happen on the unrestricted credential:

2026-09-02T01:00:59Z INFO Calendar iTIP message sent (calendar.itip-message-sent) from = “…” to = “…”
2026-09-02T01:01:26Z INFO Calendar iTIP message sent (calendar.itip-message-sent) from = “…” to = “…”
2026-09-02T09:43:11Z INFO Calendar iTIP message sent (calendar.itip-message-sent) from = “…” to = “…”

I have not raised the level to trace. Given the if above has no else, I do not think there is a branch that could emit anything at any level, but say the word and I will turn it on and re-run.

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

Additional Context

You acknowledged this defect class already, in topic 568 on 12 June: “when you pass sendSchedulingMessages: true but the event has no schedulable participants, the request currently succeeds silently instead of returning an error”. I could not find a changelog entry for it through 0.16.20. That case, this one, and the past-dated case above are all the same missing signal, so if you are going to add one, it may be worth covering all three at once.

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 confirms the report exactly as described: the code has the shape shown, with itip_messages staying None and no branch to signal a permission-based skip when access_token.has_permission(Permission::CalendarSchedulingSend) is false or the event’s end is in the past. This is a real gap in the current source, not a configuration issue, and matches the previously acknowledged silent-success defect class from the June 12 thread.

I don’t see anything in the current docs or changelog indicating this has been fixed for the permission-denial or past-dated-event cases in 0.16.20. Since this is a source-level bug report rather than a documentation question, I can confirm the behavior you found is present in the code as of this check, but I can’t commit to or predict a fix or timeline on behalf of the team.

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.

Fixed in v0.16.21 (yet to be released).