Commit Graph
386 Commits
Author SHA1 Message Date
Linus Rath 1807b2df6b feat: allow setting an address book as default (#924) 2026-08-29 17:15:50 +02:00
Linus Rath 39dcc6b219 fix: invalidate persisted update status after an upgrade 2026-08-29 12:46:39 +02:00
Linus Rath d800689924 feat: use Stalwart synthetic ids for recurring occurrences #140 2026-08-29 11:03:48 +02:00
Linus Rath a15af72296 fix: resolve shared-account identity and email on reschedule; isolate per-account scheduled-list failures 2026-08-25 20:26:48 +02:00
Linus Rath 1305d13a77 Merge pull request #874 from rotterp/fix/scheduled-send-shared-accounts
fix: route scheduled sends to the account that owns the submission
2026-08-25 20:23:58 +02:00
Linus Rath 99daa53c8c feat: search suggestions with recent searches and contact autocomplete #845 2026-08-25 20:09:18 +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 8cd5c00b06 fix: keep the folder tree when a refresh burst hits maxConcurrentRequests #780 2026-08-25 19:09:21 +02:00
Linus Rath a29cf27ff8 fix: route multi-account email lookups and invitation parsing by source account #847 2026-08-25 18:50:50 +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
texchi2andClaude Fable 5 5e0efff091 fix: toast store crashes on insecure origins (crypto.randomUUID undefined), breaking every post-action acknowledgement
addToast() minted toast ids with crypto.randomUUID(), which only exists in
secure contexts (https/localhost). On a plain-http LAN origin every
toast.success/toast.error threw a TypeError, so e.g. calendar event Save
crashed AFTER a successful CalendarEvent/set — before the dialog-close ran —
leaving the dialog open reporting failure while the server had the event;
retries created duplicates. Same class as the generateMessageId send bug.

Also guards the same-class contact uid fallback in lib/jmap/client.ts
(createContact). Both now use the guarded generateUUID() from lib/utils.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtFkcUA8VM2XAKZKzptpqy
2026-08-22 19:58:18 +08: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 3e5ed4f3c4 feat: folder tabs via drag & drop + sidebar in fullscreen email tabs 2026-08-20 15:06:45 +02:00
Linus Rath 54d7fb6fc9 fix: folder drag & drop can move folders into other parents #855 2026-08-20 14:21:41 +02:00
Patrick Rotter 4461935a3c fix: route scheduled sends to the account that owns the submission
A delayed send composed from a shared/group identity is created in the
*shared* JMAP account, because sendEmail() passes the target account to
getSubmissionAccountId(). Every read/write on the scheduled surface,
however, asked the primary submission account only:

    getScheduledEmails / cancelEmailSubmission / rescheduleEmailSubmission
        -> getSubmissionAccountId()   // no argument = primary account

So a message scheduled from a shared address is accepted and stays
pending on the server — it *will* be delivered at sendAt — while the UI
cannot list it, cancel it or reschedule it. Cancelling appears to work:
EmailSubmission/set against the primary account answers `notFound` in
notUpdated, which the client never surfaced, and the mail goes out
anyway. Silent unrecallable mail is worse than a failed send.

Stalwart advertises urn:ietf:params:jmap:submission (with FUTURERELEASE
and a non-zero maxDelayedSend) on group accounts as well as personal
ones, so scheduling from a shared address is legitimate.

Data layer:
- getSubmissionAccountIds() enumerates every submission-capable account;
  getScheduledEmails() queries each, tags every result with the account
  that owns it, and fetches each submission's Email from that same
  account (it lives beside the submission, not in the primary account).
  It also returns per-account totals computed over all pending
  submissions, so a badge stays correct past the first page.
- resolveSubmissionAccountId() locates a submission's owning account so
  cancel/reschedule (and the sendAt/envelope helpers) act on the right
  one. Callers that know the account pass it and skip the lookup.
- sendEmail() reports the submission's account in SendEmailResult, and
  the pending-undo record carries it, so the "Undo send" / "Send now"
  toast actions — which know only a submission id — address the right
  account directly rather than searching for it inside the undo window.
- The delayed-send gate considers any reachable submission account
  rather than only the primary one.

UI:
- Each shared account gets its own "Scheduled" sidebar row, scoped to
  that account; the own row keeps showing the combined view. The scope
  travels beside the virtual mailbox id, so the store's single
  __scheduled__ id is unchanged, and it also drives which row renders as
  selected and whether the scoped list has more to load.

Tested against a real Stalwart (integration/tests/11-shared-scheduled-send):
two server-truth tests pin the mechanism (the submission is invisible to
the primary account, and cancelling through it is a silent no-op) and two
UI tests cover the fix. All three UI-level assertions — the combined
list, the scoped row, and which row is selected — fail without this
change.
2026-08-20 11:23:23 +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 58ddd01cf7 Merge pull request #792 from dev-hive-kazniisa/fix/shared-mailbox-folder-management
fix(email-store): route shared-folder management to the owner account
2026-08-17 12:18:24 +02:00
KazNIISA IT ad0cc108b4 fix(email-store): scope the owner-client lookup to one server
Review follow-up on the shared-folder mutation routing.

The owner lookup replaced the resolved client with any connected client whose
JMAP account id matched. Account ids are opaque per-server values, and personal
mailboxes carry an accountId too, so this ran on every folder mutation and could
hand a mutation to a client of a different server that happens to issue the same
id - where the bare mailbox id may exist and belong to someone else. Keep the
resolved client when it already is the owner, and require a candidate to match
the server URL as well as the account id.

Recover the owner account and the bare mailbox id from the namespaced store id
when no mailbox object is available. Previously the raw `accountId:id` went to
the server as a mailbox id with the owner dropped, so the mutation targeted the
active personal account; in setMailboxRole that cleared the personal role before
failing.

Drop the shared account header context menu when the account carries no usable
id. buildMailboxTree groups shared mailboxes without an accountId under the
placeholder "unknown", which cannot be a mutation target.
2026-08-17 09:10:11 +05:00
Linus Rath c78b964c69 perf: halve time-to-mail-list (lazy locale catalogs, code-split viewer/composer, shorter auth waterfall, boot snapshot) 2026-08-17 01:50:10 +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 644a9a0793 fix: search folder filter defaults to all folders and persists #788 2026-08-16 22:32:14 +02:00
Linus Rath e44f35a203 fix: reuse cached access token on session restore #552 2026-08-16 22:20:07 +02:00
Linus Rath fc0ab57247 Merge pull request #835 from hildebrandttk/fix/hide-tasks-only-calendars
fix(calendar): hide tasks-only calendars from the event calendar UI
2026-08-16 21:59:36 +02:00
Linus Rath 034b28598f feat: render plain-text emails in the app font by default, with a monospace option #830 2026-08-16 21:55:31 +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
Linus Rath ffff165a4b Merge pull request #800 from rotterp/fix/shared-folder-account-context
fix: a directly-selected shared folder has no account context (tags, compose identity)
2026-08-16 21:27:18 +02:00
Stefan Hildebrandt 9c084d9d63 fix(calendar): hide tasks-only calendars from the event calendar UI
A calendar holding only tasks (VTODO, e.g. from tasks.org) appeared in the
event calendar UI as an empty calendar. Stalwart's Calendar/get exposes no
supported-component set, so tasks-only-ness is derived from the calendar's
objects (mirroring the existing event/task split): a calendar is tasks-only
iff it holds >=1 object and every object is a task.

- getTasksOnlyCalendarIds scans an account's objects (CalendarEvent/query +
  minimal /get), early-exits once every calendar has an event, only marks
  after a full scan, and fails open on the cap or any error.
