Commit Graph
240 Commits
Author SHA1 Message Date
Linus Rath f7deb92d16 fix: explain a closed directory and refused free/busy in the right words
With directory queries off on Stalwart 1.0, the free/busy list under the
attendees disappeared without a word. The share dialog explained every
`forbidden` from Principal/query with the 1.0 text: "only your groups
and people who have shared with you are listed", and a hint at
Stalwart's sharing settings. On 0.16 that refusal only means the role
lacks the permission, Principal/get then lists everyone, and the setting
has no effect over JMAP.

- When the server refuses free/busy for the user (`mayGetAvailability`
  false on the user's own principal, or a lookup refused with
  `forbidden`), one muted line says "Free/busy isn't available on this
  server". The block stays empty only when the server offers no
  free/busy at all.
- The share dialog keeps the 1.0 wording for Stalwart 1.0's own refusal
  ("The administrator has disabled directory queries.") and otherwise
  says "Your server doesn't let you browse its user directory.", still
  listing whoever Principal/get returned.
2026-09-25 20:07:46 +02:00
Linus Rath 6bd55c9501 fix: export contact photos in batches on Stalwart 1.0
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.
2026-09-25 20:05:19 +02:00
Linus Rath 9041cbcd08 fix: keep iCal subscriptions on refresh, rename calendars of any colour
- send a calendar's colour only when the user changed it: Stalwart 1.0
  accepts only #rgb, #rrggbb or a CSS name, so renaming a calendar that
  another client coloured with an 8-digit hex (Apple Calendar) failed
- "Refresh cached data" kept nothing of the calendar store's persisted
  state, so it deleted the user's iCal subscriptions, which exist only
  there; keep them and drop the rest
- show a notice with a retry when the task list fails to load; it keeps
  the tasks it had
- treat a method-level error on creating a task as a failure with its
  JMAP type instead of "no id returned"
2026-09-25 19:41:00 +02:00
Linus Rath d7004b3851 fix: report refused tags and keep Stalwart 1.0's attachment keywords intact
Stalwart 1.0 puts `$hasattachment` or `$hasnoattachment` on every
message and derives `hasAttachment` from them alone. It also refuses
keywords over 128 bytes (0.16 cuts them to 128 characters) and more than
100 keywords on one message. The keyword writers ignored Email/set
`notUpdated`, so a refused tag showed until the next reload and then
vanished.

- `markAsRead`, `batchMarkAsRead`, `toggleStar`, `updateEmailKeywords`,
  `setKeyword`, `removeKeyword` and `batchUpdateKeywords` check the
  Email/set outcome, and the errors carry the JMAP type. Tagging from
  the list or the viewer shows "Tagging failed"; a refused category-tab
  move rejects the plugin's promise and skips the categorize hooks.
- Tag ids are capped at 121 characters (128 bytes with `$label:`).
- The attachment keywords are hidden in view source, the plugin
  projection and the `getKeywords` fallback labels, reserved for
  category tabs, and refused by the plugin keyword API. Email/copy and
  the cross-server import still carry them over.
- A tag comes off by leaving its key out of the full keyword map instead
  of being sent as false, which RFC 8621 does not allow;
  `updateEmailKeywords` also drops the false entries local updates leave
  behind (`$seen: false` after marking unread).
- The automatic mark-as-read no longer leaves an unhandled rejection
  when the server refuses it.
