Commit Graph
319 Commits
Author SHA1 Message Date
Shuki VakninandLinus Rath 53461d1142 feat(email-viewer): message spacing setting (auto/always/edge-to-edge) 2026-07-21 23:06:43 +02:00
Shuki VakninandLinus Rath 6dfcb07b9a feat(accounts): remove a specific account from the switcher
The switcher only offered 'sign out of active' and 'sign out of all' — no
way to drop a single non-active account (e.g. one stuck in an error state you
can't switch into to sign out). Add a hover × on non-active, non-default rows
and a removeAccount(id) auth action that tears down the client, drops it from
the registry, and clears its per-slot session/token cookies.

Stacks on the switcher redesign in #517.
2026-07-21 20:59:52 +02:00
Shuki VakninandLinus Rath f2703bcc27 fix: guard false-positive on basic-auth accounts (identity != login)
Accounts whose primary sending identity differs from their login (basic
auth registers accountId from the typed login; OAuth from the identity
email) were force-re-authed on switch because the guard derived the
connected id only from the primary-identity email. Collect every
server-confirmed identifier (JMAP Session.username + primary-identity
email) and only re-auth when the target matches none. Excludes the
constructor username so a real desync still trips. Adds
JMAPClient.getSessionUsername().
2026-07-21 20:58:47 +02:00
Shuki VakninandLinus Rath cda4dcbf01 fix(auth): guard account switch against slot→token desync
When switching accounts, the target client connects with the token at
the account's stored cookieSlot. If that slot→token mapping is ever
wrong — e.g. corrupted client state persisted by an older build, or any
future slot desync — the connection succeeds as a *different* account
and the UI silently shows the wrong mailbox.

Add a post-connect identity guard: derive the connected session's
accountId (primary-identity email for OAuth, else the JMAP session
username) and compare it to the account being switched to. On mismatch,
drop the poisoned slot cookies and force a clean re-auth instead of
binding the wrong session.

This is belt-and-suspenders on top of 8b164c5, which fixed the slot
allocation that caused such a desync: that prevents new corruption,
this catches any residual/leftover mapping at switch time.

Adds a unit test for the canonicalisation (email vs JMAP username), the
make-or-break detail that avoids OAuth false-positives.
2026-07-21 20:58:47 +02:00
Shuki VakninandLinus Rath f15edd336b feat(send): 'Send now' on the send-delay toast
The post-send undo toast ('scheduled to send' + Cancel send) now also offers a
'Send now' action that reschedules the delayed submission for immediate release,
so you can skip the undo window without waiting it out. Adds an optional
secondaryAction to the toast component and carries identityId on the pending
undo-send state so the reschedule can target the right identity.
2026-07-21 20:58:27 +02:00
Shuki VakninandLinus Rath 5716d91115 feat(folders): drag-and-drop reorder for all folders 2026-07-21 20:56:38 +02:00
KazNIISA ITandLinus Rath 88b07a1713 fix(email-store): route shared-folder batch actions to the owner account
Batch actions (delete, move, archive, mark-as-read) performed while
viewing a shared/group mailbox directly from the "Shared" sidebar section
were dispatched to the user's OWN account instead of the shared owner
account. Emails in that view are undecorated (no sourceAccountId, that is
only set in unified/cross-account views) and are reached through the
active client, so they fell into the '__default__' bucket / non-unified
else-branch, which defaults the JMAP accountId to the active account.
batchArchive independently picked the archive folder from the merged
mailbox list, where the user's own archive is listed first.

Stalwart then applies Email/set to the wrong account: because the ids
belong to the shared account it returns them as `updated: null` with an
unchanged state (a silent no-op, not `notUpdated`), so the UI drops the
rows optimistically and they reappear on the next reload. It only appears
to work when the own and shared folder ids happen to collide.

Add resolveViewAccountId() — the owner accountId of the directly-viewed
shared folder (from the selected namespaced mailbox), undefined for a
normal own-account view, mirroring fetchEmails and the single-email path.
Route the four batch actions to that owner account (via the active
client); batchMoveToMailbox also resolves the destination to its bare
originalId, and batchArchive scopes the archive folder to that account.
Own-account and unified/cross-account views are unchanged.

Adds email-store-shared-folder-actions.test.ts covering all four batch
actions in the non-unified shared view plus an own-account regression.
2026-07-21 20:55:26 +02:00
Linus Rath 739b72d251 Merge pull request #509 from hildebrandttk/feat/unified-mailbox-account-scope
Feat/unified mailbox account scope

Rework the sidebar "All accounts" into an account-bounded "Unified Mailbox"
by default, with cross-account merging as an opt-in (admin-gated) sub-option.
The standalone per-account "All Mail" virtual folder is folded into the unified
All mail / Unread / Starred entries.

Conflict resolution notes:
- stores/settings-store.ts: both main and this branch independently added a
  per-account default-identity (#507) migration at different versions (main v6,
  branch v7). Merged migration is version 7 using the refactored migrateSettings
  function; the unified-mailbox rework is guarded at `version < 7` so users who
  stopped at main's interim v6 identity bump still receive it, while the #507
  identity-map coercion stays at `version < 6` so their populated map is kept.
- stores/auth-store.ts: kept main's applyPreferredIdentity (superset with the
  pre-#507 legacy migration).
- stores/email-store.ts: removed the ALL_MAIL_MAILBOX_ID paths (folded into the
  unified views) while preserving main's plugin hooks (onSearchResults /
  onEmailsFetched); adopted advancedSearchCrossViewEmails for advanced cross-view
  search.
