Drafts were always created in the primary account, so a message composed as
a group identity sat in the member's own Drafts while its Sent copy (since the
previous commit) went to the group.
- createDraft takes { accountId, previousDraftAccountId }: the draft goes to
the account that owns the From identity (resolveSendAccountId, now public
and briefly caching the identity lists for autosave), and the replaced
version is destroyed in the account it lives in.
- The composer tracks draftAccountId next to draftId (save, re-resolve of
attachment blobs, send, discard, stashed state), since draft ids are only
unique within one account.
- sendEmail destroys the sent draft in options.draftAccountId; discarding
(inline and Pro tab) deletes it there, through the identity's login.
- Re-opening a draft from a group/shared Drafts folder (or a stamped
aggregate row) keeps its account.
- resolveSendAccountId is optional on IJMAPClient; without it drafts stay in
the primary account.
- Tests: client unit tests for draft placement / replacement / send cleanup,
E2E spec 18 draft cases (autosave + send, discard).
Expanding a conversation in the message list (row click, expand chevron,
or the `x` shortcut) and clicking a row in a Pro folder tab marked the
messages read straight away. They ignored "Mark as read: Never" and any
delay, which the reading pane and the conversation view follow.
Route them through one helper, scheduleAutoMarkAsRead: -1 never marks,
0 marks at once, any other value marks after that many milliseconds and
returns a cancel function.
- New store action autoMarkExpandedThreadAsRead keeps one pending timer
per thread key. Collapsing the thread, or collapsing all threads, drops
it. Both expansion paths (EmailList and the `x` handler in MailApp) use
it.
- MailApp's handleEmailSelect drops the pending mark of every expanded
thread other than the one the newly opened message belongs to, through
cancelAutoMarkThreadAsRead. Moving on with `j`/`k` or a click no longer
lets the delayed mark land on a conversation the user has left. The
thread stays expanded.
- ProFolderTabBody schedules the row mark through the helper and drops a
pending one when another row is opened or the tab unmounts.
A new feature gate, archiveActionEnabled (default on), lets the admin turn the
Archive action off for installations that archive mail elsewhere, such as a
mail gateway. With it off, Bulwark offers no Archive anywhere: the viewer's
button and its overflow entry, the context menu for one or several messages,
Mountain View's selection bar, the hover and swipe actions, the E shortcut and
its help row, archiving after an export, and the archive mode setting (also in
the settings search). With it off nothing creates an Archive folder:
ensureArchiveMailbox returns null. Existing Archive folders stay ordinary
folders, moving mail into them by hand still works, and the plugin API is
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A draft that carried attachments and no message re-opened with the
contents of one of the attachments as its text. Attach a .csv to an
empty draft, save it, re-open it: the editor shows the rows of the csv
as what you had written, and the next save or send makes that the real
message.
Both edit-draft paths built the text body by joining every value in
bodyValues:
Object.values(draft.bodyValues).map(v => v.value).join('\n')
bodyValues is keyed by part id, and getEmail asks for
fetchAllBodyValues, which per RFC 8621 4.1.4 returns a value for every
text/* part of the message - attachments included. So any attached
.csv, .txt, .log or .md was joined in as body text, and with no html
body to prefer it became the whole message.
Take the body from the parts the server lists as the body instead, and
from nothing else. A draft whose textBody is empty now re-opens empty,
which is what an attachments-only draft is.
The selection lives in lib/email-body-selection.ts beside
getRenderableHtmlBody, which exists because the viewer and the mobile
thread view had already drifted apart once (#489). mail-app and the pro
email tab held character-for-character copies of this join, so the same
drift was already in place.
The leading-CSS strip emptied or cut ordinary previews:
"@media team, the brief is attached", "@Page can you check?" and
"@import the CSV ..." became empty, and "Reminder: your appointment
{date: 2026-10-01} is confirmed" became "is confirmed".
At-rule names now match in lower case only, only `@import` and
`@charset` end at a semicolon, an at-rule word with no block after it
is prose, and a selector colon must not be followed by a space.
The conversation view's collapsed cards, contact activity, the pro
folder tab and the list's row-height estimate now use `cleanPreview`
too, so they agree with the list rows. The test's invisible characters
are written as escapes.
`getEmails` and `getSomeEmails` caught every failure and answered with
an empty result. A refresh during a server hiccup therefore emptied the
message list, and a push delta whose follow-up read failed treated every
updated row as gone from the folder while still moving the sync state
past those changes, so the missed mail only came back on a full reload.
Both now throw. A refresh that fails keeps the list on screen, a delta
that cannot be read falls back to the full refresh, and the unified
views already record per-account failures instead of showing nothing.
A reopened draft is saved over and removed by id through the login and
account it is stamped with, and message ids repeat across servers. Two
ways of opening a draft stamped it with a login that was only on
screen, not the one it was read through:
- A Pro email tab opened by double-click named no login. It read the
message through whatever login the main list was browsing and stamped
the active one: with the 0.16.23 login active and the 1.0 login's
Drafts browsed, the first autosave destroyed the draft's id in the
0.16.23 account and left the draft itself behind. A Pro view now
stamps only a login its host names, and reads the message through
that login's client. A double-click on a row of the mail list or of a
folder tab names the row's login and account, and the search preview
and a folder tab's reading pane name theirs for the active login
too. Without a login the composer goes by the draft's From, as it did
before.
- "Cancel and edit" in Scheduled took the login browsed before, which
stays set while Scheduled lists the active login's mail. The draft
was restored through the active login and then saved over and
discarded through the other one. It now takes the active login, and
a send undone or cancelled for editing never falls back to what is on
screen.
Also:
- A draft version saved through another login is removed only once the
message is out. A host without a connection sent nothing and the
version was removed anyway; it now says so and the draft stays.
- An email tab opened for one login's message no longer focuses the tab
of another login's message with the same id and account.
"Send now" and "Reschedule" create a replacement submission and then
cancel the original. The server checks the recipients again for the
replacement, and a throttle used up since the original was taken ("452
4.4.5 Rate limit exceeded") can refuse some of them. The original was
kept only when every recipient was refused; on a partial refusal it was
cancelled, and a recipient the original had taken lost the message
without anyone being told.
- The original's delivery status is read with its envelope. When the
replacement refuses a recipient the original took, the replacement is
cancelled (its record only destroyed when it took nobody) and the
original goes at its old time. A recipient the original refused as
well loses nothing, so the reschedule goes ahead; checked live on
0.16.23 and 1.0 with a relay-refused recipient.
- The toast for a kept schedule leads with "The message is still
scheduled for ..." and says "Nothing was changed: the server refused
...". It used to open with "Not sent", which invited sending the
message a second time.
- Recipients refused while the others were taken are now named after
"Send now", a reschedule from the list or the viewer, and a quick
reply, which said "Email sent".
Files, from the review of d9ffe8a7:
- An own folder opened from search while Files showed a shared folder
opened the shared folder again: its link had no query, and the page
read `?shared=` from the address bar. The link now says it has none.
- The Pro shell kept only the path of a Files link across its redirect,
so a shared folder's link (`/files?shared=...`) didn't survive a
reload, and neither did `?preview=`. The query goes along now.
- A search hit for a file on a "Files from" page opened the first file
of that name there; it is found by the id the link names now.
- Undoing a paste of files cut on a "Files from" page moved them to
"*", the page's id; they go back to their own folders now.
A draft's old version is removed by id when the composer saves over it,
sends it or discards it. Email ids repeat across accounts and servers -
the same user logged in to two servers is account `e` on both - so the
removal must go to the login and account the draft lives in. Two paths
took the wrong one:
- "Edit draft" in an "All folders" search or a tag view took the account
from the folder still selected underneath. With a group folder
selected, an own draft was opened as the group's, and its first
autosave, send or discard destroyed the message with that id in the
group account, while the draft stayed in the own Drafts. It now takes
the login and account the result is stamped with, reads the draft
through that login, and prefers that login's identity when two
logins share an address.
- The composer discarded through the login of the current From, and
replaced or sent through it too. After switching From to another
login's identity - which saves nothing by itself - discarding,
the next save or the send removed the draft's id on the other server.
Each save now records the login it went through (a draft opened from a
list starts with the list's). A version saved through another login is
removed through that one: after the new version exists when saving,
after the message is out when sending, and on discard.
The Pro email view, folder tabs and search preview pass their login
along with the draft.
Stalwart runs the SMTP RCPT stage while it creates a submission and
records a refused recipient as `delivered: "no"` with its reply: a relay
it doesn't allow ("550 5.1.2 Relay not allowed.") or a used-up throttle
("452 4.4.5 Rate limit exceeded, try again later."), on 0.16 and 1.0
alike. The create still succeeds and onSuccessUpdateEmail files the
message in Sent. Bulwark read only the new record's id and sendAt, said
"Email sent", and on Stalwart destroyed the record right after, the only
trace of the refusal. The message was lost without a bounce.
- the send reads the new submission's delivery status in the same
request (`EmailSubmission/get` by its creation id; the create's answer
has none), before the record is destroyed
- every recipient refused: the send fails with the server's reply per
address, the copy filed in Sent is removed and the user's draft kept,
so the address can be fixed in the composer; held sends (undo send,
scheduled) are refused when created, so this covers them too
- some recipients refused: the others get the message, and a warning
names the refused ones with their replies
- "Send now" and "Reschedule" keep the original when every recipient of
the replacement is refused, instead of cancelling it
- the submission prune leaves a shared account that refuses its records
alone for the session, without a warning: 0.16 lists an account that
only shared an address book or a calendar with the submission
capability
Mail sent from a group or shared identity (team@example.org) is created
in that account, together with its submission, on Stalwart 0.16 and 1.0
alike. Undo send and "Cancel and edit" cancelled the submission there,
but then put the message back in Drafts, read it back and later removed
it through the user's own account. Email ids repeat across accounts - on
the test servers a new team message and a message in userb's inbox were
both `iaaaaac` - so an unrelated own message was moved to Drafts, opened
in the composer and destroyed on Discard, while the undone message stayed
in the group's Sent.
The account the message was sent from now travels with it:
- `restoreEmailToDraft` takes the account, the store looks up that
account's Drafts by role, and the restored draft is read from there
- the composer keeps the draft's account (`draftAccountId`); saving,
sending and discarding remove the old version in that account, and the
first save moves the draft to the own Drafts, where drafts are saved
- discarding goes through the login the draft was saved with, as the
send does (#461)
- "Edit draft" on a message in a shared or group folder, in the standard
and the Pro views, opens it with that folder's account, so a draft left
in the group's Drafts is not removed by id in the own account either
- S/MIME copies, and the unused `cancelScheduledEmail`, are deleted in
the submission's account
Follow-ups to the review of the push, keyword and identity work:
- A send that names no sender, like the List-Unsubscribe mailto: of the
unified view, fell back to the first identity in server order, which
on Stalwart 1.0 is a group's. It now takes the login's own identity,
with addresses compared regardless of case.
- Re-register creates the replacement subscription before it destroys
the old one, so at Stalwart's limit (15 per account by default) it
failed with `overQuota`. It now frees the old record's place and
tries once more.
- On 1.0 the push settings note goes by the emailpush capability alone
and ignores refusals remembered under 0.16. On 0.16 a group the user
belongs to is recognised by its identity, so a member without
calendars, contacts or files keeps the group's junk filter and is no
longer told the group sends no notifications.
- Plugin imports can no longer set Stalwart 1.0's attachment keywords,
and `keywords.discover` leaves them out.
- Batch mark-as-read showed messages the server refused as read. It now
updates only what was written and reports the rest, and thread
expansion and category-tab moves keep what a partly refused batch
did write.
- Refused read and star changes from shortcuts, the list, the viewer
and Pro tabs show "Failed to update email" instead of an unhandled
rejection. The automatic mark-as-read on opening stays quiet.
- A tag whose id predates the 128-byte limit can have its colour
changed again.
- The state-regression guard notes that a multi-node 1.0 cluster can
trip it harmlessly, at the cost of one extra full fetch.
JMAP thread ids are only unique within their account - Stalwart hands out
per-account counters such as "b" - so the cross-account views merged
unrelated conversations that happened to share an id, and every
client-side thread map (expansion, cache, loading flag, counts,
virtualizer key) mixed them up the same way.
Give ThreadGroup an account-scoped `threadKey` derived from the email's
source stamps and key grouping and all thread state by it. `threadId`
stays the raw id for Thread/get, which #281 already routes to the owning
account. fetchThreadEmailCounts now asks each source account for its own
threads instead of one client for every id. Single-account views carry no
stamps, so their key is the bare id and nothing changes there.
`ProComposeTabBody` rendered `<EmailComposer>` without
`composeFromAccountEmail`; only the standard shell in
`components/mail/mail-app.tsx` passed it, so `mode === 'compose'` resolved
`findComposeIdentityId(identities, undefined)` to null and every new message
in the Pro shell defaulted to the account owner even with a shared folder
open. That covered the whole compose surface there, since the Pro shell
hoists every "show composer" intent - `mailto:` links included - into a tab.
Resolved exactly as the standard shell does, so both share one rule, but
read once at mount: a tab body stays mounted while the tab lives, so
subscribing to the mailbox list would re-render the composer and its editor
on every counter refresh.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The #849 fix carries a re-opened draft's parts into the composer on the
classic path, but the Pro draft tab builds its own initialData and
still omitted email.attachments - editing a draft from a Pro tab kept
stripping its files on the next save. Pass the parts through; the
composer's existing initializer and remap handle the rest.
Opening a mailto: link while the composer held unsaved text used to
replace the session outright and drop the text on the floor. The mailto
entry point now hands a follow-up to the composer's dirty-aware close
handler, so the "Save or discard draft?" dialog runs first: save and
discard continue into the mailto draft, cancel leaves the current draft
untouched. When a failed save hands the text back to the continue-draft
slot (#702), that rescue wins and the follow-up is dropped.
Tags, pins and $answered/$forwarded/$mdnsent set on a message in a
shared/group mailbox opened directly from the "Shared" sidebar section
did not persist: they showed until the next reload and were then gone.
The call sites resolved the target account as
`isUnifiedView ? email.sourceAccountId : undefined`. Outside the unified
view that is always `undefined`, so the write went to the reaching
client's own account, which the server answers with `notUpdated` /
`updated: null` and no error - the same silent no-op that
`email-store-shared-folder-actions.test.ts` already covers for the batch
actions. Emails fetched from a shared folder this way are undecorated
(no `sourceAccountId`); the owner is only recoverable from the selected
mailbox.
`toggleStar` never had the bug because it routes through
`resolveEmailActionContext`, which resolves the owner from the email's
source in an aggregate view and from the selected mailbox otherwise.
That resolver was reimplemented inline at each keyword call site, and
the inline copies only ever covered the aggregate half - so starring a
shared message persisted while tagging the same message did not.
Move the two keyword writes into store actions built on that resolver
and route every call site through them, including the read-receipt and
Pro-compose paths that previously passed no accountId at all.
The store actions resolve only for an email the store actually knows.
A Pro tab fetches its own email and never enters `emails` or
`selectedEmail`; for those the selected mailbox says nothing about the
message's owner, so guessing from it would send an own-account write to
a shared account whenever a shared folder happened to be open. An
unknown email keeps the previous behaviour instead.
Behaviour on own-account and unified-view messages is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>