2026-09-25 19:24:30 +02:00
Linus Rath ffa909600c fix: keep web push on when someone shares a mailbox with you
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.
2026-09-25 17:59:17 +02:00
Linus Rath 6152e45b88 fix: explain sharing and free/busy when Stalwart 1.0 keeps its directory closed
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.
2026-09-25 17:45:22 +02:00
Linus Rath c23e306789 fix: show and keep contact photos on Stalwart 1.0
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.
2026-09-25 17:35:56 +02:00
Linus Rath e147835f0c refactor: carry the JMAP error type on thrown errors through one helper 2026-09-25 16:04:12 +02:00
Linus Rath 3823d2da17 fix: sign in and show addresses on internationalized domains #1100 2026-09-24 21:15:22 +02:00
Linus Rath f97218375e fix: end the identity provider session on sign-out #905 2026-09-23 20:44:46 +02:00
Linus Rath dc9d609c43 fix: re-authenticate against the account's own server when linking the mobile app 2026-09-23 18:35:15 +02:00
Linus Rath 8c746fb7af fix: keep inbox-only push working for accounts shared without an inbox #983 2026-09-23 05:56:28 +02:00
Aaron Guise 4078a7494a fix(notifications): don't let the inbox-only re-sync hang on "Working…" 🐛
The re-sync effect depended on pushStatus.kind, so its own
setPushStatus({ kind: 'busy' }) re-fired the effect, whose cleanup
cancelled the in-flight enableWebPush - the push row then sat on
"Working…" forever and never updated the filter. Depend only on the
setting flip; read client/relay/status live from the recreated closure.
2026-09-23 11:07:58 +12:00
Aaron Guise c89fdcbf0c feat(notifications): apply inbox-only push filter immediately on toggle ✨
Flipping the setting only wrote to the store; the server-side JMAP push
filter stayed stale until the next app launch ran resyncWebPush. When push
is already enabled on the device, re-run enableWebPush on change so the
toggle takes effect without a restart.
2026-09-23 11:07:58 +12:00
Aaron Guise 887b9e7f92 feat(notifications): add toggleable inbox-only push notifications ✨ 2026-09-23 11:07:58 +12:00
Linus Rath f3aec163c0 fix: warn before saving an auto-reply Stalwart would refuse as too long
Stalwart refuses a VacationResponse subject of 512 bytes or more and a
text or HTML body of 2048 bytes or more, answering only "Field could
not be set." The limit counts bytes, so 1030 "ä" already exceed it,
and the HTML from the rich-text editor reaches it quickly. The save
failed with a generic error.

