fix: route scheduled sends to the account that owns the submission - #874
Merged
rathlinus merged 1 commit intoAug 25, 2026
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
A delayed ("scheduled") send composed from a shared/group identity is created in the shared JMAP account —
sendEmail()passes the target account togetSubmissionAccountId(). But every read/write on the scheduled surface asked the primary submission account only:So a message scheduled from a shared address is accepted and stays
pendingon the server — it will be delivered atsendAt— while the UI cannot list it, cancel it, or reschedule it.Worse, cancelling appears to work.
EmailSubmission/setagainst the primary account answersnotFoundundernotUpdated, 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:submissionon group accounts as well as personal ones, withFUTURERELEASEand a non-zeromaxDelayedSend: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'sEmailfrom 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 thesendAt/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 inSendEmailResult, 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.UI
__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:notFoundno-op.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
sendAton create while 0.16.18 does; the existinggetEmailSubmissionSendAtfallback covers it, and is now account-aware too.One thing deliberately left alone
delayedSendSupportedinmail-app.tsxgates the whole scheduled UI onclient.hasDelayedSend()— the primary account. A deployment granting delayed send only to a shared account would hide the rows. Not reachable on Stalwart, which grantsFUTURERELEASEuniformly, 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.