IMAP MOVE fails with `NO [CONTACTADMIN]` when a concurrent STORE changes the same message (`store.assert-value-failed` not handled in copy_move.rs)

Issue Description

An IMAP UID MOVE fails with a tagged NO [CONTACTADMIN] internal error if another session changes the same message’s flags at the same moment.

The client is the Nextcloud Mail app, which opens a new IMAP connection for each HTTP request. When you delete a message you have just opened, the app sends two requests together:

  • a mark-as-read (UID STORE +FLAGS (\Seen))
  • a delete (UID MOVE to the trash folder)

Both arrived in the same second on separate connections. The STORE committed first. The MOVE batch still held the message’s older data, so its assertion failed at commit_batch (copy_move.rs:378–380 in v0.16.23). The error was returned as an internal server error.

The STORE path already handles this case in crates/imap/src/op/store.rs (around line 434):

Err(err) if err.is_assertion_failure() => {
    items.items.clear();
    response.rtype = ResponseType::No;
    response.message = "Some messages were modified by another process.".into();
}

The same-account branch of copy_move.rs has no matching handler, so the assertion failure falls through as CONTACTADMIN.

Expected Behavior

A MOVE that loses this race should re-read the message’s current data and retry the commit. Nothing about the flag change makes the move invalid. At minimum, it should return the same clean NO that STORE returns, not an internal error that tells the user to contact the administrator.

Actual Behavior

The MOVE returns NO [CONTACTADMIN]. The message stays in INBOX, and the client shows the user an HTTP 500.

Reproduction Steps

  1. Open two IMAP sessions for the same account and SELECT INBOX in both.
  2. In session A, send UID STORE <uid> +FLAGS (\Seen). In session B, at the same time, send UID MOVE <uid> Trash.
  3. Repeat until the two commands overlap. Session B then gets NO [CONTACTADMIN] and the server logs store.assert-value-failed from copy_move.rs.

In real use, this happens when you open a message in Nextcloud Mail and then delete it, which the app turns into a STORE and a MOVE on the same message. I hit it right away while deleting a few emails interactively.

Relevant Log Output

2026-09-26T09:24:23Z ERROR Another process modified the record (store.assert-value-failed) listenerId = "imaps", localPort = 993, remoteIp = 192.168.32.1, remotePort = 44556, causedBy = "crates/store/src/backend/postgres/write.rs:383", id = "5", details = "Internal Server Error", code = "CONTACTADMIN", causedBy = "crates/imap/src/op/copy_move.rs:380"

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

PostgreSQL

Blob Storage

S3-compatible

Search Engine

PostgreSQL

Directory Backend

LDAP

Additional Context

  • Stalwart v0.16.23 (stalwartlabs/stalwart:v0.16.23 Docker image). Blob storage is RustFS (S3-compatible). The directory is an authentik LDAP outpost.
  • Client: Nextcloud Mail app on Nextcloud 34.0.3. Its error is Horde_Imap_Client_Exception_ServerResponse with response code CONTACTADMIN, raised during copy(..., move: true).
  • The failing path also exists on main: the same-account branch of copy_move.rs has no is_assertion_failure() handler around commit_batch.
  • I’m an Open Collective supporter of Stalwart. Thanks for the project.

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 looks like a valid bug and your analysis matches what’s in the current source. In crates/imap/src/op/copy_move.rs, the same-account MOVE branch commits its batch with a plain .imap_ctx(...) map:

self.server
    .commit_batch(batch)
    .await
    .imap_ctx(&arguments.tag, trc::location!())?;

There is no Err(err) if err.is_assertion_failure() => ... arm here, unlike the STORE handler in crates/imap/src/op/store.rs (~line 434), which catches the assertion failure and returns a clean NO instead of propagating it as an internal error. So a lost-update race on MOVE does surface as NO [CONTACTADMIN] exactly as you described, rather than being retried or downgraded to a normal NO.

This is a code-level bug rather than a documentation or configuration question, so I can’t offer a workaround from the docs, a human maintainer will need to review and patch the missing handler (and decide whether to retry-with-refresh or just return the same clean NO as STORE).

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).