The telemetry sender and the Stalwart version probe checked the target
with their own DNS lookup and then fetched with a plain `fetch`, which
resolved the name again: a rebinding answer between the two reached an
internal host, and a lookup that failed at check time passed the check
outright. Both now connect through `fetchPublicUrl`, which validates
the address inside the socket's own lookup and fails when the lookup
does. The `BULWARK_TELEMETRY_ALLOW_PRIVATE` dev bypass keeps the plain
fetch.
The code exchange, token refresh and revocation POSTs carry the
authorization code, refresh token and client secret, and used a plain
`fetch` that follows redirects. The endpoints come from a validated
discovery document, but a redirect from them (or from an endpoint that
changed after discovery) re-sent the body to a host nobody validated.
These requests now fail on any redirect. A failed refresh still keeps
the refresh token, as for any other outage.
Printing a message opened an about:blank popup that carries the app's
CSP (`img-src https:`), not the message iframe's strict one, so with
remote content blocked anything the DOM walk missed still loaded when
printing. The print document now carries the iframe CSP and
`no-referrer`.
The DOM walk itself missed several spellings the browser still loads:
- backslashes, which the URL parser reads as slashes (`/\host`,
`https:\host`), and `https:host` without slashes
- an escaped `url` keyword in a style attribute (`\75 rl(`)
- `image-set()` with plain strings
- an unclosed `url(` at the end of a style attribute
A failed TOTP login returned the first 500 bytes of the upstream error
body. With custom JMAP endpoints allowed and no server configured, the
upstream is any address the caller names, so the route read other
servers' responses on the caller's behalf. The detail is now only
passed on from an admin-configured server.
The admin login limit keys on the client IP taken from
X-Forwarded-For. With no reverse proxy in front, that header is
whatever the client sends (Next only fills it from the socket when the
request carries none), so each forged address got a fresh bucket of
five guesses. Route handlers cannot see the socket address, so the
limiter now also stops at 50 attempts per 15 minutes across all
clients. `.env.example` says why a proxy that appends the header is
still needed for trustworthy per-IP limits and audit addresses.
`http.post` attaches the user's Authorization header to any same-origin
path the plugin's own manifest lists in `apiPostPaths`. A plugin with
only `http:post` could list the JMAP passthrough and get full JMAP
access, or reach `/api/admin/*`, `/api/settings` and the other
credentialed routes. The host now refuses those paths whatever the
manifest says, checked on the decoded path the router sees. Plugins
keep reaching their own sidecar routes.
A Bearer login may claim a bare local part, which is accepted when the
server's `Session.username` has the same local part (TOTP users who
type `john` log in this way). Settings were keyed on that claim, so on a
multi-domain server `john@b.example` could claim `john` and read and
overwrite the synced settings of the `john@a.example` who signs in as
`john` with a password.
`resolveJmapIdentity` now returns the canonical account name for a bare
Bearer claim, the context cookie carries it as `accountName`, and the
settings route keys on it. Basic logins and fully-qualified claims keep
their existing key.
The generator wrote several rule fields into the script unescaped. A
`*/` in a rule name ended the `@metadata` comment, a line break ended
the `# Rule:` comment, a custom header name could close its string, and
a size value was written as-is, so each could add live commands such as
`redirect "attacker@..."` to the active script. Rules reach the
generator from plugins holding `filters:write` and from imported filter
sets, not only from the editor.
The metadata JSON now escapes `*/` (JSON reads `\/` back as `/`), the
rule-name comment is kept on one line, header names are escaped like
other strings, and a size that is not a number becomes 0.
`applyNewTabToAnchor` and the viewer's click interceptor only looked at
`<a>`. An `<area>` in an image map kept the target and `rel="opener"`
its sender wrote and took the browser's default click, so the opened
page held `window.opener` and could navigate the webmail tab (reverse
tabnabbing, top navigation in Firefox). `<area>` now gets the same
rel/target treatment and goes through the same click gate, in the
desktop viewer and in the conversation view.
The quoted original in a reply or forward renders in the app document
(a shadow root), where the viewer's iframe CSP does not apply. Opening
a reply therefore loaded every remote image and background of the
original, firing the trackers the viewer had just held back.
The quote's node view now parks remote references in
`data-bulwark-remote-*` attributes for display and restores them when
edits are synced back, so the sent reply still quotes the original as
written. Remote content shows when the policy is `allow` or the sender
is trusted.
The conversation view used on phones built its own sanitizer hook that
only caught `<img src>` starting with a lowercase scheme and inline
style `url()`, and its iframe CSP always allowed http(s) images. With
remote content blocked (the default `ask` policy) uppercase schemes,
srcset, `<source>`, video posters and background attributes still
loaded, so trackers fired when a phone user opened a thread.
The view now uses the desktop viewer's `blockExternalResourcesOnNode`,
and the strict iframe CSP is shared as `emailIframeCsp()` and applied
whenever remote content is blocked, together with `no-referrer`.
A newsletter's List-Unsubscribe `mailto:` was sent from the user's own
account as written: every `to=` field became a recipient, and subject
and body were the sender's choice. The confirmation said only
"Unsubscribe from this sender?", so one click could mail
"I resign" to someone's boss and HR.
The unsubscribe now goes to the single address in the URL (a list of
addresses is refused and `to=` is ignored), the subject is kept on one
line and both subject and body are capped. Both confirmations show the
recipient, subject and body before anything is sent.
The theme sanitizer was a short denylist. It missed protocol-relative
`url(//host)`, schemes and keywords spelled with CSS escapes
(`url(\68ttps:...)`, `@\69mport`), `image-set("https://...")` and
`@font-face` sources, so a theme could load remote resources from the
app document. `validateThemeSelectors` was never called, so theme CSS
could also target `input[type=password]` or restyle real UI. The admin
routes and the zip validator only sanitized when the safety check
failed, and CSS returned by the `onThemeBeforeApply` plugin hook was
injected as-is.
- every `url()` except a `#fragment` and every `image-set()` is removed
with its arguments; a banned construct spelled with escapes drops the
whole stylesheet
- theme CSS keeps only `:root` and `.dark` rules (no nested rules),
plus `@font-face`, `@keyframes` and `@media`/`@supports` wrappers
- sanitizing is unconditional everywhere, including plugin-transformed
CSS; compiled token values may not contain escapes or `url()`
Attachment thumbnails were object URLs typed with the sender's MIME
type. Object URLs share the webmail origin, so an `image/svg+xml`
attachment opened from its thumbnail ("Open image in new tab", or
dragged to the tab strip) ran the sender's script as the webmail in
Chromium and Firefox. The drag-out URL and Files thumbnails had the
same shape.
`fetchBlobAsObjectUrl` now re-types every blob to an inert type, and
image thumbnails in mail and Files go through `imageBlobUrl`, which
hands an SVG over as a `data:` URL: it still renders in the `<img>`,
but opened on its own it gets an opaque origin.
Only the host API methods checked a plugin's permissions. Every hook
and slot the sandbox reported was wired as-is, so a plugin declaring no
permissions could rewrite recipients and body of every outgoing mail
through `onTransformOutgoingEmail`, replace displayed bodies through
`onRenderEmailBody`, or read all fetched mail through
`onEmailsFetched`. Admin-installed plugins are managed, so users never
saw a consent prompt for any of it.
Each hook and slot now maps to a permission in
`lib/plugin-sandbox/permissions.ts`, checked the same way as API calls
(declared, and managed or granted). Hooks that change what is sent need
`email:send`, display takeovers need `email:render-takeover`, and a hook
or slot missing from the table is refused. Logout and account switches
stay implicit so plugins can always drop per-account state.
The Stalwart admin check only bailed while `allowCustomJmapEndpoint` was
on. A context cookie minted while it was on keeps naming the
user-chosen server, and the envelope has no expiry, so after the admin
turned the switch off that server could still answer the admin probe
and mint a Bulwark admin session. The check now also requires the
cookie's server to be an admin-configured one.
The Stalwart admin check probed `x:Account/query`. Stalwart's default
tenant-admin role carries `sysAccount*`, so an administrator of a single
tenant passed and, in the default `auto` mode, was signed into the
Bulwark dashboard, which configures the whole instance for every
tenant. The `auto|password|off` switch added for #870 left the default
at `auto`, so the advisory never held on a default install.
The probe is now `x:Tenant/query`, which needs `sysTenantQuery`: by
default only the server-wide superuser holds it.
GetFile answered on the webmail origin with the Content-Type stored in
the WOPI token, which for a mail attachment comes straight from the
launch request and for a Files node is a user-settable property. No
CSP, nosniff or disposition was added, since `/api/*` skips the proxy.
A user could mint a token for their own blob typed `text/html` and have
a victim open the GetFile URL, running script in the webmail origin.
GetFile now always sends `application/octet-stream` with
`Content-Disposition: attachment`, `nosniff` and a sandboxing CSP, and
refuses browser loads (any `Sec-Fetch-Dest` other than `empty`). The
editor fetches it server-to-server, so it is unaffected.
A MAPI property in a TNEF attachment declares how many values follow.
The parser looped that many times even when a value could not be read
and nothing was consumed, so a 31-byte winmail.dat claiming 0xFFFFFFFF
values froze the tab for about 13 seconds on every open, longer with
more such properties. The count is now clamped to what the block can
hold and the loop stops as soon as a value consumes no bytes.
The Authentication-Results parser matched `dmarc=` and `dkim=` anywhere
in all headers joined together. A sender who used `dmarc=pass@...` as
the envelope address got a green DMARC pass for a message that failed,
because the server echoes that address in the SPF clause before its
real `dmarc=fail`. A sender could also add their own
Authentication-Results header to an unsigned message and get a DKIM and
DMARC pass. Calendar invites from such a message were shown as trusted.
The parser now splits each header on `;` outside quoted strings and
comments and reads the method only at the start of a part. DKIM, DMARC
and iprev come from the topmost header, which the receiving server
wrote; lower headers can only escalate SPF to a failure.
The origin gate in the proxy only covered `/api/auth/*`. The JMAP
passthrough, `/api/settings`, `/api/wopi/launch`,
`/api/plugin-approval-status`, `/api/fetch-ical`, `/api/translate`,
`/api/caldav/discover`, `/api/calendar-agenda` and `/api/webdav` all act
with the session cookie and had no origin check. A page on a same-site
sibling origin receives SameSite=Lax cookies, so it could run arbitrary
JMAP as the signed-in user, including sharing mailboxes to a third
account.
The proxy now gates every `/api/` route except the WOPI file endpoints
the office editor calls server-to-server, and each of these handlers
checks again. The proxy also decides on the decoded path, so an escaped
spelling such as `/api/%61uth/session` no longer skips the gate.
An impersonation handoff sealed `<target>%<master>` and the master
password into the session cookie, and `PUT /api/auth/session` hands that
cookie's contents to the browser on every restore. Anyone who received
a handoff could read a credential that signs in as every mailbox and
into Stalwart administration, with no expiry and no audit trail.
The impersonate route now uses the master credential server-side, once,
to create an app password on the target mailbox that expires after
8 hours. Only that app password reaches the cookies. Signing out
revokes it through a sealed grant cookie, and PUT refuses any session
that still holds the configured master password, so cookies minted
before this change are dropped instead of returned.
The URL guard behind fetch-ical, OAuth discovery, branding and the
server-side JMAP fetches blocked only the single address 127.0.0.1 plus
the RFC 1918, link-local and ULA subnets. Any logged-in user could make
the server fetch 127.0.0.2, 100.64.0.0/10, 198.18.0.0/15, multicast,
reserved space or a NAT64/6to4/Teredo address that wraps an internal
IPv4 address.
The telemetry guard already rejected all of these (GHSA-m7j8), so its
range check moves to `lib/security/ip-ranges.ts` and both guards use it.
`guardedLookup` inherits the wider list, so the connect-time rebinding
check covers the new ranges too.
In a unified view, a tag view or an "All folders" search, a message can
be selected inside an expanded thread without being a row of the list.
The batch actions only looked at the rows:
- "Delete" and "Mark as read" sent such a message's id without its
account, so the write went to the account the list is reached
through, where the same id can name another message: an unrelated own
message could end up in Deleted Items. They now write it in its own
login and account, as they do the rows, and a message marked read
shows as read in its thread.
- "Archive" and "Move to" skipped it without a word. It is archived or
moved with the rest now.
- A selected id that is neither a row nor a message of an expanded
thread is left alone, stays selected, and the error says so. The
messages of a thread collapsed again no longer count, for "Spam" and
"Not spam" either.
A mail hit of the active login opened from search in the Pro shell was
read by its id through the mail list, which may show another login, or
another account's message with the same id. It is read through the
hit's login and account now, as other logins' hits already were.
Two faults in the new series "This and following events" makes, found
on 0.16.23 (1.0 does not show them):
- The new series named its organizer only when the editor wrote one,
which it does only for an event that had none. Created without it,
0.16.23 makes the user who creates the event its organizer, whatever
the owner participant says: a sharee who split the owner's meeting
became the organizer of the rest (live: the sharee's address,
isOrigin false), and a series organized under an alias would have
moved to the user's main address. 1.0 takes the owner participant.
The new series now carries the series' organizer address whenever it
has participants.
- The overrides the new series takes over kept the old series'
SEQUENCE while the new series starts at its own, and 0.16.23 listed
each such occurrence twice, as changed and as the series has it
(live). The overrides now leave the old number behind.
Two leftovers of 9f7c12ab and 021a6732:
- A sharee who attends the owner's meeting and moves it in the owner's
calendar was asked again along with everyone else. Stalwart 0.16
counts the addresses of the user who writes as the account's own and
sends them nothing, so her answer stayed needs-action in the owner's
copy with nothing asking her to answer again, and her own copy kept
the old time (live on 0.16.23; 1.0 sends her the change). A move, a
drag or a new rule now keeps the answer of the user who makes it, in
the series and in the overrides it rewrites; the other attendees are
asked again as before. The rest of a split series still asks
everyone, the user too: 1.0 invites to a new event only who has not
answered.
- The SEQUENCE a change of every occurrence raises came from the series
as the app held it. A series opened from a link while another client
changed it gave a lower number than the attendees had, which 0.16
stores and the attendees refuse, and a pointer into an override
removed meanwhile failed the whole save. The store now reads the
series right before that write, through the account the write goes
to, and the raise starts from what the server holds: the series'
SEQUENCE, the overrides it has now, and for an override the change
rewrites at least one above the series. 1.0 raises the numbers the
same way itself, so the result there is unchanged (live).
Two faults in how a series' changed and cancelled occurrences follow a
change of every occurrence, both from e6b8c4ed (live on 1.0 and
0.16.23, organizer and attendee copies):
- A rule that names its days - Mondays and Tuesdays, weekdays, the 5th
of the month - keeps its occurrences on those days when the series'
start moves. Moving every occurrence by a day moved each override
along anyway, so the change of Monday 14 December landed on the key
of Tuesday 15 December and one silently overwrote the other: the
cancelled Tuesday came back with Monday's title, and Monday lost its
change. Under such a rule every override but the first occurrence's
now stays on its day, at the series' new time, or is dropped when the
series no longer has that occurrence; the time 0.16 keeps in an
override stays with it. The rest of a series split "this and
following" keeps them the same way. Two overrides that still meet on
one occurrence - the first occurrence's, moved onto the Tuesday after
it when the series starts a day later - no longer overwrite each
other: the one that stays where it is, or on its day, keeps the
occurrence, and the other is left out and logged.
- "Repeat: none" for every occurrence kept the override of the first
occurrence on the single event that was left, because a series'
start counts as an occurrence of any rule. Cancelled, the event was
gone from every range on 1.0 (still stored); retitled, 0.16.23 listed
it twice. A series made single keeps no overrides, as before
e6b8c4ed.
When the server refuses the replacement of a "Send now" or reschedule a
recipient the original was taken for, the replacement is cancelled and
the original kept at its old time. If that cancel went through but its
answer was lost, the reschedule went ahead and cancelled the original
as well: nobody got the message, while the toast said the others
would. After a failed cancel the replacement's `undoStatus` is read
back now, and when it reads `canceled` the original stays. Checked
live on 0.16.23 and 1.0: a cancelled held submission reads back as
`canceled` on both.
Follow-up to 0e0d99e7, which sends the batch actions of a unified view,
a tag view or an "All folders" search to each message's own login and
account.
- Batch Archive in a tag view or the default "All folders" search
failed with "archive mailbox not found", and "Not spam" in a tag view
entered from Junk with "Inbox not found": the folder lists came from
a cache only the unified views fill. In the Pro shell the list cached
under the bare account id could be another server's, so a browsed
login's messages were archived or deleted into whatever folder has
the other server's Archive or Deleted Items id. A batch's Archive,
Inbox and Deleted Items now come from the list cached for its login
and account, else from that login's own folder list, never from the
bare account id.
- "Move to" in a list that mixes accounts looked the picked folder up
in the login browsed before. In the Pro shell, after browsing a
folder of another login, the active login's mail went to that other
server. The menus list the active login's folders, so the folder is
looked up there now.
- Dragging one row moved every listed row with its id, including
another account's unrelated message. The drop hands the store the
dragged rows now.
- "Spam" and "Not spam" on a selected id that is not a row of the list,
such as a member of an expanded thread, wrote to the account the list
is reached through, where the id can name another message. Such ids
are looked up among the expanded threads now, and one found nowhere
is reported and left alone.
- A move sent in batches (500 messages each by default) stopped at a
refused batch while the list counted the unsent ones as moved. The
other batches go out, and only what the server left alone stays
listed.
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.
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).
Edits of a recurring meeting from one of its occurrences went wrong in
several ways (live on 1.0 and 0.16.23, attendees included):
- A "this and following" split whose new series could not be created
stayed half done: the calendar store answers most failures with null,
and only a thrown error put the old series back. The series stayed
ended, told so to the attendees, and the save said it had worked. A
null now puts the series back as well and fails the save. The rollback
writes the old overrides back as a whole map: a pointer into a series
without overrides fails, and the split may just have removed its only
one.
- "This occurrence" on the series itself (a meeting opened from a link
or a search result) went out in one write that reached no attendee
who had answered. It now takes the same two steps as an occurrence of
an expanded series: the override as it is without messages, then the
change of it.
- The new series of a split started at the opened occurrence's own time
and in the user's zone: Mondays in New York, one occurrence moved to
Tuesday, renamed from there by a user in Berlin, became a Tuesday
series in Berlin. It now goes on from that occurrence's recurrence id,
in the series' zone, moved only by what the editor moved; the
occurrence keeps its own time through its override. It takes the old
series' details with what the user changed, not the opened
occurrence's own ones.
- "All events" from an occurrence with an override of its own wrote that
occurrence's own title, copied into its override, back to the series:
a new room there renamed the series. Only the fields the user changed
go to the series now; the opened occurrence's override takes them too
(on 1.0 also where it holds them as pointers), and other overrides
where they only held the series' old value.
- The move of an "all" edit was measured on the user's clock: across a
week in which only one of the two zones changes to or from summer
time, the series moved an hour more or less than picked. It is now
measured on the series' own clock.
- 0.16 (0.16.23 checked) reads a rule's UNTIL as the series' wall clock
taken for UTC, so in a zone behind UTC a split or "delete this and
following" kept the occurrence it was to end before. The UNTIL is now
earlier by the zone's offset, which ends the series there on 0.16 and
1.0 alike.
- An applied counter proposal for one occurrence of a recurring meeting
went to the first event with its uid. It now goes to the occurrence
the proposal names; one for a whole series of which only occurrences
are in view is not applied to any of them.
A series keeps its changed and cancelled occurrences as overrides keyed
by recurrence id, where the rule puts each occurrence. Two edits left
them behind (live on 1.0 and 0.16.23, attendees included):
- Moving every occurrence kept the overrides under the old ids. A weekly
series moved an hour got its cancelled 16 December back, at 11:00,
and showed its retitled 30 December twice, at 10:00 and at 11:00;
1.0 doubled it in the attendee's copy as well.
- "This and following events" ended the rule but not the overrides after
the split, which then showed on as occurrences of the old series next
to the new one, and the new series got the whole COUNT again: six
weekly occurrences split at the third ran to eight dates, 4 January
twice. Deleting "this and following" left the later overrides too.
Now:
- Moving the series moves each override with its occurrence, in the same
update: by as much as the series' start moved, onto the same day at the
new time when the series turns all-day or timed. An occurrence moved on
its own keeps its time, except the one the editor was opened on, which
takes the move made there; an override that only held the time its
occurrence had moves along, and so does a pinned length. When the rule
changes, overrides of occurrences the new rule no longer has are
dropped. When the attendees are asked again, so they are in the
overrides that move.
- The split removes the overrides from the split on together with the
rule change and gives them to the new series, re-keyed and with the new
series' participants: 0.16 keeps an override as an event of its own,
so one without them reached no attendee. The new series' COUNT is what
is left of the old one; an UNTIL stays, and a rule the user changed in
the editor is taken as it is. A failed create puts the overrides back
with the rule.
- Delete "this and following" removes the overrides from there on.
Someone who writes to a calendar shared with them (read & write) changes
the owner's meeting there on the owner's behalf: Stalwart sends the change
from the addresses of the account the event is in. Since 6d4b6954 Bulwark
decided who organizes by the user's own addresses only, so it took the
sharee for an attendee (live on 1.0 and 0.16.23):
- a drag, a resize or a month-view drop wrote the new time without
scheduling messages: the attendees kept the old time and their answers
while the owner's copy moved; before 6d4b6954 they got "Updated
invitation"
- the editor wrote no participants, hid "Send invitations", showed the
attendees read-only and sent nothing
- "All events" asked nobody again, and "This and following events" ended
the series quietly and made the rest a new series without attendees
`userOrganizes` now also counts the organizer's own copy in another
account: `isOrigin`, which both versions compute from the addresses of
the account the event is in, true for the owner's meeting and every
expanded occurrence of it, false for an invitation the owner got. Such an
invitation is still changed without messages. The editor no longer drops
the user's own address from the attendees it opens with, only the
organizer's, so a sharee who attends the owner's meeting stays on it.
The demo's invitation from Alice now says it is not the demo user's copy.
"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.
A live Lite run on 1.0 and 0.16.23 found two faults with a series just
created:
- Its later occurrences did not appear until the page was reloaded. The
store added the series itself to the grid and did not load the range
again, and only the range load expands a series (on the server, or in
the browser). The store now reloads the visible range after creating
a series, and after any change to a series or one that makes an event
a series or a single event again.
- That one item was the series itself, and "This event only" on it
wrote the editor's data to the series: `CalendarEvent/set` on the
series' id with its title, start and rule, so every occurrence
changed. "Delete this event only" deleted the series. The series
itself now stands for its first occurrence: a change becomes an
override of that occurrence, and a delete excludes it. "This and
following events" from the first occurrence changes, or deletes, the
whole series instead of ending it before its first occurrence. No
single-occurrence path falls back to the series' own id any more; only
an occurrence the server keeps as an event of its own (an invitation
to that occurrence alone) is changed or deleted as itself, and a
series that cannot be found is refused with the usual error.
The delete scopes moved from calendar-app.tsx to
`lib/recurrence-scope-edit.ts`, next to the edit scopes, so they are
tested.
A move outside the editor kept every answer. A drag or resize in the
week and day views, a drop in the month view and an applied counter
proposal wrote the new time with the attendees' stored answers:
Stalwart's update then said "accepted" for a time nobody had accepted,
and a dragged occurrence, which gets its own override, reached no
attendee who had answered (live on 1.0 and 0.16.23). When the user
organizes the meeting these moves now ask the attendees again, as the
editor does; one helper (`userOrganizes`) decides for both.
Also:
- A changed recurrence rule counts as a move for the series: a custom
rule moved from Monday to Tuesday asks again. A rule the user did not
touch is no change however the server spells it, and on one
occurrence, where no rule applies, it is none.
- An event that lasts no time (a deadline) keeps its zero length on
save instead of becoming an hour long and asking everyone again.
- An attendee's save of their own copy - an alert on one occurrence, a
drag - wrote the participants again and sent the organizer a
"Reply:" mail. It now leaves the participants alone and sends
nothing. The editor shows someone else's attendee list read-only and
no "Send invitations" box, which only ever controlled the organizer's
messages; answers go out through the answer buttons.
- An existing override written on the base event (0.16 before 0.16.20,
and ranges the browser expands) gets the series' organizer again when
it has none, as every save wrote it before 4c20b09c: 0.16.19 refuses
an answer to such an occurrence. 0.16.23 takes the key and drops it;
1.0 refuses it inside an override once, and the store writes the
override again without it (both live).
- The comment on `organizerCalendarAddress` said Stalwart takes the
ORGANIZER solely from it. Both versions take it from the owner
participant when it is missing (live).
Stalwart sends a new event, and an occurrence that gets its own override
for the first time, only to attendees who have not answered (PARTSTAT
absent or NEEDS-ACTION); a change to an existing event or override goes
to everyone who has not declined. Since the editor keeps the attendees'
answers on a save (4c20b09c), two edits never reached the attendees who
had accepted (live on 1.0 and 0.16.23):
- "This and following events" made the rest of the series a new event
with the answers the old one had, so those attendees got nothing, and
it ended the old series without scheduling messages, so they kept
every occurrence of it. The new series now asks every attendee again,
and the old one is shortened with scheduling messages when the user
organizes it and sends invitations; a failed create puts it back the
same way. Deleting "this and following events" ends the series the
same way.
- The first change to one occurrence that keeps the answers - a new
title, another room - was sent only to attendees who had not
answered. When the user organizes the event, sends invitations and
an attendee has answered, the store now gives the occurrence its
override first, as it is and without messages, finds it again
(0.16.20 renumbers synthetic ids once an override exists) and then
writes the change with messages. The attendee gets the update and
keeps their answer. This works through synthetic ids (0.16.20 on,
1.0) and as an override on the base event (older 0.16, and ranges
expanded in the browser).
Saving "All events" from the editor opened on one occurrence wrote that
occurrence's data to the whole series. Renaming a weekly series from its
third occurrence moved the series' start to that day: the first two
occurrences were gone, a counted series ran two weeks longer, and
overrides before the new start were orphaned. The occurrence's
participants went to every occurrence as well, so an attendee who had
declined only that day now declined the series, and Stalwart sent them
no more updates (live on 1.0 and 0.16.23). The check for a move
compared the form with the occurrence, so the rename that moved the
series did not ask anyone again.
The series now keeps its own:
- when it happens, unless the occurrence was moved; then the series
moves by as much, from its own start and in its own time zone, and
the length changes only when the editor changed it
- its participants and their answers, with the attendees the user added
or removed in the editor; when the series moves, its organizer asks
the attendees again
The scope handling moved from calendar-app.tsx to
`lib/recurrence-scope-edit.ts` so it can be tested. The editor now hands
its attendees (as opened and as saved) and its dates to the save.
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
Four places took a narrow refusal from the server for a complete one:
- vCard export: Stalwart answers Blob/get only in accounts the user is
a member of, so for an address book shared with the user it came back
`forbidden` and the export fell back to one photo download after the
other. It checks the access to each blob by itself, whichever account
the request names, so `getBlobsAsBase64` now asks again in the user's
own account (checked live on 1.0 with a book shared by another user).
- Sending at a full submission quota: when making room found nothing,
no search ran for five minutes, for any send. Scheduled mail and
undo-send holds can go once their message is out, so a send the user
starts now always looks again; the pause stays for automatic read
receipts.
- Free/busy: a directory that could not be listed hid the attendees'
free/busy behind "Free/busy isn't available on this server". Listing
principals and looking up free/busy are separate permissions on 0.16
and 1.0, so the line now shows only for Stalwart 1.0 with directory
queries off or a lookup refused with `forbidden`; otherwise the
principals the server still returns are looked up.
- Mark as read and keyword writes over several requests: a request that
failed outright made the whole selection count as unchanged, and rows
already written showed unread until the next refresh. The write now
stops there and names only that batch and the ones after it.
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
A live install of the Stalwart bundle on 1.0 and 0.16.23 showed four
gaps in the README it carries:
- It didn't say where the web admin's update action is. It is under
**Actions > Update applications** on both versions.
- On a plain `http://` address other than `localhost` the browser has
no Web Crypto API, so the PKCE sign-in can't run and the webmail
falls back to a password session for the tab: "Remember me" does
nothing and single sign-on can't work. It now asks for HTTPS.
- The local build command left out `--in-place`, without which the
build refuses to run outside CI.
- The closing list named single sign-on as missing from this bundle,
although the README's own section above describes how it works here.
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.
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.
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.
Fixes from a second review of the Stalwart 1.0 calendar work.
- Changing the alerts of one occurrence of a series that uses the
calendar's default alerts said "Event updated" and kept the default
on 1.0: while a series uses them, 1.0 shows the defaults in place of
an occurrence's own alerts and refuses `useDefaultAlerts` on a single
occurrence. The series now stops using the defaults first, keeping
the alerts it shows under ids of their own, and then the occurrence's
alerts go out. 0.16.23 showed the edit already and still does.
- The participant limit counts the organizer entry a save adds (live on
1.0: 19 attendees and the organizer are accepted, 20 are refused), so
the editor warned one attendee late. It warns at the limit now, and
the warning and the refusal say the organizer counts, with plural
forms in every locale.
- Adding or answering an invitation from the mail view, and applying a
proposal, said "could not complete" when the server refused the
event: the import swallowed its SetError. The banner now asks the
import to reject with the refusal and names the participant limit
like the calendar does.
- A range load that finished after a later one could put back its own
events, range or failure. Only the latest load writes the store, and
a load for an account the user left is dropped.
- A search filter with an OR, a NOT or a condition of several keys next
to its date bounds goes out as it is instead of being expanded, which
1.0 refuses.
- A time zone setting typed in another letter case ("europe/berlin")
is used as Intl spells it. 1.0 refused it as a query argument, and
0.16 and 1.0 both dropped it from new events, which were stored
floating.
- A failed save no longer shows the store's English toast next to the
translated message.
- Tests for a limited read that asks later chunks of a request again
after a refused chunk or a full page.
Saving an event in the editor keeps the attendees' answers. When the
user organizes the event and the save changes when it happens, RFC
5546 has the organizer ask the attendees again, and Stalwart's update
to them carries each attendee's stored answer: the old answers would
have read as answers to the new time.
- the editor compares the event's start and end instants, and whether
it is all-day, with its form, to the minute the form shows; writing
the event in the user's time zone instead of its own is no change
- when the user organizes the event, under any of their addresses, and
it moves, every other attendee goes back to needs-action; the
organizer's own entry stays as it is
- every other save keeps the answers, and so does any save of an event
someone else organizes
- the answers are reset in what the save covers: one occurrence, this
and the following ones, or the whole series