Skip to content

fix: route scheduled sends to the account that owns the submission - #874

Merged
rathlinus merged 1 commit into
bulwarkmail:mainfrom
rotterp:fix/scheduled-send-shared-accounts
Aug 25, 2026
Merged

fix: route scheduled sends to the account that owns the submission#874
rathlinus merged 1 commit into
bulwarkmail:mainfrom
rotterp:fix/scheduled-send-shared-accounts

Conversation

@rotterp

@rotterp rotterp commented Aug 20, 2026

Copy link
Copy Markdown

The bug

A delayed ("scheduled") send composed from a shared/group identity is created in the shared JMAP account — sendEmail() passes the target account to getSubmissionAccountId(). But every read/write on the scheduled surface asked the primary submission account only:

getScheduledEmails / cancelEmailSubmission / rescheduleEmailSubmission
    -> getSubmissionAccountId()   // no argument = primary account

So a message scheduled from a shared address is accepted and stays pending on the server — it will be delivered at sendAt — while the UI cannot list it, cancel it, or reschedule it.

Worse, cancelling appears to work. EmailSubmission/set against the primary account answers notFound under notUpdated, which the client never surfaced, so the user sees no error and the mail goes out anyway. Silently unrecallable mail is a good deal worse than a failed send.

This is the same root cause as #800 (a shared mailbox not carrying its account context), on the scheduled-send path.

Why scheduling from a shared address is legitimate

Stalwart advertises urn:ietf:params:jmap:submission on group accounts as well as personal ones, with FUTURERELEASE and a non-zero maxDelayedSend:

e  carol@example.org  isPersonal=True   maxDelayedSend=2592000  ext={'FUTURERELEASE': [], ...}
f  team@example.org   isPersonal=False  maxDelayedSend=2592000  ext={'FUTURERELEASE': [], ...}

The server is happy to schedule it. Only the client's account-blind lookups were broken.

The fix

Data layer

  • getSubmissionAccountIds() enumerates every submission-capable account. getScheduledEmails() queries each, tags every result with its owning account, and fetches each submission's Email from that same account (it lives beside the submission, not in the primary account). It also returns per-account totals computed over all pending submissions, so a badge stays correct past the first page.
  • resolveSubmissionAccountId() locates a submission's owning account so cancel/reschedule — and the sendAt/envelope helpers — act on the right one. Callers that already know the account pass it and skip the lookup.
  • sendEmail() reports the submission's account in SendEmailResult, and the pending-undo record carries it. The "Undo send" / "Send now" toast actions know only a submission id, so without this they'd fall back to probing every shared account sequentially inside the undo window.
  • The delayed-send gate considers any reachable submission account rather than only the primary one.

UI

  • Each shared account gets its own Scheduled sidebar row, scoped to that account; the own row keeps showing the combined view. The scope travels beside the virtual mailbox id, so the store's single __scheduled__ id is unchanged. It also drives which row renders as selected and whether the scoped list has more to load.

Testing

integration/tests/11-shared-scheduled-send.spec.ts, against a real Stalwart:

  • Two server-truth tests pin the mechanism — the submission is invisible to the primary account, and cancelling through it is a silent notFound no-op.
  • Two UI tests cover the fix — the combined list, the scoped row, and which row is highlighted.

Control experiment: with the fix reverted and the app rebuilt, both UI tests fail while the two server-truth tests still pass (they assert server behaviour, not app behaviour). The selection assertion was likewise verified by reintroducing that specific bug and confirming it goes red.

Verified on Stalwart 0.16.7 and 0.16.18. Note that 0.16.7 does not echo sendAt on create while 0.16.18 does; the existing getEmailSubmissionSendAt fallback covers it, and is now account-aware too.

One thing deliberately left alone

delayedSendSupported in mail-app.tsx gates the whole scheduled UI on client.hasDelayedSend() — the primary account. A deployment granting delayed send only to a shared account would hide the rows. Not reachable on Stalwart, which grants FUTURERELEASE uniformly, and widening that gate would change behaviour for non-shared users beyond this bug's scope. Happy to fold it in if you'd prefer.

A delayed send composed from a shared/group identity is created in the
*shared* JMAP account, because sendEmail() passes the target account to
getSubmissionAccountId(). Every read/write on the scheduled surface,
however, asked the primary submission account only:

    getScheduledEmails / cancelEmailSubmission / rescheduleEmailSubmission
        -> getSubmissionAccountId()   // no argument = primary account

So a message scheduled from a shared address is accepted and stays
pending on the server — it *will* be delivered at sendAt — while the UI
cannot list it, cancel it or reschedule it. Cancelling appears to work:
EmailSubmission/set against the primary account answers `notFound` in
notUpdated, which the client never surfaced, and the mail goes out
anyway. Silent unrecallable mail is worse than a failed send.

Stalwart advertises urn:ietf:params:jmap:submission (with FUTURERELEASE
and a non-zero maxDelayedSend) on group accounts as well as personal
ones, so scheduling from a shared address is legitimate.

Data layer:
- getSubmissionAccountIds() enumerates every submission-capable account;
  getScheduledEmails() queries each, tags every result with the account
  that owns it, and fetches each submission's Email from that same
  account (it lives beside the submission, not in the primary account).
  It also returns per-account totals computed over all pending
  submissions, so a badge stays correct past the first page.
- resolveSubmissionAccountId() locates a submission's owning account so
  cancel/reschedule (and the sendAt/envelope helpers) act on the right
  one. Callers that know the account pass it and skip the lookup.
- sendEmail() reports the submission's account in SendEmailResult, and
  the pending-undo record carries it, so the "Undo send" / "Send now"
  toast actions — which know only a submission id — address the right
  account directly rather than searching for it inside the undo window.
- The delayed-send gate considers any reachable submission account
  rather than only the primary one.

UI:
- Each shared account gets its own "Scheduled" sidebar row, scoped to
  that account; the own row keeps showing the combined view. The scope
  travels beside the virtual mailbox id, so the store's single
  __scheduled__ id is unchanged, and it also drives which row renders as
  selected and whether the scoped list has more to load.

Tested against a real Stalwart (integration/tests/11-shared-scheduled-send):
two server-truth tests pin the mechanism (the submission is invisible to
the primary account, and cancelling through it is a silent no-op) and two
UI tests cover the fix. All three UI-level assertions — the combined
list, the scoped row, and which row is selected — fail without this
change.
@rathlinus
rathlinus merged commit 1305d13 into bulwarkmail:main Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants