EmailSubmission/query undoStatus filter contradicts EmailSubmission/get for held (FUTURERELEASE) submissions

Issue Description

For an EmailSubmission created with a future HOLDUNTIL (FUTURERELEASE, RFC 4865), EmailSubmission/get correctly reports undoStatus: "pending", but EmailSubmission/query with filter: {"undoStatus": "pending"} returns an empty result — and filter: {"undoStatus": "final"} wrongly includes the held submission. RFC 8621 §7.5 defines undoStatus as a standard filter condition, so query and get should agree.

Related (likely same root cause): the EmailSubmission/set create response echoes undoStatus: "final" for a held submission, while a subsequent get reports "pending".

Impact: clients cannot use the standard query filter to list scheduled (held) emails; the only workaround is an unfiltered EmailSubmission/query followed by EmailSubmission/get and client-side filtering on undoStatus.

Expected Behavior

EmailSubmission/query with filter: {"undoStatus": "pending"} returns held/still-queued submissions, consistent with what EmailSubmission/get reports for the same objects — either by storing Pending while the queue message has a future release time (flipping to Final on delivery), or by having query consult queue state the way get does.

Actual Behavior

  • EmailSubmission/get on the held submission → undoStatus: "pending", sendAt reflects the hold. :check_mark:
  • EmailSubmission/query with filter: {"undoStatus": "pending"}ids: []. :cross_mark:
  • EmailSubmission/query with filter: {"undoStatus": "final"} → includes the held submission’s id. :cross_mark:

Reproduction Steps

  1. Create a held submission:
["EmailSubmission/set", {
  "accountId": "a",
  "create": {
    "s1": {
      "identityId": "...",
      "emailId": "...",
      "envelope": {
        "mailFrom": {"email": "[email protected]", "parameters": {"HOLDUNTIL": "<future epoch>"}},
        "rcptTo": [{"email": "[email protected]"}]
      }
    }
  }
}, "0"]
  1. Call EmailSubmission/get on the created id → undoStatus: "pending".
  2. Call EmailSubmission/query with filter: {"undoStatus": "pending"} → empty ids.
  3. Call EmailSubmission/query with filter: {"undoStatus": "final"} → the held submission’s id appears.

Relevant Log Output

// held submission "br" (sendAt in the future):
EmailSubmission/get ["br"] →
  {"id": "br", "emailId": "w2aaaafy", "undoStatus": "pending", "sendAt": "2026-08-10T02:30:00Z"}

EmailSubmission/query filter {"undoStatus": "pending"} →
  {"queryState": "syubq", "canCalculateChanges": true, "position": 0, "ids": []}

EmailSubmission/query filter {"undoStatus": "final"} →
  {"queryState": "syubq", ..., "ids": [..., "br"]}   // wrongly includes the pending submission

Stalwart Version

v0.16.x

Installation Method

Built from source

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

Additional Context

Root cause from source (v0.16.14):

  • crates/jmap/src/submission/set.rs:754 — after the message is accepted into the queue, the stored undo_status is set to UndoStatus::Final unconditionally, regardless of a future HOLDUNTIL.
  • crates/jmap/src/submission/get.rs:140-174 — get computes the status dynamically: if queue_id still resolves to a live queued message, it reports pending, overriding the stored value. This is why get is correct.
  • crates/jmap/src/submission/query.rs:141-155 — query filters on the stored index byte written at create time (crates/email/src/submission/index.rs, undo_status.as_index() = 'f'), never consulting queue state. Held submissions are therefore indexed as final.

Also reproduced on v0.16.16. The behavior is storage-backend-independent. Client: a JMAP integration implementing scheduled send on top of FUTURERELEASE.

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 reporter’s analysis is accurate based on the current source. This is a genuine bug report rather than a documentation question, so I don’t have anything to add beyond confirming it’s accurately diagnosed against the current codebase (v0.16 branch, crates/jmap/src/submission/set.rs and query.rs).

I’ll give a brief confirming reply for the human follow-up.

The root-cause analysis is accurate against the current source: set.rs unconditionally stores UndoStatus::Final once a message is accepted into the queue (regardless of a future HOLDUNTIL), while query.rs’s UndoStatus filter only checks that stored index value, never live queue state. get.rs is the outlier because it recomputes status dynamically from the queue, which is why get and query disagree. This is a real inconsistency in the current codebase, not a documented/expected behavior, so it will need a code fix (e.g. having set.rs store Pending when a future HOLDUNTIL is present, and flipping it to Final on actual delivery/queue removal) rather than a config change.

This isn’t something documentation can resolve since it’s a source-level bug; a staff or community member with repo access will need to follow up on fixing the indexing/status logic.

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.17 (yet to be released). Note, the fix is for new submissions only, existing ones might still not work as expected.