- components/settings/layout-settings.tsx: kept main's faviconUnreadBadge setting
  alongside the new unifiedCrossAccount toggle.
- integration/: union-merged the two independently-authored suites - branch suite
  is authoritative (matches new behavior) with main's shared-identity (#569) group
  infrastructure preserved.
- components/email/email-composer.tsx: dropped a duplicate data-testid attribute
  introduced by the auto-merge.
2026-07-16 19:57:51 +02:00
honzupandLinus Rath 334fdbfb86 feat: show unread count badge on favicon
Closes #560.

Composes the active inbox's unread count over the base favicon as an SVG
badge, served as a percent-encoded data: URL, so new mail is visible on a
tab that is not focused — including when the browser collapses tabs to
icon-only, where a title-based count disappears entirely.

The base icon is read from the rendered <link rel="icon"> rather than from
config, so admin and per-domain branding overrides are inherited for free:
the count is drawn on whatever logo the deployment actually serves. Keeping
the badge in SVG rather than rasterising to a canvas also means the browser
can rasterise it at whatever size it asks for, so a HiDPI tab is not served a
16px bitmap.

Notes on the approach:

- The badge link is an *additional* icon link that we append and mark as
  ours; we never remove or mutate a link we did not create. Next's metadata
  icons are rendered by React, which keeps a fiber pointing at that DOM node,
  so removing it would leave React holding a detached node and throw
  "Cannot read properties of null (reading 'removeChild')" on the next
  commit that deletes the fiber. Appending instead means the last-declared
  icon wins, and non-SVG fallback links survive with their type/sizes intact.
  (The usual recipe for this feature — assign canvas.toDataURL() to the
  existing link's href — does both of the things that break here.)

- Every change of state is an *insertion* of a fresh link of ours, never a
  mutation or a removal, because that is the only signal a browser reliably
  re-reads the favicon on. Firefox ignores an in-place href change, and it
  equally ignores a removal — so clearing the badge by deleting our link left
  a stale count painted on the tab until a hard reload. Clearing it instead
  inserts a new link of ours carrying the original base href.

- Holding last place has to be defended: on a client-side navigation React
  re-hoists its metadata icon link into <head>, landing after ours, and the
  base icon silently wins again. A MutationObserver on <head> moves our own
  link back to the end whenever a foreign icon link appears — moving only our
  node, never anyone else's. It no-ops once ours is last again, so a move
  cannot feed itself.

- The badge is a full-width band across the foot of the icon, drawn to the
  metrics measured from Gmail's own 16px favicon: band height 0.625 of the
  icon, digit cap height 0.44, flush to the edges, corners rounded by about a
  pixel. Full width is what keeps a three-glyph label legible — rounded ends
  waste exactly the horizontal space it needs. Neutral white with black digits
  rather than the conventional red: faviconUrl is admin-overridable and
  Bulwark's own icon is rgb(219,45,84), so a red badge sat red-on-red.

- The base SVG may be admin-uploaded, and the branding route deliberately
  serves it under a sandboxing CSP because SVG can carry script. Re-emitting
  it as a same-origin data: URL would un-fence that, so script, foreignObject
  and every on* handler are stripped before serialising.

- Mounted in the root layout, not on the mail route: the badge belongs to the
  tab, so mounting it on the page would clear it on every hop to settings,
  calendar or contacts.
2026-07-16 18:00:41 +02:00
Stefan HildebrandtandLinus Rath 01e5cd69cf fix(identity): sync default sender identity per account (#507)
The default sender identity (`preferredPrimaryId`) lived only in the
browser-local `identity-storage` store and was never written to the synced
settings, so the choice was lost on clearing site data / switching browsers and
never appeared in exported settings.

Persist it in the synced settings store, keyed **per account**
(`preferredIdentityIds: Record<accountId, identityId>`), mirroring the existing
per-account `allMailFolderIds`. Per-account keying is required because JMAP
identity ids are account-scoped and would otherwise collide across accounts /
the unified mailbox.

This supersedes the earlier username-keyed fix that had landed on main: the
username-keyed map, `loadIdentities()` fallback write, and the
`applyPreferredIdentityOrdering` store action (plus its settings-store hook)
are removed so a single account-keyed mechanism remains.

- settings-store: `preferredIdentityIds` (accountId -> identityId) in state,
  defaults, export, import (non-record guard), rehydrate coercion, v6 migration.
- auth-store: `applyPreferredIdentity(accountId?)` reorders the active account's
  identities once synced settings load, and performs the one-time migration of
  the pre-#507 browser-local default into the synced map (keyed by accountId).
  Invoked from every `loadFromServer().finally()` (login / OAuth / SSO / switch
  / restore). `loadIdentities()` now only applies the local fallback ordering.
- identity-manager-modal: the star action writes the choice by `activeAccountId`.
- identity-store: `preferredPrimaryId` kept as a local (sync-off) fallback.
- tests: per-account independence, export/import round-trip, import guard, and
  applyPreferredIdentity reorder / active-account gating / local-default
  migration.
2026-07-13 21:21:34 +02:00
honzupandLinus Rath 9072bf8470 fix: keep sidebar tag counts in step with read/unread changes
Marking mail as read left the sidebar's tag unread counts untouched — the
folder counts cleared, but a tag went on showing "47 unread" in bold until
the page was reloaded.

tagCounts is fetched from the server (Email/query per $label keyword) rather
than derived from state, and no read/unread mutation refreshed or adjusted
it. The per-mailbox unreadEmails counters were kept current by a local delta;
tags simply had no equivalent.

Add applyTagCountReadDelta alongside the existing mailbox-counter helpers and
apply it wherever the affected emails are known locally: markAsRead,
batchMarkAsRead, and setEmailKeywordsLocal. Only a genuine $seen flip moves a
count, so re-marking a read email as read cannot drift it, and unread is
clamped at zero. A tag's total is never touched by a read-state change.

markMailboxAsRead is the exception and refetches instead: it is a server-side
bulk operation over an entire mailbox, so it also marks emails that were never
loaded into state.emails, and a local delta would leave the counts high.
2026-07-13 21:19:08 +02:00
Paulhenry SauxandLinus Rath 622adc34de feat: add onEmailsFetched and onSearchResults hook + new JMAP method getSomeEmails 2026-07-13 18:18:57 +02:00
Stefan Hildebrandt d3addf54b4 fix(rebase): reconcile viewer blob routing and dedupe settings after main rebase
Rebasing feat/unified-mailbox onto main hit deep, divergent conflicts in the
mail-view/settings area (main added its own All-Mail + RTL refactor + a
username-keyed #507 identity impl). Post-rebase reconciliation:

- re-apply the cross-account blob routing to the message viewer (inline images,
  drag-out, TNEF, embedded messages, thumbnails, bundle download) on main's
  restructured file — every fetch goes through blobClient/blobAccountId derived
  from the message's source account;
- drop the duplicate `preferredIdentityIds` declaration that both main
  (username-keyed) and the branch (accountId-keyed) introduced — the branch's
  account-scoped map is kept, matching the resolved modal/store logic.

tsc + eslint clean; unified-mailbox unit tests pass (settings-store all-mail /
preferred-identity, unified-mailbox-cross, jmap-client-resilience, migrate-policy).
2026-07-11 21:16:02 +02:00
Stefan Hildebrandt 034f7a4b9b fix(identity): sync default sender identity per account (#507)
The default sender identity (`preferredPrimaryId`) lived only in the
browser-local `identity-storage` Zustand store and was never written to
the server-side synced settings. As a result the choice was lost when
clearing site data or switching browsers, and never appeared in the
exported settings JSON.

Persist the default identity in the synced settings store, keyed per
account (`preferredIdentityIds: Record<accountId, identityId>`), mirroring
the existing per-account `allMailFolderIds`. Per-account keying is required
because JMAP identity ids are account-scoped and would otherwise collide
across accounts / the unified mailbox.

- settings-store: add `preferredIdentityIds` to state, defaults, export
  (so it shows in exported JSON), import (with a non-record guard),
  rehydrate coercion, and a v6->v7 migration.
- auth-store: add `applyPreferredIdentity()`, invoked in every
  `loadFromServer().finally()` (login / OAuth / SSO / switch / restore) so
  the synced default reorders the active account's identities once server
  settings load (the composer defaults From to identities[0]).
- identity-manager-modal: the star action also writes the choice to the
  synced per-account map, triggering server sync + export inclusion.
- identity-store: keep `preferredPrimaryId` in local persist as a sync-off
  fallback; synced settings are the durable cross-device source of truth.
- tests: per-account independence, export/import round-trip, non-record
  import guard, and v6->v7 migration.
2026-07-11 21:14:56 +02:00
Stefan Hildebrandt 2e42693228 fix(unified-mailbox): single-source unified counters, unified id space, background push
The unified-section sidebar badges (per-role unified folders + cross-view
All mail/Unread/Starred) failed to count down when messages were deleted/moved/
read from the unified views, and failed to count up for incoming mail - while
the underlying per-account folder counters updated correctly. Root cause: the
badges were a separate counter representation, recomputed only by a fresh server
fetch, completely decoupled from the optimistically-patched mailbox lists.

Three coordinated changes:

V1 - single source of truth: derive `unifiedCounts`/`crossUnreadCount` as a pure
live projection of `mailboxes` + `accountMailboxes` (the lists every mutation
already patches and push refreshes), over the last-known unified scope. A store
subscription re-projects whenever those lists change, so optimistic deletes and
push refreshes flow into the badges with no server round trip and no
eventual-consistency snap-back.

V3 - unified id space: searchEmails/advancedSearchEmails now namespace shared/
delegated mailboxIds (`${ownerId}:${id}`) like getEmails already did. The
cross-account views browse via advancedSearchEmails, so shared emails there
previously carried bare owner ids; now every fetch path is consistent and
emailInMailbox hits the `ids[mailbox.id]` fast path (originalId branches kept as
a defensive fallback). resolveSourceFolderName matches `m.id` first (also fixes
a latent missing source-folder name for shared emails).

Background push: bind push notifications for every connected login, not just the
active one - background accounts now drive the unified counters by rebuilding the
unified scope on their state changes. handleStateChange also refreshes the
mailbox list on a Mailbox change for ANY changed account key, so delegated
shared-folder activity arriving via the active client updates counters too.

Tests: unified-badge live projection on delete; client-level namespacing for
searchEmails/advancedSearchEmails (shared vs own account).
2026-07-11 21:14:50 +02:00
Stefan Hildebrandt fdad60cf03 fix(unified-mailbox): update shared/group folder counters on delete/move/read
In the unified All mail / Unread / Starred views, deleting (or moving /
marking read) a message from a shared/group folder left that folder's
sidebar counter at its old value.

Root cause: lib/unified-mailbox.ts decorates shared emails WITHOUT
namespacing their `mailboxIds`, so they carry the owner's bare JMAP ids,
while the shared mailbox is stored with a namespaced id (`${ownerId}:${origId}`)
and `isShared: true`. `emailInMailbox` only matched the namespaced `mailbox.id`
and disabled the `originalId` fallback for shared mailboxes, so no shared
email ever matched its folder and the counter math skipped it.

Match shared mailboxes via `originalId` too, scoped to the owning account
(`sourceAccountId === mailbox.accountId`) so a bare owner id can't collide
with another account's folder. This is the single matching helper used by all
counter paths (delete/move/markRead/spam), so they're all fixed at once.

Adds a regression test covering deletion of a shared-folder email in the
unified view.
2026-07-11 21:14:44 +02:00
Stefan Hildebrandt dc72122ed8 feat(unified-mailbox): enable search in the unified views
Enable text AND advanced search in all Unified Mailbox views (the per-role
mailboxes and the folder-selected All mail / Unread / Starred cross views). The
search input was hard-disabled for every unified view; the store fan-out already
supported text search.

- page.tsx: the search text input and the advanced-filter toggle are enabled for
  all unified views (only the scheduled view stays disabled). Clear-search also
  restores a cross view (not just per-role).
- Advanced filters now apply in cross views too: new advancedSearchCrossViewEmails
  ANDs the advanced filter (text + field conditions from buildJMAPFilter, built
  without an inMailbox clause) onto the cross-view membership. Per-role unified
  views keep using advancedSearchUnifiedEmails. Both honor the filter on the first
  page, on load-more, and on the folder-switch re-run. Fixes: an active Starred
  filter not applying after switching into a cross view, and the Unread filter in
  the Unread view returning nothing.
- Search persistence on folder switch: an active search is kept and re-run in the
  target view, preserving advanced filters. handleMailboxSelect picks
  advancedSearch when filters are set (normal, per-role unified, and cross views,
  after setting the unified state), text searchEmails when only a query is set,
  and browses otherwise. The scheduled view is the only view that resets the
  search on enter (unavailable there; setScheduledView clears searchQuery +
  searchFilters).

Account scope is intentionally left unrestricted in search (it already fanned out
across all accounts); the per-view folder selection still applies via
crossIncludedMailboxIds.
2026-07-11 21:14:39 +02:00
Stefan Hildebrandt 7c221c4a4a feat(unified-mailbox): account-bounded Unified Mailbox with opt-in cross-account
Rework the sidebar "All accounts" section into a "Unified Mailbox" that, by
default, stays within the active login account and its shared/group folders.
Merging across multiple logged-in accounts becomes an opt-in sub-option instead
of the default, and the standalone per-account "All Mail" virtual folder is
folded into the unified All mail / Unread / Starred entries (its folder selection
now narrows those lists).

Scope:
- lib/unified-mailbox.ts: UnifiedAccountClient.crossIncludedMailboxIds; the cross
  views honor the per-account folder selection (union across accounts = the sum
  of each account's selection), falling back to inbox+custom when unset.
- stores/email-store.ts: buildUnifiedAccountClients gains scopeToClientAccountId
  (the account boundary) and populates crossIncludedMailboxIds from
  allMailFolderIds; remove the standalone __all_mail__ fetch/search/load-more
  branches.
- page.tsx: scope to the active account unless cross-account is active (per-user
  opt-in AND admin gate); the per-role unified mailboxes obey the same scope.

Folding:
- Drop ALL_MAIL_MAILBOX_ID (lib/jmap/types.ts); thread-list source-folder column
  now keys on isUnifiedView only; settings folder picker moves under the unified
  group and shows once any unified entry is enabled.

Config:
- User: new unifiedCrossAccount (default false); includeGroupInUnified default
  flips to true; enableAllMailView retired; the three cross-view toggles now gate
  the unified Unread/Starred/All mail entries.
- Admin: new unifiedCrossAccountEnabled gate, default FALSE (cross-account is an
  admin opt-in; when off the per-user toggle is hidden and the scope is forced
  account-bounded at runtime). allMailViewEnabled deprecated and normalized
  forward into crossAllViewEnabled on policy load; cross-view gate labels reworded
  to "Unified Mailbox: ...".

Header: the sidebar section shows "All accounts" when cross-account is active
(opt-in AND admin gate AND >1 connected account), else "Unified Mailbox".

Migration:
- Settings persist v5 -> v6 (exported migrateSettings) - cross-active users keep
  cross-account; All-Mail-only users get the account-bounded unified All mail
  entry with folder ids preserved; includeGroupInUnified enabled for every
  migrated config; fresh installs are account-bounded.
- Admin policy: one-shot, marker-guarded migratePolicyUnifiedMailbox (run before
  configManager.load) enables unifiedCrossAccountEnabled when a cross view was
  active, so existing cross-account installs keep the behaviour despite the
  default-false gate. Skipped on read-only config dirs.

Locales: sidebar all_accounts (original label) + unified_mailbox (translated, per
locale) keys; dead standalone all_mail strings removed across all 20 locales.

Docs: FEATURES.md updated to the account-bounded model, the cross-account gate,
and the folder-narrowed aggregate entries.

Verification: tsc clean, eslint clean, full vitest suite green (incl. translations
completeness, cross-view/migration coverage, and the admin policy migration test).
2026-07-11 21:14:34 +02:00
dealerwebandLinus Rath 38a396d150 Fix: end refresh loops on sign-out and back off failed retries
Fixes #588.

Sign-out already cleared the token-refresh timers and stopped the
keep-alive interval - the reported endless loops came from async
callbacks that were in flight at that moment. The token refresh's
failure handler re-armed its retry after logout, and a failing
keep-alive ping called reconnect() -> connect(), which restarts the
keep-alive and thereby revived the interval disconnect() had just
stopped. Only closing the tab ended it.

Two mechanisms fix that class: transiently failed token refreshes only
re-arm while the account is still signed in (checked when the failure
lands, not when the request started), and the client carries an
intentionallyDisconnected flag set by disconnect() - the ping callback,
reconnect(), the SSE reconnect scheduling and the polling fallback all
stop at it, so nothing revives after an intentional sign-out.

Failed retries also back off instead of hammering a down server every
30 seconds: the token refresh climbs 30s/1m/2m/5m (capped, reset on
success), and the keep-alive skips upcoming ticks on consecutive
failures for the same effective ladder. Recovery after an outage is
unchanged in substance - the session survives and reconnects within at
most ~5 minutes, immediately on user activity.
2026-07-10 14:13:04 +02:00
dealerwebandLinus Rath c6bd5f645a Feature: contact groups as single expandable recipient chips
Typing a contact group's name in a recipient field suggested the
individual members, and "send email to group" on the contacts page
filled the field with one chip per member - the group itself never
appeared anywhere.

The autocomplete now offers the group as a single entry (group icon
plus member count), and selecting it - like the contacts-page action -
inserts one chip named after the group that carries a snapshot of its
members. The chip expands into the deduplicated member addresses when
the message is sent or saved as a draft, mirroring how Outlook handles
distribution lists. Expansion happens where the outgoing address lists
are built, so validation and every plugin hook see real addresses.

Group chips survive the composer's string boundaries (draft data, dirty
compare, the contacts-page hand-off) as RFC 5322 group syntax
("Team: a@x, b@y;"). A bare colon reliably opens a group there because
display names containing a colon are always quoted. Typed text only
parses as a group when it carries at least one valid member, so stray
"Subject: hello" input stays a plain recipient.

RecipientSuggestion gains an optional group field; plugins that ignore
it keep working unchanged.
2026-07-09 17:46:06 +02:00
Linus Rath c47137fb49 feat: enable trusted-senders address book sync by default when contacts are available 2026-07-09 13:59:52 +02:00
Hamed FallahandGitHub e10fced28a feat: add Jalali (Persian/Shamsi) calendar support with Saturday as week start (#490)
* feat: add Jalali (Persian/Shamsi) calendar support with Saturday as week start

- Add jalaali-js library for Gregorian ↔ Jalali date conversion
- Create lib/jalali-utils.ts with Jalali calendar utilities
- Create hooks/use-calendar-locale.ts for unified calendar locale handling
- Expand FirstDayOfWeek type to include 6 (Saturday)
- Update all calendar views (month, week, day, mini, toolbar) to support
  Jalali calendar display and Saturday-first week ordering
- Add Jalali month names (Farvardin … Esfand) to all locale files
- Add Persian (fa) locale with full translations
- Update settings UI to include Saturday as first day of week option
- Update useFormatEventDate to show Jalali dates when locale is fa
- Auto-detect Jalali calendar when fa locale is active

The calendar system automatically switches to Jalali when the locale is
set to Persian (fa). All internal date handling remains Gregorian (ISO
8601) for JMAP protocol compatibility; Jalali conversion is purely at
the display layer.

* Add PR template for Jalali calendar feature

* chore: remove accidentally added PR template

* fix: add image_too_large key to fa locale for PR #462 compatibility
2026-07-08 15:42:42 +02:00
Shuki VakninandLinus Rath 22418c17cf feat(i18n): Hebrew locale + full RTL support 2026-07-08 00:07:34 +02:00
honzupandLinus Rath 7db6fd7e24 feat: add setting to disable tag-color row tint in message list
Message-list rows are tinted with the first tag's color, which becomes
overwhelming when a label applies to most messages (for example
per-account labels). Add a `tintListRowsByTag` setting (default true, so
current behavior is unchanged) with a toggle in Settings beside
"Colorful Sidebar Icons". When off, rows are not tinted; tag dots and
chips still show the color. Gated in both list renderers
(email-list-item and thread-list-item).
2026-07-07 23:39:05 +02:00
Linus Rath c3a97de62f fix: use calendarAddress/organizerCalendarAddress for scheduling, drop retired sendTo/replyTo #500 2026-07-07 23:18:05 +02:00
dealerwebandLinus Rath 9110bc388f Fix: keep the session when the auth server is briefly unreachable
Any transient failure used to end the session: the token route deleted
the refresh cookies on every non-OK answer from the OAuth endpoint,
refreshAccessToken logged out on any non-OK status or network error,
and the startup restore evicted the account and deleted its session
cookie. A server restart, a proxy hiccup, a Wi-Fi switch or a laptop
waking before the network is back all kicked the user out despite
"stay signed in".

Failures are now classified. Only a definitive rejection (400/401/403
from the OAuth endpoint, 401 from the token route) tears the session
down and deletes cookies, exactly as before. Network errors and 5xx
keep the session: the token refresh re-arms itself and retries every
~30 seconds until the server is back, and the startup restore keeps
the account, marked unreachable - the same treatment the rate-limit
carve-out (#104) already applies.

Token validity stays entirely server-enforced: the first definitive
401 after an outage still logs out as before.
2026-07-06 15:52:05 +02:00
dealerwebandLinus Rath d384c3b553 Feature: pin emails to the top of the folder list
Outlook-Web-style pinning: a context-menu Pin/Unpin action stores a
$pinned keyword on the message (plain IMAP-compatible flag, survives
other clients), and pinned mails stay at the top of the folder list
regardless of age, marked with a pin icon.

Ordering is done server-side via the hasKeyword sort comparator
(RFC 8621), applied consistently to the folder fetch, pagination and
the push-refresh so page windows stay stable. The client-side safety
sort in getEmails mirrors it, and sortThreadGroups keeps threads
containing a pinned mail on top so the client-side thread grouping
does not undo the order.

The new-mail notification in refreshCurrentMailbox now checks the
first non-pinned entry: with pinned mails on top, the newest mail is
no longer at index 0 and arrivals would never have notified.

The toggle reuses the color-tag pathway (routed keyword write for
unified views, in-place local patch), then refetches the first page
so the mail floats or sinks immediately. Search and unified views
keep their existing order.

Pin/Unpin strings are added to all 21 locales.
2026-07-06 13:36:25 +02:00
dealerwebandLinus Rath 06ddda688d Fix: spam actions left folder counters and the open message stale
Marking mail as spam (or not spam) updated the email list but nothing
else, unlike delete/move which patch the folder counters optimistically.
Without a connected JMAP push the sidebar badges simply never moved, and
"not spam" additionally left the reading pane stuck on the message that
had just left the folder.

markAsSpam now mirrors moveToMailbox: source folder counts down, junk
counts up (honoring that trash-and-read delivers the mail to junk
already read). batchMarkAsSpam and batchUndoSpam mirror the batch move
pattern the same way.

undoSpam advances the selection to the next message like markAsSpam
already did, and refreshes the mailbox list instead of patching counts:
in the undo-toast path the email is no longer in the list, so its unread
state is unknown and an optimistic patch is not possible.
2026-07-06 11:04:20 +02:00
dealerwebandLinus Rath e6aa79ed94 Feature: recipient autocomplete from Sent, with on-demand server search
Compose recipient fields only suggested existing contacts and directory
users, so people you had emailed before but never saved as a contact
never came up. This adds an Outlook-Web-style suggestion flow.

On startup the Sent folder is read once (metadata only) to build a cache
of addresses you have written to; those are merged into the autocomplete
after contacts and directory principals, deduped, contacts winning.

When the recipient is not in the cache, the dropdown offers a "search the
server" row that queries the Sent folder on demand. That lookup fetches
only the to/cc fields (no subject, body or attachments) and returns the
matching addresses, deduped.

New strings are added to all 20 locales.
2026-07-04 14:56:49 +02:00
honzupandLinus Rath 14c2807f0a feat: add user-selectable regional date format 2026-07-04 14:53:37 +02:00
Kristofer PettijohnandLinus Rath 2a41e73bf9 fix(pro): prompt to save or discard draft when closing compose tab via tab-bar X 2026-06-30 06:51:24 +02:00
Linus Rath e141abc849 feat: add option to hide total message count on folders (#498) 2026-06-28 20:31:35 +02:00
Linus Rath f90cd6abc4 fix: sync default identity (preferredPrimaryId) to server settings #507 2026-06-28 20:12:54 +02:00
Linus Rath 63e087f3ef fix: support MFA login via structured auth endpoint 2026-06-28 19:53:08 +02:00
Linus Rath f8b8e0b108 refactor: move S/MIME to generic crypto plugin hooks 2026-06-28 19:13:02 +02:00
Linus Rath 512adab7e3 feat: add privileged same-origin plugin tier + crypto API surface 2026-06-28 16:51:42 +02:00
Linus Rath de68d68fb7 feat: add "New address book" creation UI #415 2026-06-24 19:53:41 +02:00
Linus Rath 85fbab9eb4 fix: surface server errors on password change and TOTP toggle 2026-06-24 18:02:18 +02:00
Linus Rath cd247e9c47 Merge remote-tracking branch 'origin/main' into feat/all-mail-cross-account-views
# Conflicts:
#	stores/settings-store.ts
2026-06-24 16:05:00 +02:00
Stefan HildebrandtandLinus Rath 751f3c1685 feat: per-account All Mail folder selection
Replaces the global allMailFolderIds (string[] | null) with a per-account
Record<accountId, string[]>, so each account chooses which of its own folders
the "All Mail" view merges. A missing entry = "not configured" (defaults to
every no-role folder); an explicit [] = "no folders".

- settings-store: type/default -> Record (default {}); persist version 4 -> 5,
  migration drops the legacy global list (the active account isn't known at
  migrate time); onRehydrate + importSettings coerce/ignore any non-record
  (legacy global string[] | null) shape. isPlainRecord() guard.
- email-store.resolveAllMailJmapIds: reads the entry for the account the view is
  scoped to (viewingAccountId ?? activeAccountId); undefined -> all no-role,
  [] -> none.
- layout-settings: read/write the active account's entry; when more than one
  account is logged in, an italic hint names the account the selection applies
  to (settings.appearance.all_mail.account_hint, 19 locales; de/ro translated).
- Test: stores/__tests__/settings-store-all-mail.test.ts (per-account
  independence, explicit-empty vs not-configured, importSettings legacy guard).
2026-06-24 15:52:09 +02:00
Shuki VakninandLinus Rath acc61db6f2 feat(settings): make return-to-list-after-action configurable (default on)
Per review: gate the return-to-list behaviour behind a setting,
returnToListAfterAction, defaulting to true (the Gmail/Yahoo default). When off,
deleting the open message keeps the previous auto-advance-to-next behaviour.

Adds the setting to the store (persisted), a toggle under Reading settings, and
i18n keys across all locales (English; non-English need translation). The same
setting will govern mark-as-unread (#468).
2026-06-24 02:23:57 +02:00
Stefan Hildebrandt fa3c57467b fix: route all counter updates to the email's own account in aggregate views
Extend the counter-routing fix beyond markAsRead to every optimistic mailbox
counter update, so a different account's email never adjusts the active
account's folder counters (JMAP ids can collide across accounts).

- Add applyBatchMailboxCounterUpdate() + applyDeleteCounters() and apply the
  per-account routing to: deleteEmail (trash + permanent), moveToMailbox,
  moveEmailsToMailbox, batchMarkAsRead, batchDelete, and markThreadAsRead.
- markAsSpam/batchMarkAsSpam/batchMoveToMailbox don't touch counters (rely on
  refresh) and folder-level ops (rename/empty/markMailboxAsRead) are already
  account-scoped — left as-is.
- Test: batchMarkAsRead adjusts each account's counter in its own list.
2026-06-23 19:16:23 +02:00
Stefan Hildebrandt befee332d2 fix: route unread-counter update to the email's own account in aggregate views
In a cross-account view, marking a second account's email read/unread updated
the *active* account's folder counter instead of the email's. Two causes: the
optimistic counter update only touched `state.mailboxes` (the active account),
and JMAP mailbox ids can collide across accounts so the id match hit the wrong
folder.

Add applyMailboxCounterUpdate(): route the counter delta to the list that holds
the email's folders — the active account's `mailboxes` (incl. its shared
folders) for active-account/shared emails, otherwise that account's
`accountMailboxes[sourceClientAccountId]` entry. Use it in markAsRead.

Regression test: a 2nd-account email with a colliding inbox id decrements that
account's counter and leaves the active account's untouched.
2026-06-23 19:16:22 +02:00
Stefan Hildebrandt a29c33b50a feat: cross-account "All accounts" views + full group/shared-account support
Add cross-account aggregate mail views and make group/shared (delegated)
accounts first-class in every aggregate view. (The unified mailbox, the "All
Mail" view, and "include group inboxes" already exist on main; this branch adds
the cross-account views and the shared-account correctness work.)

New views (admin-gated + per-user toggle, nested under Unified Mailbox):
- Cross-account "All accounts": All unread / All starred / All mail across every
  connected account, including shared/group folders. Each list labels the source
  folder of every message.

Source reference on aggregated emails (the core of the shared-account work):
- Replace the overloaded `accountId` with two explicit, always-set fields:
  `sourceClientAccountId` (the login the mail is reachable through) and
  `sourceAccountId` (the owning JMAP account). `accountId` stays display-only.
- Resolution is branch-free everywhere: pick the client by sourceClientAccountId,
  pass sourceAccountId as the JMAP accountId (no-op for personal), read the
  owner's mailbox list cached by JMAP id. No capability scan.

Shared/group-account correctness across all aggregate views:
- Route open (click + auto-fetch), thread/conversation open + reply-refresh,
  mark read, star, move, delete (account-scoped trash), archive (owner-routed
  createMailbox / fetchAccountMailboxes), and spam + undo via the source ref.
- Add accountId params to toggleStar / batchMarkAsRead / batchDeleteEmails /
  createMailbox where missing.
- Fix local unread/total counter math for shared folders via emailInMailbox()
  (matches namespaced shared ids and bare own ids).
- Keep the unified/cross virtual selection on background mailbox refresh (no
  jump back to inbox after deleting in All Drafts/Junk).

Junk UX:
- In "All Junk" the spam action becomes "not spam" in the viewer, context menu,
  and list hover icons; undo routes shared mail back to its own inbox.

Admin:
- Policy gates crossUnread/Starred/AllViewEnabled, each noting the matching
  per-user toggle (allMailViewEnabled clarified too).

i18n / docs / tests:
- locales (19): cross-view labels + descriptions and hover not_spam, translated
  in all shipped languages.
- FEATURES.md + README.md document the new views and group-account support.
- Tests for shared-account routing (single + batch + undoSpam), decoration, and
  unified-selection preservation.
2026-06-23 19:16:22 +02:00
Max HaoandLinus Rath 6f615f4c32 fix: fix directory fetching display names. 2026-06-17 15:51:34 +02:00
Max HaoandLinus Rath fd700f412e fix: fix inconsistent behavior with threading email messages in the inbox/folders 2026-06-15 23:00:30 +02:00
Linus Rath c51c3655d5 feat: add "All Mail" view 2026-06-15 18:42:23 +02:00
Linus Rath 4c6c1aab60 feat: manage shared/group account settings from Accounts page 2026-06-14 17:05:29 +02:00
Linus Rath b32e102ff9 feat: include directory users in recipient autocomplete 2026-06-14 14:52:13 +02:00
Linus Rath 8df483d8c7 fix: don't send connected-account key as JMAP accountId when sharing files #408 2026-06-12 00:36:45 +02:00