On Stalwart the vacation settings now check the subject and the text
and HTML that would be sent, and name the limit instead of saving.
2026-09-22 21:06:56 +02:00
Linus Rath 91a1b1cb51 fix: let non-admin users see their name and change a TOTP password
Bulwark read the live display name (#900) and the account profile with
`x:Account/get`, which Stalwart only allows for admins. Every other
user got `forbidden`, so the settings page showed no name and the
identity kept the stale snapshot. The name now comes from
`x:AccountSettings/get`, the account's own settings object; aliases
and quota still use `x:Account/get` where it is permitted.

With TOTP on, Stalwart requires the current code for a password change
and for switching TOTP off ("Current OTP code is required"). Neither
dialog asked for it. Both now do, and the password change sends it as
the `otpAuth/otpCode` patch, because a whole `otpAuth` object would
reset `otpUrl` and turn TOTP off.

Saving the display name also ignored a `notUpdated` answer and showed
success.
2026-09-22 20:28:01 +02:00
Linus Rath dadfb08f9c fix: keep filter flags and folder targets when mail is moved
The Sieve generator wrote actions in the order they were picked in the
rule editor. `fileinto` stores the flags set at that moment, so "mark
read", "star" or "add label" placed after a move never reached the
moved message. Flag actions are now emitted first.

Moves and copies also targeted the folder by path only. Renaming the
folder silently sent the rule's mail to Inbox. Rules now store the
folder id and emit `fileinto :mailboxid "<id>" "<path>"` when the
server lists `mailboxid` (declaring `mailbox` too, which Stalwart
requires), with the path as the fallback. Saving a rule refreshes the
path from the id, so existing rules pick up the id on their next save.

Forward actions get a "Keep a copy" option (`redirect :copy`), and the
editor honours the server's `maxNumberRedirects` (1 on Stalwart):
forward is disabled once the limit is used up, with a hint when
existing rules already exceed it, because the server skips extra
redirects without telling anyone.
2026-09-22 20:09:19 +02:00
Linus Rath 198a3c0d61 fix: keep mail filters running while the auto-reply is on
Stalwart allows one active Sieve script and turns the auto-reply on by
activating its own `vacation` script. Turning the auto-reply on switched
every Bulwark filter off (including junk and folder rules), and saving
any filter switched the auto-reply off again.

When the vacation script takes over from an existing filters script,
the filters script is now re-activated with
`include :personal :optional "vacation"`, so the server-built reply
(with its date window and HTML body) runs from there. Turning the
auto-reply off drops the include again. A filters save while the
vacation script is active keeps it through the same include, and the
vacation settings read the include as "on", because
VacationResponse.isEnabled reports false in that state.

Clients that only look at VacationResponse (Stalwart's web UI, phones)
still show the auto-reply as off while the include is active.
2026-09-22 20:02:06 +02:00
Linus Rath ec67ab994b feat: add missing mail action toasts and show sonner toasts again 2026-09-21 20:52:02 +02:00
Linus Rath d7548a4bab refactor: migrate icon set from Lucide to Tabler Icons 2026-09-19 13:32:41 +02:00
Linus Rath 2d4a562432 fix: harden Bulwark Lite login, deep-link replay and settings gating 2026-09-18 22:16:57 +02:00
Linus Rath 5de98bc04b feat: add Bulwark Lite static export build 2026-09-17 23:54:33 +02:00
Linus Rath fb24ef5d24 feat: sort contacts by last name #963 2026-09-09 19:47:33 +02:00
Linus Rath 565d5c1095 feat: choose exact-address or same-domain matching for reply from received address #1000 2026-09-09 19:27:08 +02:00
Linus Rath 9796b0b14d fix: index newer settings and flat calendar toggles in the settings search 2026-09-09 18:40:15 +02:00
Linus Rath ae403b06af feat: setting to turn off free scrolling in the calendar 2026-09-09 18:27:01 +02:00
Linus Rath 8e4296ae5b fix: add the missing scheduled folder role label #495 2026-09-07 22:34:47 +02:00
Linus Rath f4d2fd950c fix: sort folders in the role assignment dropdown #984 2026-09-07 22:34:47 +02:00
Shuki Vaknin 12f01409a6 feat(search): add "clear search when switching folders" setting
Switching folders currently keeps an active search applied and re-runs
it in the newly selected folder — deliberate behavior from #553, where
users wanted the query to follow them across folders. Others expect the
opposite: clicking Inbox/Sent should drop the search and just browse the
folder.

Add an opt-in setting (Settings → email behavior) that reconciles both.
Default is off, so #553's search-follows-you behavior is unchanged for
everyone. When on, any folder click clears the text query and advanced
filters and browses the folder instead of re-running the search.

Applied uniformly across the four folder-entry paths in MailApp — normal
mailboxes, per-account (Pro sidebar) folders, unified views, and cross
views — via one helper so no path diverges.
2026-09-01 21:26:08 +03:00
Linus Rath 14cdb2f6ff feat: organize invitations as the default ParticipantIdentity 2026-08-29 18:07:47 +02:00
Linus Rath 6e5aca60a9 feat: share mail folders with other users via mail:share 2026-08-29 17:53:54 +02:00
Linus Rath 1807b2df6b feat: allow setting an address book as default (#924) 2026-08-29 17:15:50 +02:00
Linus Rath 4fe5701b3b feat: default sidebar apps for all users #931 2026-08-29 12:31:17 +02:00
Linus Rath 3ef2b45fb4 feat: configurable message-list ordering #718 2026-08-25 19:20:48 +02:00
Linus Rath 9f32fc879b feat: add custom time zone setting overriding browser detection #755 2026-08-25 19:18:16 +02:00
Linus Rath 46ccd2267d fix: pin supported-calendar-component-set on created calendars #760 2026-08-25 19:16:20 +02:00
Linus Rath f189e0bab6 fix: refresh account display name from the Stalwart principal on login/restore/switch #900
Stalwart seeds the default identity name from the principal "Full name" only
once, and addAccount never rewrote displayName after first login, so an admin
rename never showed up in Bulwark. Read the live name via x:Account/get
through the passthrough (falling back to the identity name elsewhere) and
prefer the synced registry name in account settings.
2026-08-25 18:45:44 +02:00
Linus Rath 93fd9068f7 Merge pull request #863 from shukiv/feat/mobile-swipe-actions
feat(mobile): swipe list rows left/right for quick actions
2026-08-20 21:59:15 +02:00
Linus Rath 65e70a8088 feat: address bar reflects the focused Pro tab + live deep-link delivery to mounted surfaces 2026-08-20 15:35:41 +02:00
Linus Rath 2d5748e6c9 fix: leaving the Pro interface returns to the surface in use and keeps the settings scroll position 2026-08-20 14:35:05 +02:00
Linus Rath 1c6cf60bbf fix: preserve settings scroll position when the Pro interface toggle remounts the settings surface 2026-08-20 14:29:16 +02:00
Linus Rath 54d7fb6fc9 fix: folder drag & drop can move folders into other parents #855 2026-08-20 14:21:41 +02:00
Shuki Vaknin 78c84f19a5 feat(mobile): swipe list rows left/right for quick actions
Adds Gmail-style horizontal swipe actions to message rows on touch
devices. Swiping a row past a threshold fires a configurable action and
snaps back; a coloured background with an icon is revealed under the
finger and grows when the release would commit.

- New `useSwipeActions` hook: claims a touch as a swipe only once the
  horizontal travel dominates (vertical stays native list scrolling via
  `touch-action: pan-y`, no preventDefault), resists past the commit
  distance, and suppresses the tap that would otherwise follow.
- Two settings (Settings -> reading/email behavior): "Swipe right
  action" (default Archive) and "Swipe left action" (default Delete),
  each one of none / archive / delete / toggle read / toggle star /
  spam. A direction set to an action the current folder does not expose
  simply does nothing.
- Composes with the existing long-press (context menu) handlers; desktop
  is unchanged (hover actions + right-click).
- Scoped to single-message rows; unit tests cover commit/short-drag/
  no-action-side/vertical-scroll/tap-suppression.
2026-08-19 02:18:01 +03:00
KazNIISA IT 5c484af1f7 fix(jmap): escape the JSON Pointer in keyword patches, and migrate a keyword honestly
A PatchObject key is a JSON Pointer (RFC 8620 section 5.3), so a slash
inside the member name it points at has to be escaped as `~1`. Keyword
patches were built as `keywords/${keyword}` verbatim, which is fine for
every flat keyword but wrong for a nested tag: `$label:work/clients`
became the pointer `keywords/$label:work/clients`, asking for the
`clients` member of the `$label:work` keyword rather than for the keyword
itself. The patch is not a boolean at that path, so the server rejects or
ignores it and the tag never lands.

Route the keyword patch sites - setKeyword, removeKeyword, migrateKeyword
and the category-tab batch patch - through `keywordPointer`, which escapes
per RFC 6901 the way the calendar store already does for participant ids.
Flat keywords are untouched, so `$seen`/`$flagged` and friends keep the
exact pointers they had. The dev JMAP mock reads the escaping back, or
nested tags would behave differently against the mock than against a real
server.

migrateKeyword also took any answer as success: a method-level error on
the query read as "no message carries this keyword", one on the write was
ignored outright, and a per-message refusal was invisible - so a rename
that moved nothing still reported a clean migration. It now answers with
{ migrated, refused }, because the work is batched and a failure partway
through cannot undo the batches already applied. A whole batch the server
refuses stops the rest, which it would refuse too; a single message it
refuses does not, the mail behind it being unaffected. A query error
throws, nothing having been written yet, and so does a run that migrated
nothing at all - the case where the caller must not rename a tag over
messages that all kept the old keyword.

The walk over tagged mail was not paginating safely either. It stopped at
the first page shorter than the limit it asked for, but RFC 8620 section
5.5 lets a server clamp that limit, so a clamping server left most of the
mail unmigrated. Positions are also only comparable within one queryState,
which the walk ignored: mail arriving mid-walk shifts every later page. It
now restarts when the state changes under it, and gives up - before any
write, so the caller can simply repeat - once a mailbox has moved under it
three times, rather than mixing ids from states that no longer agree. A
page carrying no id it has not already seen ends the walk, which keeps it
bounded against a server that answers every position with the same page.
2026-08-18 08:15:22 +05:00
Linus Rath b37313af95 fix: recreate the push subscription on re-register and add per-device revoke #841 2026-08-16 23:35:34 +02:00
Linus Rath 548200131b feat: pick push relay from an admin-defined list instead of a URL field 2026-08-16 23:13:36 +02:00
Linus Rath 5b8ddc352b feat: pick push relay from admin-defined list instead of a URL field 2026-08-16 23:04:22 +02:00
Linus Rath d8c9aaa66a feat: add setting to render plain-text emails in normal or monospace font #830 2026-08-16 21:54:52 +02:00
zisisvakras 07ce2d0139 fix: scope separate translation hooks instead of relative namespace paths
next-intl has no "../" relative-namespace escape. A key like
t('../context_menu.not_spam') is not resolved relative to anything -
it is concatenated literally into the lookup key and always misses,
throwing MISSING_MESSAGE. email-list.tsx, about-data-settings.tsx,
and account-settings.tsx all used this pattern to reach keys outside
their own translation namespace.

Fixed by calling useTranslations() again for each additional
namespace needed, which is the supported way to reach sibling
namespaces, instead of trying to walk out of the current one.

The translations test already verifies that every key referenced in
source resolves in the en locale, but its extractor skipped any key
starting with a dot, which is exactly the shape of the broken calls,
so this class of bug never failed CI. Since no legitimate key can
start with a dot, the exclusion is removed. With it gone the test
reports all thirteen call sites fixed here when run against the old
code, and passes with the fix, so a reintroduced relative path fails
CI with the offending file listed.
2026-08-12 17:53:35 +03:00