Email/set with unchanged mailboxIds + keyword change persists IMAP UID 0, hiding messages from IMAP

Issue Description

A JMAP Email/set update that contains a full-object mailboxIds value identical to the message’s current mailboxes, together with any keyword change commits the message with IMAP uid: 0 in every mailbox it belongs to. Some JMAP clients emit exactly this shape during routine sync, so mail silently vanishes from IMAP clients while JMAP clients still show it.

Expected Behavior

A message whose mailbox set is unchanged keeps its existing IMAP UIDs; a keyword-only update never alters UID assignment. STATUS MESSAGES and SELECT EXISTS agree, and no message ever has UID 0.

Actual Behavior

The update reports success, but the message’s UID becomes 0 in all its mailboxes. Affected messages disappear from IMAP (SELECT/SEARCH/FETCH); multiple affected messages in one mailbox collapse into a single UID-0 entry, so SELECT EXISTS drops below STATUS MESSAGES. JMAP access is unaffected throughout.

Reproduction Steps

  1. APPEND two messages to an empty mailbox via IMAP.
  2. For each, send via JMAP (mailboxIds identical to current, plus a keyword change):
    [“Email/set”, {“accountId”: “a”, “update”: {“Mxxx”: {
    “mailboxIds”: {“”: true},
    “keywords”: {“$seen”: true}
    }}}]
  3. Check the mailbox via IMAP: STATUS still reports 2 messages, SELECT reports EXISTS 1, UID SEARCH ALL returns only 0.

Relevant Log Output

Server logs show nothing, both updates are accepted and logged as normal successful changes. Client-side evidence:

before: STATUS (MESSAGES 2 UIDNEXT 18) | SELECT EXISTS: 2 | UID SEARCH ALL: 16 17
after: STATUS (MESSAGES 2 UIDNEXT 18) | SELECT EXISTS: 1 | UID SEARCH ALL: 0

Stalwart Version

v0.16.x

Installation Method

Binary (Linux)

Database Backend

PostgreSQL

Blob Storage

S3-compatible

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 is a bug report about actual JMAP/IMAP behavior, not something the docs would answer definitively, but let me check the source to understand and give a grounded response.

Thanks for the detailed report, this matches a real bug I can trace in the current source.

In Email/set update handling (crates/jmap/src/email/set.rs), when the client sends a full mailboxIds object, the server always rebuilds the mailbox list via UidMailbox::new_unassigned(...), which resets every entry’s UID to 0, regardless of whether the mailbox set actually changed:

(Key::Property(EmailProperty::MailboxIds), Value::Object(ids)) => {
    new_data.set_mailboxes(
        ids.into_expanded_boolean_set()
            .filter_map(|id| {
                UidMailbox::new_unassigned(
                    id.try_into_property()?.try_into_id()?.document_id(),
                )
                .into()
            })
            .collect(),
    );
}

Later, the code only re-assigns real IMAP UIDs (via assign_email_ids) inside the if has_mailbox_changes { ... } block. has_mailbox_changes is computed by diffing the mailbox set against the previous state. If the mailbox set is unchanged (same mailbox IDs, only keywords differ), has_mailbox_changes evaluates to false, so that re-assignment block is skipped entirely, and the message is committed with the freshly-reset UID of 0 still in place. This is exactly the behavior your reproduction shows: identical mailboxIds plus a keyword change silently zeroes the UID for every mailbox the message belongs to, without ever hitting the “obtain IMAP UIDs” code path that would restore it.

This is a genuine bug rather than a documentation gap, so I’m flagging it for the human maintainers to confirm and fix rather than speculating on a workaround. A staff or community member will follow up on this thread with next steps (and likely a patched build or interim mitigation).

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 v0.16.24 (yet to be released).