Commit Graph
1441 Commits
Author SHA1 Message Date
Paulhenry SauxandLinus Rath 432ba0516b fix: add bodyValues to onRenderEmailBody hook 2026-07-13 21:21:07 +02:00
Stefan HildebrandtandLinus Rath 37152504b4 feat(composer): drag-to-reorder To/Cc/Bcc recipient chips (#593)
Recipient chips could already be dragged between the To/Cc/Bcc fields, but a
drop always appended and same-field drops were a no-op, so recipients could not
be rearranged without deleting and re-adding them.

Add positional drag-and-drop: while dragging a chip, an insertion caret shows
the gap it would land in (based on which half of the hovered chip the pointer
is over, mirrored for RTL); dropping inserts it there.

- same-field drop reorders the chip locally (via onChipsChange), using the
  source index carried in the drag payload (fromIndex) and adjusting for the
  removal shift; dropping onto its own position is a no-op;
- cross-field drop inserts at the drop position: handleMoveChip gained an
  optional toIndex (omitted = append, e.g. dropping onto a hidden Cc/Bcc
  button, preserving existing behaviour);
- per-chip onDragOver computes the target gap; the container handles the
  trailing gap (past the last chip / over the input).

No new user-facing strings (the caret is purely visual), so no locale changes.

Tests (components/email/__tests__/recipient-chip-drag.test.tsx): reorder to end
/ front, self-drop no-op, cross-field positional insert, and caret visibility.
Also add the missing findComposeIdentityId export to the reply-identity mock in
the recipient drag/paste suites so <EmailComposer> mounts in compose mode.
2026-07-13 21:19:37 +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 b1f6758f98 fix: add ui:download-file permission to consent screen. 2026-07-13 21:18:36 +02:00
Paulhenry SauxandLinus Rath a679d82cc3 feat: add download file method for files generated by plugin 2026-07-13 21:18:36 +02:00
Paulhenry SauxandLinus Rath a08a9e9ed3 feat(plugins) : add new api api method : webauthn.getOrCreate 2026-07-13 21:17:45 +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 4a3394cf8c test(vitest): exclude integration/ and examples/ Playwright specs
vitest was collecting the dockerized integration Playwright specs (run via
`npm run test:integration`) and the untracked examples/ sample code, which
fail under the vitest runner. Exclude both so `npm test` only runs the unit
suite.
2026-07-11 21:16:19 +02:00
Stefan Hildebrandt a8598db44d i18n(unified-mailbox): sync he/sk locales with unified mailbox keys
The unified-mailbox rework added settings.appearance.unified_mailbox.
cross_account.{label,description} and sidebar.unified_mailbox, and dropped
the legacy settings.appearance.all_mail.{label,description}, in en and every
other locale except Hebrew (he) and Slovak (sk). Bring he/sk in line so the
translations-completeness test passes (no missing/extra keys vs en).
2026-07-11 21:16:11 +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 e1e83c4a83 test(integration): make reconcile-dependent counter assertions robust
The single `forceSync` + assert pattern flaked under full-suite load: one
reconcile can miss (or a shared/cross-account counter refresh lands late) with
no retry, so the assertion polls stale DOM until timeout. The flake moved
between reconcile-dependent tests (server-side move, spam source drain, unified
/ shared counters) run to run.

- Add expectFolderCountsSynced(): nudges a reconcile (visibilitychange ->
  checkForStateChanges) before *every* poll, so a missed reconcile is retried
  for the full window. Compares only the provided unread/total fields.
- Use it for the reconcile-dependent counter checks in 02 (move/delete),
  03 (multi-account isolation + unified aggregation), 04 (All Mail), 05 (spam
  source), 06 (shared folders); drop the now-redundant standalone forceSync.
  Pure live-push assertions (incoming/burst, background-login-live) keep the
  plain helpers so they still prove push works.
- The spam->not-spam round-trip could stall the Junk badge reconcile even with
  retries under load; assert the optimistic list removal + authoritative server
  round-trip (out of Junk, back in Inbox) instead of the badge.

Validated with two back-to-back full-suite runs: 35 passed each.
2026-07-11 21:15:57 +02:00
Stefan Hildebrandt cdb31634a6 fix(attachments): route all viewer blob fetches for cross-account messages
Extend the cross-account blob routing beyond download/preview to every blob
fetch in the message viewer, so a message opened from a different account in the
unified / All-Mail view renders and exports correctly instead of 404ing against
the active account:

- inline cid: images, drag-to-desktop, attachment thumbnails, the "download all"
  zip bundle, and the S/MIME / TNEF / embedded-rfc822 blob reads now use a
  resolved blobClient (getClientForAccount(sourceClientAccountId)) and the owner
  blobAccountId (sourceAccountId), computed once from the open message's source;
- fetchBlobAsObjectUrl / fetchBlobArrayBuffer / fetchBlob calls pass the
  accountId (the client methods gained the param in the previous commit);
- non-cross-account behaviour is unchanged (blobClient === active client).

Extends 10-attachments with an inline-image case (verified to fall back to the
placeholder without the routing). The SMTP helper can now send multipart/related
inline images.
2026-07-11 21:15:49 +02:00
Stefan Hildebrandt 26c3d07d56 fix(attachments): download/view attachments on cross-account All-Mail messages
Blobs are scoped per JMAP account, but the attachment download/preview path
always used the active account's client and accountId. Opening a message from a
different account in the unified / All-Mail view and downloading (or previewing)
an attachment therefore 404'd against the active account.

Route the blob fetch to the message's source instead:
- resolveBlobSource() picks the owning login's client
  (getClientForAccount(sourceClientAccountId)) and the owner accountId
  (sourceAccountId) for delegated/shared blobs, in the unified view;
- handleDownloadAttachment + the attachment-preview handlers use it;
- downloadBlob / fetchBlobAsObjectUrl / fetchBlobArrayBuffer gain an accountId
  param (getBlobDownloadUrl/fetchBlob already had one).

Adds 10-attachments: an attachment on another account's All-Mail message
downloads with the correct bytes (verified to fail without the routing).
2026-07-11 21:15:43 +02:00
Stefan Hildebrandt c3acb537d0 test(integration): live unified/All-Mail counter coverage
Add 09-live-counters: a background-login account updates the unified counter
live (no reconcile), and a shared-folder change reconciles the All-Mail counter
on focus. Document the shared-account counter behaviour in the README.
2026-07-11 21:15:35 +02:00
Stefan Hildebrandt 060c5d00d1 test(integration): draft handling and shared-folder moves
Add draft and shared-folder-move coverage (suite now 31 tests). Findings are
asserted server-side or pinned with test.fail where the UI is incomplete.

Drafts (07):
- multiple recipients (committed and typed-but-uncommitted) persist, and the
  draft reopens via the continue-draft button;
- a server-created draft (with $draft) shows the continue-draft button;
- a changed sender identity is saved to the draft on the server;
- KNOWN BUG (test.fail): reopening a draft resets the From selector to the
  default identity instead of the one the draft was saved with.

Shared-folder moves (08):
- shared -> shared (same owner) moves work in both directions (server-verified);
- KNOWN LIMITATION (test.fail): cross-account moves (own account <-> shared
  folder) don't relocate the message — the Move-to submenu offers the target
  but clicking it is a no-op.

Hooks added: composer From select + save-status, viewer edit-draft button,
context-menu "Move to" submenu + per-target testids (testId on
ContextMenuSubMenu). Helpers: JMAP identities/createDraft/sharing, composer
drive + move-via-submenu. README documents the findings.
2026-07-11 21:15:27 +02:00
Stefan Hildebrandt e05fbb2fe9 test(integration): All Mail, message actions, and shared-folder sync
Extend the integration suite (now 22 tests) to cover:

- All Mail view (04): single-account merge of Inbox + custom folders with
  Junk excluded, and cross-account aggregation across every logged-in account.
- Message actions from the list context menu (05): mark read/unread, delete
  (→ Trash), mark-as-spam (→ Junk) and not-spam round-trip, verified on both
  the UI counters/row state and the server mailbox the message ends up in.
- Shared/delegated folders (06): a delegated folder (+ Trash/Junk) shared
  alice→carol; the shared folder renders with its counter, and read/unread/
  delete/spam performed there land correctly (server-verified).

Hooks added: data-testid on context-menu delete/spam/read-unread items
(via a testId prop on ContextMenuItem), data-shared on folder rows, and
testId/data-expanded on sidebar section headers to drive the Shared section.

Observations surfaced by the suite (asserted server-side / with a reconcile):
- mark-as-spam doesn't optimistically decrement the *source* counter the way
  delete does; a visibility reconcile settles it.
- shared *destination* counters (shared Trash/Junk) don't refresh live —
  forceSync reconciles the active account only, not shared accounts.
2026-07-11 21:15:20 +02:00
Stefan Hildebrandt b8809c2e69 test(integration): dockerized webmail⇆Stalwart Playwright sync suite
Add an end-to-end integration harness that runs the webmail against a real
Stalwart mail server in Docker and drives it with Playwright, focused on the
mail/folder synchronisation behaviour (unread/total counters, folder-list
sync, account-scoped Unified Mailbox) that the unified-mailbox work touches.

- integration/ stack: Stalwart (declarative bootstrap: alice/bob/carol,
  submission + IMAP listeners, permissive CORS) + webmail (dev mode, so the
  browser's plaintext cross-origin JMAP calls aren't blocked by the prod CSP).
- Helpers: dependency-free SMTP submit client, JMAP client for seeding /
  inspecting server state, and page helpers (login, add/switch account,
  locale-independent folder-counter reads).
- Specs: login, single-account sync (receive/read/move/delete/folder-create/
  burst) and multi-account (per-account isolation + cross-account Unified
  Inbox aggregation + background-account delivery). 12 tests, all green.
- Add focused data-testid hooks to the mail UI (folder rows + counters, email
  list items, account switcher, composer) for stable selectors.
- Exclude examples/ and integration/ from tsconfig/eslint/.dockerignore.
2026-07-11 21:15:13 +02:00
Stefan Hildebrandt bc11450f3f feat(jmap): keep unified/All-Mail counters current for shared accounts
Stalwart's JMAP EventSource only pushes StateChange for the session's *primary*
account — a background change in a shared/delegated (secondary) account is never
pushed — so the shared folder's counters, and the unified/All-Mail badge that
aggregates them, went stale until a full reload. (Other *login* accounts already
update live because each login has its own SSE.)

Extend the client's state poll to every account in the session:
- buildStatePollingRequest emits a Mailbox/Email `get` per account, with the
  accountId encoded in the callId (`mbx:<id>` / `eml:<id>`);
- checkForStateChanges / fetchCurrentStates key polling state per account and
  report a per-account `changed` map, which handleStateChange already treats as
  "some mailbox changed" and refetches the full (own + delegated) mailbox list
  from — the badge is a live projection over that list;
- a slow (20s) secondary-account poll runs alongside SSE (paused while hidden)
  so shared counters stay current between focus events.
2026-07-11 21:15:05 +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
Stefan HildebrandtandLinus Rath c1acf58c5f test(compose): add findComposeIdentityId to reply-identity mock
The recipient chip-drag and paste tests render <EmailComposer>, which since
b716f95a (feat(compose): preselect identity of the active mailbox) calls
findComposeIdentityId() from @/lib/reply-identity in compose mode. Both tests
mock that module but only returned resolveReplyFrom, so vitest threw
"No 'findComposeIdentityId' export is defined on the mock" on mount,
failing all 18 tests. Add the missing export (returns null; the composer
guards with if (composeIdentityId)).
2026-07-11 19:36:27 +02:00
honzupandLinus Rath 42798c2b7c fix: open signature links in a new tab instead of navigating the app away
Signatures render into the main document - the identity form's live preview
and the composer's signature block - rather than the sandboxed iframe used for
message bodies. SIGNATURE_SANITIZE_CONFIG allows no target attribute, so those
anchors were live and target-less: one click navigated the whole app away,
discarding the unsent draft or the unsaved signature with it.

Add sanitizeSignatureHtmlForDisplay, which keeps the storage sanitizer's image
restrictions but forces target="_blank" rel="noopener noreferrer" on every
anchor, and use it at the two render sites. The composer's SignatureBlock
NodeView stamps the target on its rendered DOM instead, because attrs.html is
what serializeEditorContent emits into the sent message - storage and the
recipient's copy stay exactly as the user wrote them.
2026-07-11 10:45:01 +02:00
honzupandLinus Rath 75d17d4e37 fix: keep target/rel on links in plain-text message bodies
Plain-text bodies render into the main document rather than the sandboxed
iframe, so an anchor without target="_blank" navigates the whole app away
instead of opening a new tab.

plainTextToSafeHtml emits target and rel correctly, but
sanitizePlainTextRenderedHtml stripped both back off: DOMPurify URI-tests
every attribute value not on its URI-safe list, and "_blank" does not match
PLAIN_TEXT_RENDERED_CONFIG's ALLOWED_URI_REGEXP. EMAIL_SANITIZE_CONFIG avoids
this only because its regex carries a catch-all alternation for non-URI values.

Mark target and rel as URI-safe so they survive the URI test, rather than
loosening href validation.
2026-07-11 10:45:01 +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 fa31933922 chore: update version to 1.7.7 1.7.7 2026-07-09 16:25:12 +02:00
Linus Rath d3f77ef9cf feat: add plugin ui.rerenderEmail API and restyle read-receipt banner 2026-07-09 16:04:33 +02:00
Linus Rath 5ccba83129 Merge branch 'main' of https://github.com/bulwarkmail/webmail
# Conflicts:
#	lib/plugin-sandbox/host-api.ts
2026-07-09 14:57:36 +02:00
752e71198c feat: add 3 new plugin hooks : onBeforeBlobUpload, onBeforeDraftAutoSave, onBeforeEditDraft (#586)
Co-authored-by: Linus Rath <minipixxelinfo@gmail.com>
2026-07-09 14:55:34 +02:00
Linus Rath 2cb74c0186 i18n: use example.com in email placeholder strings 2026-07-09 14:54:04 +02:00
Linus Rath 9ab7339320 feat: add plugin ui.prompt dialog and first-class settings-section tabs 2026-07-09 14:51:17 +02:00
Linus Rath 782974ecdb fix: load trusted senders address book on settings page so count isn't 0 2026-07-09 14:01:05 +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
Linus Rath 38313639ed fix: use callable .get to detect Headers in pickRequestHost 2026-07-09 13:51:12 +02:00
Linus Rath e933800792 fix: store event organizer as owner-only to prevent duplicate ORGANIZER/ATTENDEE 2026-07-09 13:45:04 +02:00
Linus Rath 60a1cc670c fix: apply per-domain favicon override in root metadata #585 2026-07-09 13:30:44 +02:00
dealerwebandLinus Rath c0515001f1 i18n: restore key parity after the Jalali calendar
The translation parity test fails on main again after the Jalali
calendar landed: the two newest locales, he and sk, were missed when
the twelve Jalali month names were added - they get the same Latin
transliterations every other non-Persian locale received.

fa in turn carried four keys that do not exist in en and are not
referenced anywhere in the code (the email_composer.text_direction
block and settings.templates.image_too_large) - removed, as the
suite's no-extra-keys check demands.
2026-07-08 22:43:49 +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
dealerwebandLinus Rath 3d36492518 i18n: restore key parity after the Hebrew locale
The translation parity test fails on main since the Hebrew locale
landed. Three gaps, all from catalogs drifting past each other:

The new he locale was based on an older en catalog and was missing 17
keys (pin/unpin, recipient autocomplete, attachment-upload validation,
the date_locale block, the tint_list_rows and show_folder_total_count
settings, language.sk). They are translated to Hebrew here; date_locale
keeps the same English values every other locale currently has.

The rtl_editing setting existed only in en and he - added translated to
the other 20 locales.

language.he was missing everywhere - added as the endonym "עברית" to
all locales, matching how the other language names are written.
2026-07-08 15:42:02 +02:00
dealerwebandLinus Rath 94af4725b6 Fix: send mailto unsubscribe ourselves instead of via the OS handler
The List-Unsubscribe action for mailto: links created a hidden anchor,
clicked it and reported success. That hands the mailto: URL to the OS
default mail handler - for a webmail user that opens the wrong program
or nothing at all, and the unsubscribe message is never sent, while the
banner still claims it was.

The confirm flow now parses the mailto: URL (address, subject, body -
percent-decoded manually since RFC 6068 does not use plus-encoding) and
sends the message through the account's own JMAP client, preferring the
identity that received the newsletter so the list can match the
subscriber. In unified views the send is routed to the email's owning
account. Success is only reported once the server accepted the message.

The mobile confirm dialog reused the success strings as its question
text; it gets proper confirm_message strings in all 22 locales, and
success_mailto now says what actually happened.
2026-07-08 15:41:46 +02:00
Shuki VakninandLinus Rath 22418c17cf feat(i18n): Hebrew locale + full RTL support 2026-07-08 00:07:34 +02:00
Linus Rath 8904d724bb fix: hide Files when account lacks filenode capability #563 2026-07-07 23:59:19 +02:00
Linus Rath c7d551f185 fix: attachment reminder ignores quoted text on reply/forward #570 2026-07-07 23:54:50 +02:00
Linus Rath 6470fa86f0 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-07-07 23:44:34 +02:00
Linus Rath 8647d709ff fix: only commit recipient on Space when input is a valid email #571 2026-07-07 23:43:54 +02:00
Shuki VakninandLinus Rath b73d1b55d1 fix(email): render emails that put height:100% on a wrapper element
Some HTML emails set height:100% on a full-bleed wrapper table/div rather
than html/body. With the viewer's body{overflow:hidden}, this collapses
documentElement.scrollHeight to the iframe's 150px default, so the
scrollHeight-based auto-resize locks the iframe short and the body renders
blank below the fold. A Box.co.il verification email rendered as a
logo-only 150px strip.

- Neutralise height:100% on any element so the body grows to its content.
- Measure max(documentElement, body).scrollHeight, and re-measure on a
  fixed cadence over a short settle window so a late reflow (height:100%
  wrapper, or images that resize after onload) is caught even when no
  ResizeObserver/image event fires.
2026-07-07 23:40:18 +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