- getAllCalendars/getCalendars annotate calendars with isTasksOnly.
- Such calendars stay in the store for the tasks view (task picker, task-list
  grouping) but are filtered from the event sidebar and default selection.

Verified against Stalwart v0.16.16: the tasks-only calendar is flagged, the
default (with an event) is not.

Closes #761.
2026-08-16 21:18:57 +02:00
Linus Rath 47cf0ed90d feat: rework pro split-screen shell, per-pane tab strips, drag & drop, pane-scoped overlays 2026-08-16 03:03:40 +02:00
Linus Rath 8e3b4e71a1 fix: open right conversations in shared mailbox #814 2026-08-16 02:19:04 +02:00
Linus Rath e6597542fd fix: include email templates in cross-device settings sync #825 2026-08-16 02:09:38 +02:00
Stefan Hildebrandt b63b227645 fix(email): route unified/cross virtual ids through the fan-out in fetchEmails (#791)
fetchEmails sent a unified/cross view's client-only virtual mailbox id
(__cross_all__ / __unified_*) straight to getEmails as `inMailbox`. The server
rejects it (Stalwart: "filter.inMailbox = '__cross_all__'") and the foreground
catch wiped the list to "no messages found" - notably after actions (mark read,
delete, move) in the aggregated views.

Mirror the loadMoreEmails / refreshCurrentMailbox guard: when the target id is
virtual, fan out via fetchCrossViewEmails / fetchUnifiedEmails (search-aware) at
page 0 instead of querying the virtual id. If the view state isn't resolved yet,
stop loading without sending the id or wiping the list. Real folders are
unaffected.

+5 unit tests (email-store-fetch-unified): cross/unified fan-out, explicit
virtual-id arg, unresolved-view no-op, and the real-folder path still using
getEmails. tsc + eslint clean; all store suites green (389).
2026-08-13 21:27:24 +02:00
Patrick RotterandClaude Opus 5 3560cf258b fix: route keyword writes to the selected shared account
Tags, pins and $answered/$forwarded/$mdnsent set on a message in a
shared/group mailbox opened directly from the "Shared" sidebar section
did not persist: they showed until the next reload and were then gone.

The call sites resolved the target account as
`isUnifiedView ? email.sourceAccountId : undefined`. Outside the unified
view that is always `undefined`, so the write went to the reaching
client's own account, which the server answers with `notUpdated` /
`updated: null` and no error - the same silent no-op that
`email-store-shared-folder-actions.test.ts` already covers for the batch
actions. Emails fetched from a shared folder this way are undecorated
(no `sourceAccountId`); the owner is only recoverable from the selected
mailbox.

`toggleStar` never had the bug because it routes through
`resolveEmailActionContext`, which resolves the owner from the email's
source in an aggregate view and from the selected mailbox otherwise.
That resolver was reimplemented inline at each keyword call site, and
the inline copies only ever covered the aggregate half - so starring a
shared message persisted while tagging the same message did not.

Move the two keyword writes into store actions built on that resolver
and route every call site through them, including the read-receipt and
Pro-compose paths that previously passed no accountId at all.

The store actions resolve only for an email the store actually knows.
A Pro tab fetches its own email and never enters `emails` or
`selectedEmail`; for those the selected mailbox says nothing about the
message's owner, so guessing from it would send an own-account write to
a shared account whenever a shared folder happened to be open. An
unknown email keeps the previous behaviour instead.

Behaviour on own-account and unified-view messages is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 10:42:06 +02:00
Linus Rath 70383dea18 Merge pull request #796 from lucas-gaitzsch/fix/scheduled-send-polish
fix: polish scheduled send follow-ups
2026-08-12 18:12:28 +02:00
Lucas Gaitzsch 17f18e378e fix: polish scheduled send follow-ups 2026-08-12 17:46:51 +02:00
Ctaoist f1fdb24c8c feat: emit mailbox refresh hook 2026-08-12 22:08:09 +08:00
KazNIISA IT c516ba98d0 fix(email-store): route shared-folder management to the owner account
Folder management performed on a shared/group account was sent to the
logged-in user's primary JMAP account instead of the mailbox owner's
account. Subfolder creation therefore failed with invalidProperties for
parentId, root folders could be created silently in the personal account,
and rename, delete, role, and reorder targeted the wrong mailbox tree.

Shared mailboxes use namespaced store ids to avoid cross-account
collisions, while Mailbox/set requires the owner's accountId and the bare
server mailbox id. Resolve that mutation context centrally: recover the
mailbox originalId and owner account, use a directly connected owner
client when one exists, otherwise reach delegated/group accounts through
the active client with an explicit accountId.

Thread the account target through create, update, and delete JMAP methods
and all folder-management actions. Scope role replacement to the same
owner account, and expose explicit root-folder creation from shared
account headers without making personal root creation depend on the
currently selected folder.

Add unit coverage for JMAP request scoping, shared store routing, role
isolation, direct-client handling, and reorder. Add a Stalwart integration
scenario that creates root and nested group folders, renames them, and
deletes them through the UI.
2026-08-12 18:11:43 +05:00
Linus Rath 5e73633a80 Merge pull request #777 from hildebrandttk/fix/consistent-cross-account-id-namespacing
Fix/consistent cross account id namespacing
2026-08-11 17:37:55 +02:00
Stefan Hildebrandt 0efdb95a27 fix(settings): merge per-account maps on server load (compose identity switch)
With multiple accounts and settings sync on, the composer's default sender
would switch depending on login order and get disturbed by logging a third
account off. Cause: the per-account maps preferredIdentityIds (#507) and
allMailFolderIds live in EVERY account's synced settings blob, and
loadFromServer(username, serverUrl) runs once per connected account calling
importSettings(), which REPLACED the whole map with that account's server copy.
So the last account to load clobbered the map (last-writer-wins by login
order), and a stale blob from a since-removed account could win too.

Fix: importSettings takes an optional { serverAccountId }; loadFromServer passes
generateAccountId(username, serverUrl). For the per-account maps, a server load
now merges only that account's OWN entry into the existing map instead of
replacing it - each account's server is authoritative only for itself, so other
accounts' entries survive. File imports (no serverAccountId) still replace
wholesale.

+ regression tests: a per-account load preserves other accounts' entries,
only trusts the loading account, file import still replaces, same for
allMailFolderIds. tsc + eslint clean.
2026-08-10 22:49:35 +02:00
Stefan Hildebrandt 583e20d9dc fix(calendar,contacts): namespace all accounts consistently on switch
Calendar/contact aggregation left the ACTIVE account's ids raw while
namespacing background accounts (<localAccountId>::id). So an entity's id
depended on which account was active; switching flipped the id form and
invalidated persisted references (selectedCalendarIds), broke isSubscription
matching, and destabilised selection - the account-switch bug users saw in
Calendar and Address Book (mail was fine: it keys per account + stable
virtual ids).

Invariant now: an entity is namespaced iff it carries a localAccountId;
originalId always holds the raw JMAP id. Consumers/mutations already resolve
the raw id via `originalId || stripLocalAccountPrefix`, so they are unchanged.

- prefix* functions (calendar + contacts) always namespace + set originalId;
  drop the isActiveAccount branch and the now-dead activeLocalAccountId params
  (callers in the hooks + calendar-app/contacts-app updated).
- mapServerEventToStoreEvent namespaces the event id with the matched calendar's
  localAccountId, so createEvent/updateEvent results are consistent.
- reconcileSelectedIds(): remap selectedCalendarIds by raw identity on every
  fetch, so a raw->namespaced transition (switch, just-created calendar, or the
  one-time migration from old persisted raw ids) keeps the selection instead of
  resetting to "all".
- isSubscriptionCalendar(): namespace-robust and collision-safe - resolve the
  calendar and match by raw id scoped to the owning JMAP accountId (Stalwart's
  raw ids like "b"/"c" collide across accounts).

+9 unit tests (selection stability across switch/create, subscription
robustness, no cross-account false positives). tsc + eslint clean; 368 store
tests green.
2026-08-10 22:45:21 +02:00
Stefan Hildebrandt 7a3a262c87 fix(email): keep just-read/unstarred mail in the open unread/starred view
In the unread ('unread') and starred ('starred') cross views, marking a mail
read or removing its star flips the very keyword the view filters on
(notKeyword:$seen / hasKeyword:$flagged). The push-triggered refresh re-runs
that filtered query, so the mail was absent from the fresh page and the merge
dropped it - the row vanished from under the cursor on the user's own action.

Keep such rows in place: only their status + the counters update (both already
applied by markAsRead/toggleStar/batchMarkAsRead), they stay visible until the
view is re-opened. A new retainedInViewIds set records the ids the user just
read/unstarred while in these views; refreshCurrentMailbox re-inserts those rows
at their prior position via mergeRetainedRows. The set is scoped to the two
keyword cross views, cleared on navigation (fetchEmails/fetchUnifiedEmails/
fetchCrossView/exitUnifiedView), and dropped per-id when the action is reversed
(mark unread / re-star). totalEmails counts the retained rows so hasMore stays
correct.

Genuinely removed rows (deleted/moved -> absent from the local list) are not
resurrected, and a change the user did NOT make in this view (e.g. read on
another device, retain set empty) still drops as before.

Tests: stores/__tests__/email-store-retained-in-view.test.ts - read keeps the
row (unread view), unstar keeps the row (starred view), reversing/ navigation
clear retention, external read still drops, 'all' view does not retain. The
row-keeping cases fail without the fix.
2026-08-09 22:17:50 +02:00
Stefan Hildebrandt 2a1585a4d3 fix(email): stop All-Mail views emptying on delete/star/read
refreshCurrentMailbox runs on every Email state push (delete, star, and
mark-read all raise one). It only handled real single mailboxes: in a
unified ("All Mail") or cross-account view, selectedMailbox is a virtual
id (e.g. __cross_all__ / __unified_inbox__) that no folder matches, so it
was sent to getEmails as an inMailbox filter, returned an empty page, and
the first-page merge wiped the whole aggregated list. The empty query was
previously masked by the length-based merge cutoff; af2b00dd (#592)
changed the cutoff to the page size and unmasked it.

Make refreshCurrentMailbox view-aware, mirroring loadMoreEmails /
performSearch: fan out page 0 via fetchCrossViewEmails / fetchUnifiedEmails
(search-aware) when in those views, then run the existing merge so loaded
pages are preserved. Surface cross-view fetch errors via unifiedErrors.

Test: email-store-refresh-unified asserts a push refresh in the cross-All
and unified-inbox views keeps the list and never queries the virtual id
(fails without the fix: the list wipes to []).
2026-08-09 22:17:42 +02:00
Paulhenry Saux a3c06e89f2 fix: per-account isolation encryptionAtRest 2026-08-08 11:04:54 +02:00
Linus Rath 905e6ed96f fix: split "Name <addr>" recipients into the JMAP name and email fields #672 2026-08-06 19:19:18 +02:00
Linus Rath 706c6c23fd fix: send via the identity's own account client so DKIM matches the From domain #461 2026-08-06 18:55:08 +02:00
Linus Rath 11b6bdd8a6 fix: sync PWA title bar colour with the active theme #671 2026-08-06 18:23:21 +02:00
Linus Rath 23c4ab87da feat: confirm sending without a subject instead of blocking it #684 2026-08-06 17:31:50 +02:00
Linus Rath e2da05c130 feat: deep links for mail, calendar, contacts, files, settings and improve screen reader support
#733 #722 #721  #720  #719
2026-08-06 16:47:20 +02:00