JMAP Email/copy `onSuccessDestroyOriginal` destroys the wrong id (the copy's create-key, not the source Email id), leaving the original behind

Issue Description

A cross-account Email/copy with onSuccessDestroyOriginal: true never removes the original. The copy is created, and the follow-up implicit Email/set is correctly issued on the fromAccountId, but its destroy list contains the create-id from the copy request instead of the source Email id. The destroy therefore fails with notFound and the source message is left in place -
a silent duplicate on every move. The create-id is additionally lower-cased before the lookup (ids are treated as base32), which makes the mismatch visible.

Expected Behavior

Per RFC 8621 §5.4, when onSuccessDestroyOriginal is true the server makes a single implicit Email/set on fromAccountId whose destroy argument is the list of source Email ids that were successfully copied. Those originals are destroyed, so a cross-account move leaves no duplicate.

Actual Behavior

The implicit Email/set.destroy receives the create object keys from the Email/copy request (lower-cased), not the source ids. The destroy fails with notFound and the original is never removed.

Reproduction Steps

  1. Create a source account (alice) and a destination account (carol); grant carol mayRemoveItems on a shared source folder owned by alice.

  2. Put a message in the source folder; note its id (e.g. daaaaaay).

  3. As carol, copy it across accounts with a distinctive create-key and
    onSuccessDestroyOriginal:

    ["Email/copy", {
      "fromAccountId": "c",              // alice
      "accountId": "e",                  // carol
      "create": {
        "zzTag99": { "id": "daaaaaay", "mailboxIds": { "<carol-inbox>": true }, "keywords": { "$seen": true } }
      },
      "onSuccessDestroyOriginal": true
    }, "0"]
    
  4. Observe the response — the copy succeeds, but the implicit destroy targets the create-key rather than the source id:

    ["Email/copy", { "created": { "zzTag99": { "id": "iaaaaac", ... } }, ... }, "0"],
    ["Email/set",  {
       "accountId": "c",
       "notDestroyed": { "zztag99": { "type": "notFound" } }   // <-- lower-cased create-key, not "daaaaaay"
    }, "0"]
    
  5. Email/get on fromAccountId for daaaaaay afterwards still returns the message — the original was never destroyed.

Reproduced deterministically (every run) on 0.16.12 and 0.16.14, both directions (shared folder → own account, and between two owners’ shared folders).

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

PostgreSQL

Blob Storage

PostgreSQL

Search Engine

PostgreSQL

Directory Backend

SQL

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

Confirmed, this is a genuine bug in the Email/copy handler. Looking at the source in crates/jmap/src/email/copy.rs, after a successful copy the code builds the destroy list like this:

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

Here id is the loop variable bound to the create-key from the create map (e.g. zzTag99), not from_message_id (the parsed source Email id from the id property inside the create object, e.g. daaaaaay). That destroy_ids vector is then used verbatim as the destroy argument of the implicit Email/set issued against fromAccountId:

destroy: MaybeResultReference::Value(destroy_ids).into(),

So the implicit set tries to destroy an id that never existed in fromAccountId (the create-key, lower-cased during id parsing), always returning notFound, and the original message is never removed. Your analysis and reproduction match the code exactly, this is not RFC-compliant behavior per RFC 8621 §5.4 as you describe. This has been captured accurately; a human/staff member will follow up on the fix and release timing.

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