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).
Stalwart's default folder set has no archive-role mailbox, so on a fresh
account Archive did nothing but raise the not-found toast (#578). Both
archive paths now go through ensureArchiveMailbox(), which creates
"Archive" with the archive role in the account the action targets (own,
the selected shared folder's owner, or the pinned account in unified
view), reloads the mailbox list and continues with the store's own
object. createMailbox() takes an optional role for this. A refused
creation or a folder that does not come back still surfaces as an error.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A copy between logins goes through `Email/import`, which sent no
`receivedAt`, so the server stamped the import time and every copied
message landed at the top of the destination folder as today's mail.
`importRawEmail` now takes the original `receivedAt` (the same change
#1151 carries), and the cross-account copy and move pass it.
With several accounts connected, mail could only be moved between
them (drag to another account's folder). There was no way to file a
copy in another account and keep the original where it is.
crossAccountMoveEmails and copyEmailAcrossAccounts take an optional
`{ keepOriginal: true }`. With it the source message is not destroyed,
and the store leaves the list, the open message and the selection as
they are; only the destination's folders are refreshed. Without it
nothing changes.
The message menu gets "Copy to account..." when more than one account
is connected. It lists the writable folders of the other accounts
(their folder lists are fetched when the menu opens) and copies the
selection, or the message the menu was opened on, through the new
copyEmailsToAccount action. That groups the messages by the login and
JMAP account they live in and refuses a copy into the account a
message is already in.
Drafts were saved without In-Reply-To and References, and a draft
re-opens in compose mode, where the composer has no original message to
rebuild them from. A reply that was saved and re-opened, or sent and
brought back with Undo, therefore went out as a new conversation.
`createDraft` now stores the threading headers, the composer saves them
with every draft of a reply, and re-opening a draft carries them into
the composer for later saves and the send.
A message sent or scheduled from a shared or group identity lives in
that account. Undo send, "Edit" on a scheduled message and cancelling
one cancelled the submission in the right account (#874), but then
restored the message to Drafts, fetched and deleted it through the
login's primary account. That hit whichever primary message had the
same id - pulling an unrelated mail into Drafts or deleting it - and
left the unsent message sitting in the group's Sent.
`restoreEmailToDraft` now takes the account, and the store passes the
submission's account to restore, fetch and delete, using that
account's Drafts and Sent.
Stalwart 0.16 (0.16.6 to 0.16.23) sends a change of one occurrence of a
meeting with that occurrence's SEQUENCE raised, but does not keep the
raise itself: in its organizer update code the list of components to
raise is shadowed for changes of single occurrences. A later change of
every occurrence that does not raise the number again - a new title,
another description - then went out with the old number for that
occurrence, and the attendee's server refused the whole message as out
of sequence. The attendee got the "Updated invitation" mail, but their
calendar stayed as it was (live on 0.16.23; 1.0 raises and keeps the
number itself).
A change of every occurrence that the user as organizer sends to the
attendees, of something they see, now raises the SEQUENCE of the series
and of each changed occurrence by one, on a series that has changed
occurrences. One up from what the organizer's copy holds is at least
what the attendees got, and it keeps the series and its occurrences on
equal numbers, which 0.16 needs to list an occurrence once. 1.0 raises
the number of every occurrence the same way for a change it sends, and
keeps a number it would have given: the result there is the same
(live).
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
Fixes from the review of the Files work (1cf9944b..a9a25b94).
- A file shared on its own, or one below a folder its owner took back,
could not be reached. Once Stalwart 1.0's placeholder folders were
left out it became a root of "Shared with me", which listed folders
only, and search opened it on the drive root. 0.16 lists such files
as roots too. Each owner's files shared on their own are now listed
on one page under "Shared with me" ("Files from <name>"), where they
open, preview and download like any other file and nothing can be
added; two of the same name get a number. Search opens a shared hit
where it is listed, and links into a shared drive name the node by id
(`/files?shared=<owner>:<id>`), so a shared folder or page also
survives a reload. A Files page that is already open takes the link
from search directly, since Next keeps it mounted when only the query
changes.
- A copy that fails part-way destroys only the nodes it made, the
deepest first and without `onDestroyRemoveChildren`, one call per
level because 0.16 refuses a folder destroyed in the same call as
its children. It used to destroy the new folder with everything
below it, so whatever someone put there during a long copy was
deleted for good. A folder holding such nodes now stays, and the
error says part of the copy was left in place.
- The document editor opens read-only on 0.16 for a sharee without the
rename right. 0.16.6 to 0.16.23 refuse any change a sharee makes
without Modify, so every save of such a document failed with a 500.
1.0, told apart by its `maxFileNodeDepth`, still asks for
`mayModifyContent` alone.
- Ctrl+C with only symlinks selected empties the clipboard instead of
keeping an earlier cut for the next paste.
- A paste reads each account's file tree once, not once per pasted
item for the listing and 1.0's depth check.
- The Stalwart bundle's README says that on older 0.16 releases and
the 1.0 pre-releases a failed update also drops the cached copy, and
the next restart or reload of the Applications downloads it again,
not only a later `UpdateApps`. The static README says a restart
applies the CORS setting on the current 1.0 pre-releases, where
`ReloadSettings` fails with the stock spam-filter rules.
44c77b26 kept the server's order for sender and subject sorted lists.
That is right for Stalwart 1.0, which sorts lowercased byte-order keys,
but 0.16 compares the raw bytes, capitals first, so on every 0.16
server a sender list read "Adam, Carla, Eve, bea, dave, zed".
- keep the server's order only for accounts that list
`urn:ietf:params:jmap:principals:owner` (every 1.0 account, never
0.16); elsewhere pages and thread groups are re-sorted
case-insensitively, as before 44c77b26
- the client comparator puts a message without a sender or subject
last in both directions, as both versions do
Expanding a thread marked it read in the login's own account instead
of the shared or group account it lives in, so Stalwart answered
notFound or marked an unrelated message with the same id, and since
d7004b38 the thread also stayed unread. The write now names the owning
account. Moving mail between category tabs had the same fault: it took
a listed message's display-only account reference, and now routes each
message like the other keyword writes.
On 1.0 a vCard export sent every blob photo through the avatar download
queue, a burst of 40 and then 6 a second, two at a time: about 160 s for
1000 photos. Avatars and print (which gives up after 5 s) waited behind
it, the Export button showed nothing, and a second click started the
whole export again.
- Export fetches the blob media of all cards with Blob/get
(`data:asBase64`), asking for the sizes first and packing the requests
to about 4 MiB. `urn:ietf:params:jmap:blob` goes into `using` only
when the session advertises it; without it, or when a request is
refused, the rest is downloaded one by one behind avatars and print,
which keep half of the download burst for themselves.
- The export buttons in the settings, the bulk bar, the context menu and
the detail menu show that an export is running and ignore clicks until
it is done.
- When the photo changes, the form rebuilds `media` on the card as the
server has it now. A logo or sound blobId from the list's older copy
made 1.0 refuse the whole update ("The following blobIds do not exist
or are not accessible").
- A new photo takes the key, place, `pref`, `label` and `contexts` of the
one it replaces, so it stays the photo shown on cards with several.
Removing the photo removes every photo the card had instead of only the
shown one, which brought up the next; logos and sounds stay.
- A card whose login has no client downloads nothing, instead of asking
the active login's server and remembering the refusal for two minutes.
- Photo downloads run at half the server's `maxConcurrentRequests`
instead of a fixed two.
- `invalidProperties` naming `media/...` also counts as a refused blobId
in media, not only `media` itself.
Four smaller calendar fixes found in review of the Stalwart 1.0 work.
- A range load that failed left `dateRange` on the range loaded before,
so a push or a pull-to-refresh reloaded that one instead of the range
on screen, and any action that reset `error` hid the "may be out of
date" notice. The store now takes the range asked for and keeps the
failure in `eventsLoadError`, which only a successful range load
clears.
- The agenda plugin's expanded query had no sort, so its limit could
keep later occurrences instead of the next ones. It now sorts by
start, looks less far ahead when the server refuses the range as too
long (`expandDurationTooLarge`, an administrator's shorter
`maxExpandedQueryDuration`) instead of answering 502, and sends its
gets several per request.
- With a limit (search, contact activity) the chunks of a long range
went out one request each. Up to four now share a request, and what
the later ones return past the limit is dropped. Global search passes
its AbortSignal down, so a replaced search stops between requests.
- A search of several words sends its date bounds as conditions of a
flat AND. The client did not see them as a range: it neither expanded
recurrences nor turned the UTC bounds into the request's wall clock,
so such a search found series instead of occurrences, over a range
shifted by the zone's offset. The bounds are now lifted out of the
AND, converted and chunked, and each chunk keeps one flat AND of
single conditions - the shape 1.0 expands; it refuses an AND holding
a condition with both bounds.
Stalwart 1.0 enforces maxParticipantsPerEvent (20 by default) on every
CalendarEvent create and update, answers to invitations included, and
refuses anything over it with invalidProperties "An event cannot have
more than 20 participants.". 0.16 advertises the same value but never
enforced it. Creating, editing, moving or answering a bigger meeting
failed with a generic "Failed to save event".
- read the limit from the calendars account capability and warn in the
event editor when the attendees go over it, without blocking the save
- name the limit, and that an administrator can raise it, when the
server refuses a create, edit, move, resize or answer for it, in the
calendar and in the invitation banner
- keep the SetError type on refused event creates and updates
Stalwart 1.0 checks names more strictly than its capability says: a
reserved name may not be the stem either (`con.txt`, `Aux.tar.gz`), and
control characters are refused (macOS `Icon\r`). Names over 255 bytes
of UTF-8 were never accepted. Uploaded names are now made acceptable
before they are sent: control characters become "_", a reserved stem
gets "_" after it (`con.txt` becomes `con_.txt`), and a long name is
shortened, keeping its extension. The rename and new-folder dialogs
refuse such names with their own messages. The rules apply wherever the
server publishes naming rules, so 0.16.6 to 0.16.23 get `con_.txt` too.
One refused file used to abort a whole upload. Uploads now go on and
report the files that failed at the end, and a folder upload skips the
contents of a folder it could not create instead of dropping them into
the folder the upload started in. What would sit deeper than 1.0's
`maxFileNodeDepth` (64) is left out before it is uploaded, and a folder
copy that would go past it fails before the first create.
File errors keep their JMAP type, and the Files toasts now say why an
action failed: too many files or folders (`overQuota`), folders nested
too deeply, a refused name, missing rights, or a file too large. New
and copied files get a bare lowercase media type, which 1.0 requires.
Stalwart 1.0 allows 100 Principal/getAvailability calls per account a
minute and counts every call, however they are batched. The event form
sent one request per attendee whenever the time or the attendees
changed, and showed "Available" for an empty answer, which is also what
the server returns when the user may not see that person's free/busy.
- Answers are cached for three minutes per principal and the local
days the event touches, so moving an event within its day asks
nothing new; the status for the exact time is worked out locally.
- Lookups already on their way are shared, and the principals without
an answer go out in one request, split by `maxCallsInRequest`.
- After `rateLimit` nothing is sent for a minute, and attendees without
an answer show "Couldn't check".
- An empty answer shows "No busy times shared" instead of "Available".
0.16 has no rate limit; it gets the same labels with fewer requests.
Stalwart refuses a push subscription's whole emailPush map as
`forbidden` when it names a mailbox shared by ACL (0.16.16 and later,
and 1.0). Bulwark put every account with mailboxes in the map, so
enabling push showed raw JSON, and the background re-sync destroyed the
working subscription after its refresh was refused and dropped its key:
push stopped without a word and could not be turned back on while the
share existed. A network error, a timeout or rate limiting during the
refresh ended it the same way.
- The map holds the primary account and the accounts that list
`urn:ietf:params:jmap:emailpush`, which 1.0 leaves off ACL shares.
0.16 lists it everywhere, so a refused map narrows to the accounts
that look owned, then to the primary account, as the mobile app does.
Refused accounts are remembered under
`bulwark.push.emailPushRefused.v1.<id>`; Re-register and disable
clear the list.
- A subscription ends only when a verified replacement exists, the user
turns push off, or the server no longer has it. The old record is
destroyed after the new one is verified. A failed listing, a network
error or a refused method call leave it and its key alone.
- PushSubscription/set failures throw typed errors (`jmapSetError` for
a refused object, `jmapError` for a refused call) instead of JSON in
the message or `false`, and the client exposes `getAccountInfo`.
- Settings says that mailboxes others share don't send background
notifications, and `forbidden` and `overQuota` read as plain text in
settings and the onboarding prompt.
Stalwart 1.0 refuses Principal/query and Principal/getAvailability with
`forbidden` unless the administrator allows directory queries, which is
off by default. `getPrincipals` turned that into an empty list with no
signal: the share dialog said "No other users or groups found", existing
shares showed bare ids, and free/busy called every attendee "Not a user
on this server".
- `listPrincipals` says whether the listing is complete, `restricted`
or failed. On `forbidden` it falls back to Principal/get without ids,
which 1.0 still answers with the user's own account, their groups and
the accounts that shared something with them.
- Principal/get asks for `id`, `type`, `name`, `description` and
`email` only; 1.0's default list adds `capabilities` and `accounts`.
- One listing per client, kept for five minutes, serves the share
dialog, recipient autocomplete and free/busy, which used to download
the directory three times.
- The share dialog explains a restricted directory while still offering
its candidates, says when the list failed to load, and shows "Unknown
user" with the id for sharees it can't name.
- Free/busy first reads `mayGetAvailability` from the user's own
principal and stays hidden when it is false or a lookup is refused
with `forbidden`. "Not a user on this server" now needs a complete
listing; failures and `rateLimit` show "Couldn't check".
On 0.16 with the default roles the listing is complete and the dialog
behaves as before.
Stalwart 1.0 returns every photo, logo and sound in a contact card as a
blobId without a uri, including every photo Bulwark saved as a data:
URI. Bulwark read only `uri`, so avatars, the contact list and detail,
print, vCard export and the "has photo" filter showed nothing, and the
form sent `media` without the photo on every save, so editing a phone
number deleted the photo on the server.
- `ContactMedia` carries `blobId`, `label`, `pref` and `contexts`; `uri`
is optional, and `getContactPhotoRef`/`hasContactPhoto` read both.
- Blob photos are downloaded once each, at most two at a time and at a
paced rate, only while on screen, into an LRU of object URLs revoked
on eviction, account switch and logout. They share Stalwart's
per-account budget of 4 concurrent requests and 1000 a minute.
- Avatars look senders up in an index built once per contact list.
- The form leaves `media` out unless the photo changed, and otherwise
passes the other entries through as the server returned them.
- A new photo is uploaded into the user's own account and sent as a
blobId where the blob capability lists ContactCard (1.0). 0.16 refuses
blobIds in media and keeps getting a data: URI; a refusal at runtime
falls back the same way for the rest of the session.
- Print and vCard export download blob media first; duplicate and
cross-account move send the bytes instead of the source's blobIds.
- vCard export also writes photos whose data: URI names no media type
(`data:;base64,`, `data:base64,`), which it dropped before.
Stalwart 1.0 counts every EmailSubmission record of an account against
`maxSubmissions` (500 by default), whatever its status or age, and its
own purge of old records never runs. Bulwark never destroyed a
submission, so after about 500 JMAP sends an account could not send at
all: every send, read receipt and reschedule failed with a raw "There
are too many email submissions" error, and each failed attempt left one
more copy of the message in Drafts.
Bulwark now removes the records that can no longer be cancelled.
Destroying a record never stops its delivery, but a pending one could
no longer be cancelled, so pending records are never touched:
- an immediate send (no hold, no undo window) destroys its own record
right after the send, riding along with the old-draft cleanup; only
where `urn:stalwart:jmap` is advertised, and never decided by the
created `undoStatus`, which 0.16.10-0.16.16 report as final for held
sends
- a throttled background prune destroys canceled records and final
records whose `sendAt` has passed, going by `EmailSubmission/get`, in
every submission account; it runs at startup, after sends, after
undo and on EmailSubmission state changes
- a confirmed cancel (undo, cancel, reschedule) destroys the canceled
record in a request of its own
- an `overQuota` on a submission create prunes at once, on Stalwart
walks the change log for records older than the query's three-day
window, retries once, and otherwise throws `SubmissionQuotaError`,
shown as a translated explanation in the composer, quick reply, read
receipts and the scheduled-send actions
Failed sends now clean up after themselves and say so:
- a failed send removes the Drafts copy it created and keeps the
user's draft; the raw send paths remove their import the same way
- a read receipt that did not go out is taken back out of Sent, a
method-level error counts as a failure, `$MDNSent` is only set after
a sent receipt, and the automatic receipt no longer retries on every
refresh of the open message
- quick-reply failures, "Send now", "Reschedule" and a failed undo are
shown instead of only logged
- the scheduled list reads every page of `EmailSubmission/query`; it
stopped after the first because `total` needs `calculateTotal`
The composer uploaded any file and learned about `maxSizeUpload` (per
blob) or `maxSizeAttachmentsPerEmail` (per message) only from a failed
upload or a failed send. Files over the per-file limit, and files that
would push the message past the per-message limit, are now skipped
with a message naming the limit. The limits come from the account the
message is composed in.
From 0.16.6 Stalwart refuses FileNode names with `/<>:"\|?*` or a
reserved name such as `CON` ("Name contains a forbidden character."),
and publishes the rules as `forbiddenNameChars` / `forbiddenNodeNames`.
Bulwark did not read them: the error showed up only after an upload had
started, and it aborted the rest of a multi-file or folder upload.
- new folder, new text file and rename now say which characters or
names the server does not allow, before sending anything
- uploads store an accepted variant instead: forbidden characters
become `_` and a reserved name gets `_` appended
MIME types over 30 characters (most Office formats) were always stored
as `application/octet-stream`, because Stalwart before 0.16.6 refuses
them. From 0.16.6 the limit is 255, so the real type is kept there.
Stalwart rejects a whole ContactCard over one property it does not
know, and several reached it:
- the form sent `calendarUri`, `schedulingUri` and `freeBusyUri`,
so any contact with a calendar, scheduling or free/busy link failed
to save ("Invalid property.")
- vCard import sent `source`, and Pro mode left `localAccountId` on
the card
- vCard import wrote addresses as flat `street`/`locality` fields
instead of RFC 9553 `components`
The card now passes through `lib/jmap/contact-wire.ts` on its way to
the server: client-only fields are dropped, the links are written as
`calendars` / `schedulingAddresses` (and SOURCE as a `directories`
entry), flat addresses become components, and reads map the links back
for the form and detail view.
Clearing a field (nickname, notes, a link) never reached the server:
the form set it to `undefined`, which JSON drops, and an omitted
property keeps its value. Updates now send `null` for cleared keys.
Deleting an address book that still held contacts always failed with
`addressBookHasContents`. The delete now passes
`onDestroyRemoveContents`, which the confirm dialog already promised.