JMAP Email/copy with onSuccessDestroyOriginal destroys the original when the copy failed (overQuota, alreadyExists)

Issue Description

Email/copy with onSuccessDestroyOriginal: true is the usual way to move mail into another account. When the copy of a record fails with overQuota or alreadyExists, the original is destroyed anyway. The message is then in neither account. Email/copy reports the failure in notCreated, and the implicit Email/set in the same response lists the original under destroyed. The blob is gone too, and a JMAP destroy does not go through Trash, so there is nothing to restore.

With several records in one call, the originals of the copied and of the failed records are all destroyed. A bulk move into a nearly full shared or group account loses every message that did not fit.

The alreadyExists case loses data when the destination already holds a different message with the same Message-ID and subject, for example a list copy and a direct copy of the same post. The source version is destroyed and exists nowhere afterwards.

Failures caught before the copy keep the original: unknown mailboxId (invalidProperties), no mayAddItems on the destination (forbidden), unknown source id (notFound).

This looks like a side effect of the fix for JMAP Email/copy `onSuccessDestroyOriginal` destroys the wrong id (the copy's create-key, not the source Email id), leaving the original behind in v0.16.15. Before that fix the destroy list held the creation key, so nothing was removed. Since then it holds the source id, but the id is still added for failed copies.

Expected Behavior

A record that ends up in notCreated keeps its original.

Actual Behavior

One message, destination account over its disk quota:

["Email/copy", {"fromAccountId": "l", "accountId": "h",
  "create": {"c": {"id": "bl2aaaak3", "mailboxIds": {"a": true}}},
  "onSuccessDestroyOriginal": true}, "0"]

<- ["Email/copy", {"fromAccountId": "l", "accountId": "h", "oldState": "n", "newState": "n",
     "notCreated": {"c": {"type": "overQuota", "description": "Account quota exceeded."}}}, "0"],
   ["Email/set", {"accountId": "l", "oldState": "sxycq", "newState": "sx2cq",
     "destroyed": ["bl2aaaak3"]}, "0"]

["Email/get", {"accountId": "l", "ids": ["bl2aaaak3"]}, "0"]
<- "list": [], "notFound": ["bl2aaaak3"]

The other cases, each with onSuccessDestroyOriginal: true:

case                                              Email/copy          original
one message, destination over disk quota          overQuota           destroyed, blob 404
two messages, room for the small one only         created + overQuota both destroyed
identical message already in destination          alreadyExists       destroyed
different body, same Message-ID and subject       alreadyExists       destroyed, text exists nowhere
unknown mailboxId                                 invalidProperties   kept
destination mailbox without mayAddItems           forbidden           kept

Reproduction Steps

  1. User A can add mail to a mailbox in account B: a group account, or a mailbox shared with mayReadItems and mayAddItems.
  2. As admin, set a small disk quota on account B (10 is enough).
  3. As user A, import a message into A’s own Inbox.
  4. As user A, copy it into B’s mailbox with onSuccessDestroyOriginal: true.
  5. The copy is notCreated with overQuota, and the original is destroyed.
2. ["x:Account/set", {"accountId": "<admin account>", "update": {"<B>": {"quotas/maxDiskQuota": 10}}}, "0"]

4. ["Email/copy", {"fromAccountId": "<A>", "accountId": "<B>",
     "create": {"c": {"id": "<email id>", "mailboxIds": {"<B mailbox>": true}}},
     "onSuccessDestroyOriginal": true}, "0"]

Stalwart Version

v0.16.x

Installation Method

Built from source

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

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

I can confirm this from the source. In crates/jmap/src/email/copy.rs, the “add to destroy list” step runs unconditionally after the per-item copy attempt, regardless of whether copy_message returned Ok or Err:

match self.copy_message(...).await? {
    Ok(email) => { ... response.created.append(...) ... }
    Err(err) => {
        response.not_created.append(id, match err {
            CopyMessageError::NotFound => ...,
            CopyMessageError::OverQuota => SetError::over_quota(),
            CopyMessageError::AlreadyExists(existing) => SetError::already_exists()...,
        });
    }
}

// Add to destroy list
if on_success_delete {
    destroy_ids.push(MaybeInvalid::Value(from_message_id));
}

The destroy_ids.push(...) sits outside the match, so any source id that reaches this point is queued for destruction whether the copy succeeded or failed with overQuota/alreadyExists/notFound. This matches your observed behavior exactly: validation failures caught earlier (invalidProperties, forbidden, missing id) use continue 'create before reaching this code, so those correctly skip destruction, while failures from copy_message itself (NotFound, OverQuota, AlreadyExists) fall through to the unconditional push. This is a genuine bug, the destroy should only be queued inside the Ok(email) arm. A staff member will follow up on a fix.

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

This is already fixed on the v1.0 branch: the original only goes on the destroy list when its copy was created. In 0.16 that push happens after the result is checked, so overQuota and alreadyExists failures get destroyed as well. v1.0 is planned for the end of this month or early November. Until then I wouldn’t use onSuccessDestroyOriginal when copying into an account that might be close to its quota.

Would it be possible to get a exact release date when you know one? Privately or not

We don’t know yet. All new features are implemented. We are now stress testing Stalwart in different scenarios, fixing any bugs caused by load and retesting again. It takes time as we are using massive clusters with 128 nodes for the tests and creating very large accounts with over 5 million emails each.

Sounds good, any reason why you removed the 1.0 branch?