Compare commits

...
94 Commits
Author SHA1 Message Date
Linus Rath de56229ef2 chore: update version to 1.7.5 2026-06-24 20:06:03 +02:00
Linus Rath 1ff23790ae Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-24 19:56:15 +02:00
198407ebf5 fix(composer): keep HTML signature styling in the editor and on send
Rich, table-based identity signatures lost all their inline CSS
(background/text colors, fonts, border-radius, bgcolor). The composer
embeds the signature into the TipTap editor, and parsing it into the
ProseMirror schema flattened it to a generic bordered table. That
normalized version was then shown while composing AND delivered to the
recipient, even though Identity settings stored and previewed it
correctly.

Hold the signature as a dedicated, non-editable atom node
(SignatureBlock) that keeps the verbatim HTML in an attribute and renders
it inside a Shadow Root, mirroring the existing QuotedHtml island. The
markup is never parsed into the schema, so the styling survives 1:1 both
in the in-editor preview and in the outgoing mail
(serializeEditorContent inlines the verbatim HTML, as it already does for
quoted originals). The signature stays a single unit: select it and
Backspace/Delete to remove it; identity switching still swaps it via the
existing data-signature-block markers.

Adds unit coverage (parse + serialize round-trip preserves inline styles).

Fixes #475

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:56:10 +02:00
Linus Rath de68d68fb7 feat: add "New address book" creation UI #415 2026-06-24 19:53:41 +02:00
Linus Rath f285a2bd64 fix: add missing fa locale to client IntlProvider messages map 2026-06-24 19:40:10 +02:00
Linus Rath 84b6d0fd8b i18n: add missing translation keys across 19 locales 2026-06-24 19:36:55 +02:00
Linus Rath f972068143 fix: localize special-folder names by JMAP role #404 2026-06-24 19:32:13 +02:00
Linus Rath 7882b254a0 fix: disable iMIP scheduling on calendar import #411 2026-06-24 19:29:56 +02:00
Linus Rath ae5d397512 feat: add "Download all" button to bundle attachments into a zip #466 2026-06-24 18:46:41 +02:00
Linus Rath 7a022596c3 feat: add option to disable calendar 2026-06-24 18:39:38 +02:00
Linus Rath 8c575e8ed8 fix: load mailboxes in Filters when opened directly #485 2026-06-24 18:20:52 +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 1119d8ed73 fix: strip display names from EmailSubmission envelope addresses 2026-06-24 17:01:51 +02:00
Linus Rath 32f0a67dbc fix: gate Send-now toolbar label, translate send_now across locales 2026-06-24 16:52:36 +02:00
Shuki VakninandLinus Rath baf094d026 feat(scheduled): Send now button on scheduled/delayed messages
Adds a 'Send now' action to the scheduled-send view (both the toolbar and the
inline banner) so a queued message — whether explicitly scheduled or held by the
undo-send delay — can be sent immediately instead of only cancel/reschedule/edit.
Reuses the existing reschedule path (reschedules the submission to now), so no
new JMAP plumbing. Toolbar 'Cancel send' demoted to ghost so 'Send now' is the
single primary action. i18n added across locales.
2026-06-24 16:46:18 +02:00
hamedf62andLinus Rath f1b9f4ba50 feat(i18n): complete Farsi (fa) translation - 2654 translated strings
- Comprehensive Persian translation of all 2705 locale keys
- Covers login, sidebar, email viewer, composer, settings,
  calendar, contacts, files, S/MIME, tour, and all other sections
- 51 keys intentionally match English (language names, placeholders, templates)
- 98.1% of all strings fully translated to Persian
2026-06-24 16:34:27 +02:00
hamedf62andLinus Rath cc20d29636 feat: add Farsi (fa) locale support
Add comprehensive Farsi translation for the webmail interface:
- Create locales/fa/common.json with Farsi translations
- Register 'fa' locale in i18n/routing.ts and i18n/request.ts
- Add Iran flag component (FlagIR) to flag-icons.tsx
- Add ف��رسی to language switcher dropdown
- Add Farsi language name to English locale for language selector

Translation covers login, sidebar, email viewer, composer, settings,
notifications, calendar, contacts, errors, shortcuts, and more.
2026-06-24 16:34:27 +02:00
Shuki VakninandLinus Rath 5f713a033b fix(mail-list): truncate long subjects so they don't overlap the timestamp
In the single-line (focused) message-list layout, the subject span used
`shrink-0`, which prevented `truncate` from engaging: a long subject sized to
its full content width and overflowed the bounded subject/preview group,
rendering on top of the timestamp on the right.

Let the subject shrink and truncate (`shrink-0` -> `min-w-0`), and give the
inline preview a high shrink factor (`shrink-[9999]`) so it collapses first —
the subject stays fully visible while there's room and only truncates with an
ellipsis once the preview is gone, never colliding with the time.

Applied to both email-list-item and thread-list-item (single-email and
thread-aggregate rows).
2026-06-24 16:33:43 +02:00
Linus RathandGitHub 079ec57204 Merge pull request #458 from hildebrandttk/feat/all-mail-cross-account-views
feat: "All accounts" view extended by unread, stared and all filter and improved shared folder handling
2026-06-24 16:12:56 +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 8af6694152 fix: strip reply/forward prefixes followed by a full-width colon
The prefix-stripping regex only matched an ASCII ":", so a localized
prefix from a CJK mail client (e.g. "回复:foo", using the full-width
colon U+FF1A) was left in place. On reply this caused the user's own
prefix to be stacked on top, growing the subject chain.

Accept both ":" and ":" after the prefix token. Adds tests.
2026-06-24 15:56:38 +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 5c2f206c74 feat(mail): return to the list after marking an open message unread
Gmail-style: marking the currently-open message unread returns to the message
list instead of staying in the reading pane (where the viewer's auto-mark-read
would just flip it back to read). Gated on the returnToListAfterAction setting
added in #477 (default on); when off, you stay in the viewer.

Only the single-message viewer, and only on mark-unread (read === false).
2026-06-24 12:09:22 +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
Shuki VakninandLinus Rath d671c606a1 feat(mail): deleting the open message returns to the list, not the next email
Deleting from inside an open message advanced to the next email. Gmail (and most
clients) return you to the message list instead. In the viewer's onDelete,
deselect first (handleMobileBack) so the store's remove-and-advance sees no
selection and won't auto-open the next message, then delete the captured email.
Returning to the list immediately also avoids a flash of the next email.

Scoped to the single-message viewer; list and keyboard deletes (which keep
auto-advance) are unchanged. Consistent with the mark-unread-returns-to-list
behaviour.
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
3dd596ba50 fix: guard compose Send against double-submit
Every Send control was disabled only by `canSend` (recipient/subject/body
validity), which never reflects an in-flight submission, so the composer
stayed interactive during the JMAP round-trip. Clicking Send quickly more
than once - or a click racing the keyboard send shortcut - invoked
handleSend once per click and sent the message multiple times (duplicate
deliveries and duplicate Sent entries), most easily hit on higher-latency
connections.

Add a synchronous re-entry guard: a ref (not state, which updates
asynchronously and wouldn't block a second click in the same tick) set once
handleSend clears its "don't send" early returns and reset in a finally,
plus an isSending state that disables every Send control. Covers all entry
points - the three Send buttons, the keyboard shortcut, the schedule dialog,
and the attachment-warning confirm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:42:39 +02:00
Shuki VakninandLinus Rath e50fe0d4db fix(mail-list): add breathing room between the unread dot and the avatar
The unread indicator dot was absolutely positioned at `left-1` (4px), leaving
only ~4px between it and the avatar (which starts at the row's `px-4` gutter),
so the dot read as flush against the avatar. Move it to `left-0.5` so it sits
nearer the panel edge (like other mail clients) and opens the dot-to-avatar gap
to ~6px. Applied to both email-list-item and thread-list-item (all three
absolute dot instances).
2026-06-22 17:10:46 +02:00
Linus Rath 3d3ad8f0ef Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-22 00:10:13 +02:00
Linus Rath d0ed4b4dfe fix: block remaining email tracking vectors #457 2026-06-22 00:09:09 +02:00
Stefan HildebrandtandLinus Rath 5306f7c548 fix: cap filename tokens at the full 200-char limit, not 80
renderRaw (and the attachment-template renderer) sanitised each {token}
with sanitizePart's default 80-char cap, so a single long token such as
{subject} was truncated to 80 — well before the documented 200-char
filename limit, which was therefore unreachable per token. Introduce a
FILENAME_MAX_LEN (200) constant and use it for the per-token cap so the
overall limit governs. Adds tests.
2026-06-21 19:28:57 +02:00
Stefan HildebrandtandLinus Rath ddb596affc fix: isolate per-account state snapshots from leakage and mutation
account-state-manager had two latent correctness issues:

1. Shared references: snapshotAccount stored the live store arrays/objects
   directly, so a later in-place mutation (array push/splice, or a shared
   email object being stamped) retroactively corrupted an earlier snapshot.
   Now copies the captured collections.

2. Incomplete restore: the snapshot only captures a subset of each store's
   fields, but restoreAccount applied it with a merge, leaving every other
   field (email selection, loading flags, tag counts, …) at the previously
   active account's values. It only worked because every caller happened to
   call clearAllStores() first. restoreAccount now resets the stores to
   baseline itself before layering the snapshot back on, so it is correct
   standalone and can't leak state across accounts.

Adds tests pinning the isolation guarantees.
2026-06-20 13:09:26 +02:00
Stefan HildebrandtandLinus Rath bfc8ba851a chore: switch lint scripts from removed next lint to eslint
Next 16 removed the `next lint` subcommand, so `npm run lint` failed with
"Invalid project directory provided, no such directory: .../lint". Point the
lint and lint:fix scripts at ESLint directly, using the existing flat config
(eslint.config.mjs).
2026-06-19 23:53:37 +02:00
Stefan HildebrandtandLinus Rath 3f9e60843d fix: repair pre-existing failing vitest suite
Fixes failures across the suite that fail on main independently of any branch.

Documented + skipped
- smime/smime-crypto: this suite OOMs its worker (~4 GB heap) generating and
  using real 2048-bit RSA keys via pkijs/asn1js — a pre-existing memory issue,
  not a logical failure. Skipped behind a single SKIP_SMIME_CRYPTO_OOM flag with
  an in-file explanation and re-enable instructions, and the beforeAll bails
  early so the skipped file runs in ~2s instead of crashing the worker.

Code fixes
- jmap/client: getSubmissionAccountId honoured the requested (mail) account
  even when it lacks the submission capability, so EmailSubmission/set was
  addressed to the wrong account when JMAP hosts submission in a separate
  account. Prefer an account that actually advertises submission, falling back
  to primaryAccounts['…:submission'].
- plugin-sandbox/loader: deactivateAllSandboxed used require('./registry'),
  which is unresolvable under the Vite/ESM test runtime. registry only imports
  types (no cycle), so use a static import; all() already returns a copy, so
  iterating while deregister mutates is safe.

Test fixes (tests trailed intentional code/behaviour changes)
- vitest.setup: add a matchMedia stub (jsdom lacks it) — unblocks 8
  email-list-item tests.
- calendar-utils: pin TZ=UTC for the timezone-sensitive bounds/layout assertions
  (host runs at UTC+2) and update expected minutes to UTC.
- calendar-participants: buildParticipantMap keys entries by generated UUIDs
  (RFC 8984), not 'organizer'/'attendee-N'. Look entries up by identity so the
  test no longer depends on a generateUUID mock leaking from another file.
- email-headers: softfail now returns the semantic 'text-warning' token.
- email-list-item: unknown keyword ids intentionally render a gray fallback badge.
- plugin-loader: exposePluginExternals is now a documented no-op.
- plugin-slot: PluginSlot reads the sandbox registry and renders iframe slots;
  rewrite the tests against that architecture with a referentially stable snapshot.
- plugin-types: MAX_THEME_SIZE was raised to 2 MB.
2026-06-19 23:53:20 +02:00
Stefan HildebrandtandLinus Rath 2fac6ebfb8 test: add characterisation tests for untested integration seams
Golden-master tests pinning the CURRENT behavior of high-value modules
that had no coverage — integration seams, security helpers, two API
route handlers, and complex pure utils. 111 tests across 12 files.

New tests:
- auth-crypto / session-cookie: AES-256-GCM session encryption roundtrip,
  tamper/version/missing-secret handling; cookie-slot naming.
- unified-mailbox: multi-account fan-out, sort, totals, per-account error
  isolation, personal-vs-shared JMAP target resolution, counts/roles.
- account-state-manager: snapshot/restore across the six real Zustand
  stores; clearAllStores reset shape; evict.
- mdn: RFC 5322 MDN assembly (CRLF, RFC2047, base64 wrap, headers).
- tnef: winmail.dat binary parsing from hand-built fixtures.
- download-filename / subject-prefix / birthday-calendar / eml-import:
  filename templating, multilingual prefix stripping, birthday event
  generation, .eml/.zip import.
- webdav / caldav-discover route handlers: auth guards, path validation,
  upstream URL construction, candidate probing.
- helpers/factories.ts: shared makeEmail/makeMailbox/makeFakeJmapClient.

Tests follow the repo's existing patterns (route-import, fake IJMAPClient,
fetch spy, real store singletons). Where current behavior looks buggy it
is pinned and flagged with a // CHARACTERISATION: comment (see PR for the
suspected-bugs list); no production code is changed.
2026-06-19 23:52:42 +02:00
Paul HandLinus Rath dda9fd1433 feat(i18n): add Romanian (ro) locale
Adds locales/ro/common.json and wires ro through routing, request,
intl-provider, the language switcher and the flag list. Plurals use
Romanian one/few/other forms.
2026-06-19 15:35:32 +02:00
Stefan HildebrandtandLinus Rath 3516d3c727 chore: resolve react-hooks/exhaustive-deps warnings
Goes through the 7 exhaustive-deps warnings individually:

Added the genuinely-missing dependency (safe, no extra churn):
- email-viewer useMemo: add effectiveEmailContent.hasStyleTag (used for
  hasOwnLayout; changes in lockstep with .html, closing a latent staleness gap).
- pro-compose-tab-body handleSend: add refreshCurrentMailbox (stable zustand
  selector) and drop the stale fetchEmails/selectedMailbox deps — which left
  those two selectors entirely unused, so remove them too.
- use-mailbox-drop handleDrop: add sourceMailboxId (changes in lockstep with
  draggedEmails, already a dep).

Suppressed with a justified comment where depending on the whole object would
regress behavior — these are intentional fine-grained deps:
- email-composer signature-swap effect (keyed to signature fields + prev*Ref
  guards; whole signatureIdentity would re-splice the live editor).
- email-viewer auto-mark-as-read (whole email would reset the delay timer on
  any unrelated field update).
- email-viewer effective-attachments memo (derives from email.attachments;
  whole email would churn the list + its layout measurement).
- email-viewer auto-MDN effect (email already captured via id +
  sendReadReceiptNow; autoMdnRef guards double-send).

tsc --noEmit clean; eslint now reports 0 problems.
2026-06-19 12:31:07 +02:00
Stefan HildebrandtandLinus Rath 0b7203df0f chore: clear pre-commit eslint warnings (unused symbols, stale disables, test any)
Cleans up the lint warnings the pre-commit hook surfaces, without any
behavioral change:

- Remove unused imports/vars/destructured props (parseISO, useEffect,
  format, durMin, roles, daysInYear, ALLOWED_PLUGIN_FILES, continuesBefore,
  isPushConnected, isSelected) and the now-unused parseDuration import.
- Drop three stale `// eslint-disable-next-line no-undef` directives that
  no longer suppress anything (browser-navigation, smime/crypto-engine).
- recurrence-expansion.test.ts: replace 39 `as any` casts with a cast-only
  `rule()` helper for partial recurrence-rule fixtures, typed access to
  utcStart/utcEnd (now on CalendarEvent), and the source's
  `Partial<CalendarEvent> & { excluded?: boolean }` for the excluded
  override. No defaults are injected, so the expansion logic sees the same
  partial rules as before (35 tests still green).

Remaining: 7 react-hooks/exhaustive-deps warnings are left as-is — adding
the missing deps changes effect/memo timing and needs per-hook review, not
a mechanical fix. tsc --noEmit clean; eslint 0 errors / 7 warnings.
2026-06-19 12:31:07 +02:00
Stefan HildebrandtandLinus Rath 344795a8d9 feat: split a pasted address list into recipient chips
Pasting a list of addresses into To/Cc/Bcc now creates one chip per
address instead of dropping the whole blob in as a single invalid chip.
A paste is split only when it actually contains a separator; a lone
address falls through to normal editing.

- Separators: commas, semicolons, and any whitespace/newline - covers
  comma/space dumps, spreadsheet columns and Outlook-style `;` lists.
- Display names are preserved: `Name <email>`, a fully-quoted
  `"Name <email>"` entry, and `"Doe, John" <email>` (comma inside a
  quoted name) each stay a single chip with the name intact.
- Bare-address runs split per address; a `<addr>` token is unwrapped;
  tokens that aren't valid addresses are left behind in the input for
  the user to fix rather than becoming junk chips.
- Deduped case-insensitively within the paste and against existing chips.

Implemented as splitPastedRecipients in email-composer-utils, layered on
the shared quote/angle-aware splitter: splitRecipients gains an optional
`separators` argument so the composer/mailto serialization boundary
(comma-only) and the paste path (`,;\n\r`) share one implementation.
Wired into the recipient chip input's onPaste handler (To/Cc/Bcc).
2026-06-19 12:30:43 +02:00
Loïs PostulaandLinus Rath 638fc7db4e feat(oauth): add OAUTH_AUTHORIZE_URL to override authorize endpoint
Lets a per-brand authorize host front a single canonical issuer, so the
IdP token's `iss` stays constant for downstream validation while login
branding varies per domain. Discovery, token exchange and refresh keep
using OAUTH_ISSUER_URL.
2026-06-19 12:30:23 +02:00
Stefan HildebrandtandLinus Rath ab3e0e717a feat: email a contact or group via the in-app composer
Adds a "Send email to group" action (To / Cc / Bcc) that opens the composer
pre-filled with the group's members in the chosen field, preserving each
member's display name. It is available both in the group context menu (between
"Edit Group" and "Delete") and in the group detail panel's header (shown when
the group has at least one member with an email). The single-contact "Send
email" button in the contact detail panel uses the same path.

Routing is internal, not via mailto:. Contacts is its own route and the composer
lives in the mail route, so the handoff stashes the recipients
(savePendingMailto) and does a client-side router.push("/"); the main route's
existing consumePendingMailto effect opens the composer in the current account.
This avoids the OS mailto handler (which could open a different mail app) and
the protocol round-trip's full-page reload, which dropped the in-memory
per-account JMAP clients of a multi-account session (a logout).

- contacts/page.tsx: openComposeInApp(recipients, field) shared helper;
  handleComposeGroupFromSidebar (deduped "Name <email>" members, empty -> toast)
  and handleComposeContact; wired to the sidebar, group detail, contact detail.
- contact-group-detail.tsx: onComposeGroup(field) prop + To/Cc/Bcc header control
  (shown when the group has emailable members).
- contacts-sidebar.tsx: onComposeGroup(groupId, field) prop + "Send email to
  group" submenu between Edit and Delete.
- contact-detail.tsx: onCompose() prop; the button is no longer a mailto: link.
- mailto.ts: recipient splitter is quote-aware (reuses the composer's
  splitRecipients) so a comma in a display name survives — still useful for real
  OS mailto: links.
- i18n: contacts.groups.send_email{,_to,_cc,_bcc} and no_member_emails across all
  locales.

Display names round-trip via formatRecipient -> parseRecipientList.
2026-06-19 12:29:43 +02:00
Max HaoandLinus Rath c9eae3b3a1 fix: update markAsSpam to fetch mailboxes with accountId 2026-06-18 21:38:29 +02:00
Max HaoandLinus Rath 6f615f4c32 fix: fix directory fetching display names. 2026-06-17 15:51:34 +02:00
Linus Rath 52eacf87b9 ix: reap only relay-confirmed-dead leftover push subscriptions 2026-06-17 09:11:29 +02:00
Max HaoandLinus Rath c4f1cc23d7 fix blank space with plain-text emails 2026-06-16 15:48:50 +02:00
Max HaoandLinus Rath 4f1390fdeb fix toolbar re-render when opening emails 2026-06-16 15:48:50 +02:00
Max HaoandLinus Rath c0ca6d3102 fix: add collapse all threads functionality to email selection in thread list 2026-06-16 13:34:56 +02:00
Linus Rath 0872d3dc8d chore: update version to 1.7.4 2026-06-15 23:28:20 +02:00
Linus Rath 1f889b0965 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-15 23:16:10 +02:00
Linus Rath 0b9fe5451f fix: preserve line breaks in generated text/plain alternative #421 2026-06-15 23:15:04 +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
Max HaoandLinus Rath f7d4f9d53c fix: prevent draft emails from being marked as unread 2026-06-15 23:00:00 +02:00
Stefan HildebrandtandLinus Rath 84aced7b4e test(dev-mock): use comma display names in a mock email
Give email-002 ("Project Update - Q1 Review") a sender and CC with
"Lastname, Firstname" display names so Reply/Reply-All in dev mode
exercises the comma-in-name recipient case end to end.
2026-06-15 22:59:46 +02:00
Stefan HildebrandtandLinus Rath 94b1f5aa48 refactor: model composer recipients as arrays instead of delimited strings
Alternative to the quote-aware string fix: represent committed To/Cc/Bcc
recipients as Recipient[] ({name?, email}) with a separate input-text
string per field, instead of a single comma-joined string parsed with
split(','). Structured recipients can never be torn apart on a delimiter,
so a display name containing a comma ("Doo, John <john@doo.org>", as
produced on Reply-All) stays a single chip.

- email-composer-utils: add Recipient type, parseRecipient/formatRecipient,
  and parseRecipientList/formatRecipientList for the (de)serialization
  boundary (ComposerDraftData stays a string; quoting keeps it lossless).
  Remove the now-unused string-chip helpers.
- email-composer: to/cc/bcc are Recipient[]; toInput/ccInput/bccInput hold
  the in-progress text. Reply/forward init, autocomplete, chip edit, drag &
  drop (payload now carries the structured recipient), send/draft/validation
  and template paths all operate on arrays. withInput() folds uncommitted
  typed text into the send/validation set.
- Tests updated for the array contract; add comma-in-name chip coverage.
2026-06-15 22:59:46 +02:00
Linus Rath c51c3655d5 feat: add "All Mail" view 2026-06-15 18:42:23 +02:00
Linus Rath 404a1e847c fix: move "Plain Text Only" setting from Reading to Composing #422 2026-06-15 17:39:18 +02:00
Linus Rath dcea4fdd5e feat: show recipient address in chip drag preview 2026-06-15 15:28:03 +02:00
Linus Rath 701f96adb3 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-15 15:23:09 +02:00
Stefan HildebrandtandLinus Rath 1ebfb286ad feat: drag and drop recipient chips between To/CC/BCC fields
Adds native HTML5 drag-and-drop so users can move recipient email
address chips between the To, CC, and BCC fields in the composer.
Chips dragged onto the Cc/Bcc toggle buttons auto-reveal the hidden
field and place the chip there.
2026-06-15 15:23:00 +02:00
Linus Rath b5f15dfdb7 i18n: add missing translation keys across 17 locales 2026-06-15 15:18:47 +02:00
Stefan HildebrandtandLinus Rath aee4bd78db feat: add Edit contact button to email viewer contact sidebar
Clicking an email address in the viewer already shows a contact detail
sidebar. An "Edit" button now appears there (for known contacts) that
navigates directly to the contact edit form via the existing URL-param
intent system (?contactId=…&view=edit), removing the need to open the
Contacts page manually and search for the contact.
2026-06-15 15:06:35 +02:00
Linus Rath 798a33495e refactor: remove JMAP status from admin dashboard 2026-06-14 17:15:52 +02:00
Linus Rath 4c6c1aab60 feat: manage shared/group account settings from Accounts page 2026-06-14 17:05:29 +02:00
Linus Rath 848ed9774d feat: show avatars in recipient autocomplete suggestions 2026-06-14 14:54:27 +02:00
Linus Rath b32e102ff9 feat: include directory users in recipient autocomplete 2026-06-14 14:52:13 +02:00
Linus Rath e8feb11983 feat: add telemetry to web setup wizard 2026-06-14 14:35:48 +02:00
Linus Rath 3e12fca517 fix: make telemetry opt-in 2026-06-14 14:31:26 +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
Linus Rath 1e63e2469a fix: strip build-time basePath from router.push redirects after login #390 2026-06-12 00:23:00 +02:00
Linus Rath e1c28e767a fix: context menu invisible on first right-click after page load 2026-06-12 00:21:09 +02:00
Linus Rath fe5645c818 refactor: redesign custom recurrence editor to match modal UI 2026-06-12 00:15:54 +02:00
Linus Rath 38570b1723 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-12 00:03:52 +02:00
Linus Rath a4f476945d feat: JMAP file/folder sharing in Files app #408 2026-06-12 00:02:45 +02:00
Linus Rath 20fd9ff4de fix: prevent wide email tables from rendering with rotated headers #409 2026-06-11 19:24:05 +02:00
Linus Rath 569dde9985 fix: preserve folder list when mailbox refetch hits concurrent-request limit 2026-06-11 19:10:06 +02:00
Linus Rath 08f344403b feat: recurrence editor, set-default calendar, and timezone-aware calendar queries 2026-06-11 19:05:11 +02:00
Linus Rath be58cee989 fix: dedupe scheduling emails, Stalwart-compatible calendar filters 2026-06-11 18:24:43 +02:00
Max HaoandLinus Rath 7c11e2b3c9 add localized translation placeholders 2026-06-10 19:06:44 +02:00
Max HaoandLinus Rath f2913a7c7d feat: add email display name support to the composer. 2026-06-10 19:06:44 +02:00
Linus Rath a27a5be3dd fix: correct dark-mode background-image inversion and height clipping in email viewer 2026-06-08 15:53:44 +02:00
Linus Rath 964136b540 feat: require re-authentication for device pairing and SSO 2026-06-05 19:23:06 +02:00
Linus Rath 569f688fbf feat: QR-code SSO login between webmail and mobile app 2026-06-05 18:33:20 +02:00
Linus Rath 1d050f8469 feat: add QR code device pairing for mobile app login 2026-06-05 17:41:55 +02:00
Linus Rath 2e4c0f9eea Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-05 16:09:12 +02:00
Linus Rath fd0b866339 fix: open recent contact emails at "/" instead of 404ing on "/mail" 2026-06-05 16:07:38 +02:00
MaartenandLinus Rath 1518ba04dd fix(nav): hide Add App button when sidebarAppsEnabled is false 2026-06-05 14:32:56 +02:00
Norbert Balák-HorváthandLinus Rath c5672c64fb res lock file 2026-06-04 19:52:42 +02:00
Norbert Balák-HorváthandLinus Rath 5997579f54 rem lock file 2026-06-04 19:52:42 +02:00
Norbert Balák-HorváthandLinus Rath f77d2e9103 Fix HU i18n 2026-06-04 19:52:42 +02:00
Linus Rath 6350e9dabc chore: update version to 1.7.4 2026-06-04 12:59:28 +02:00
Linus Rath 7723c134ff chore: update version to 1.7.4 2026-06-04 12:39:50 +02:00
Linus Rath d11ed904a9 feat: calendar agenda plugin sidecar + persist email detail sidebar state 2026-06-04 12:35:56 +02:00
173 changed files with 18888 additions and 1707 deletions
+13 -4
View File
@@ -49,6 +49,11 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# OpenID Connect issuer URL for discovery
# OAUTH_ISSUER_URL=https://your-idp.example.com
# Overrides only the user-facing authorize endpoint (e.g. a per-brand login
# host). Discovery, token exchange and refresh keep using OAUTH_ISSUER_URL.
# Leave unset to use the authorization_endpoint from discovery.
# OAUTH_AUTHORIZE_URL=https://login.your-brand.example.com/application/o/authorize/
# Allow OAuth discovery to resolve to private (RFC-1918 / loopback) addresses.
# Off by default as an SSRF guard. Enable for split-DNS deployments where the
# OAuth issuer's public hostname resolves to an internal IP from this server.
@@ -110,12 +115,16 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Anonymous Telemetry
# =============================================================================
# Anonymous instance telemetry is enabled by default. Heartbeats contain no PII:
# version, platform, bucketed account counts, and feature toggles only. See
# Anonymous instance telemetry is OPT-IN and disabled by default. Enabling it
# helps us understand how Bulwark is used so we can make the product better.
# Heartbeats contain no PII: version, platform, bucketed account counts, and
# feature toggles only - never email addresses, hostnames, or IPs. See
# https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema.
#
# Disable telemetry entirely (overrides the admin UI):
# BULWARK_TELEMETRY=off
# Enable telemetry (also toggleable in the admin UI):
# BULWARK_TELEMETRY=on
#
# Setting this (on or off) locks the choice and disables the admin UI toggle.
# Directory for telemetry state: instance id, consent, login HMACs
# (default: ./data/telemetry). For Docker, the default resolves to
+81
View File
@@ -1,5 +1,86 @@
# Changelog
## 1.7.5 (2026-06-24)
### Features
- **Mail**: Cross-account "All accounts" views with full group/shared-account support
- **Mail**: Per-account "All Mail" folder selection
- **Mail**: "Download all" button to bundle attachments into a zip (#466)
- **Mail**: Return to the list after deleting or marking the open message unread — configurable (default on)
- **Mail**: Collapse-all-threads action in thread-list selection
- **Calendar**: Option to disable the calendar
- **Composer**: Send-now button on scheduled/delayed messages
- **Composer**: Email a contact or group via the in-app composer
- **Composer**: Split a pasted address list into recipient chips
- **Contacts**: "New address book" creation UI (#415)
- **OAuth**: `OAUTH_AUTHORIZE_URL` to override the authorize endpoint
- **i18n**: Farsi (fa) locale — complete (2654 strings)
- **i18n**: Romanian (ro) locale
### Fixes
- **Composer**: Keep HTML signature styling in the editor and on send
- **Composer**: Guard Send against double-submit
- **Composer**: Strip display names from the `EmailSubmission` envelope addresses
- **Calendar**: Disable iMIP scheduling on calendar import (#411)
- **Mail**: Localize special-folder names by JMAP role (#404)
- **Mail**: Block remaining email tracking vectors (#457)
- **Mail**: Route counter and unread updates to the email's own account in aggregate views
- **Mail**: Fix blank space in plain-text emails
- **Mail**: Fix toolbar re-render when opening emails
- **Mail**: Truncate long subjects so they don't overlap the timestamp
- **Mail**: Strip reply/forward prefixes followed by a full-width colon
- **Mail**: Add breathing room between the unread dot and the avatar
- **Mail**: Isolate per-account state snapshots from leakage and mutation
- **Mail**: Cap filename tokens at the full 200-char limit
- **Spam**: Fetch mailboxes with `accountId` in `markAsSpam`
- **Filters**: Load mailboxes when opened directly (#485)
- **Settings**: Surface server errors on password change and TOTP toggle
- **Send now**: Gate the toolbar label and translate `send_now` across locales
- **Directory**: Fix fetching display names
- **Push**: Reap only relay-confirmed-dead leftover subscriptions
- **i18n**: Add the missing fa locale to the client `IntlProvider` messages map
- **i18n**: Add missing translation keys across 19 locales
## 1.7.4 (2026-06-15)
### Features
- **Mail**: New "All Mail" view across folders and accounts
- **Mail**: Edit contact directly from the email viewer contact sidebar
- **Calendar**: Recurrence editor, set-default calendar, and timezone-aware calendar queries
- **Calendar**: Agenda plugin sidecar
- **Composer**: Email display name support
- **Composer**: Drag-and-drop recipient chips between To/CC/BCC fields, with the address shown in the drag preview
- **Composer**: Avatars in recipient autocomplete suggestions, including directory users
- **Files**: JMAP file/folder sharing in the Files app (#408)
- **Auth**: QR-code SSO login and device pairing between webmail and the mobile app
- **Auth**: Require re-authentication for device pairing and SSO
- **Accounts**: Manage shared/group account settings from the Accounts page
- **Setup**: Opt-in telemetry in the web setup wizard
- **Mail**: Persist the email detail sidebar state
### Fixes
- **Mail**: Preserve line breaks in the generated `text/plain` alternative (#421)
- **Mail**: Fix inconsistent threading of email messages in the inbox and folders
- **Mail**: Stop draft emails from being marked as unread
- **Mail**: Prevent wide email tables from rendering with rotated headers (#409)
- **Mail**: Preserve the folder list when a mailbox refetch hits the concurrent-request limit
- **Mail**: Correct dark-mode background-image inversion and height clipping in the email viewer
- **Calendar**: Dedupe scheduling emails and use Stalwart-compatible calendar filters
- **Calendar**: Redesign the custom recurrence editor to match the modal UI
- **Files**: Don't send the connected-account key as the JMAP `accountId` when sharing files (#408)
- **Routing**: Strip the build-time `basePath` from `router.push` redirects after login (#390)
- **Nav**: Open recent contact emails at `/` instead of 404ing on `/mail`
- **Nav**: Hide the Add App button when `sidebarAppsEnabled` is false
- **Settings**: Move the "Plain Text Only" setting from Reading to Composing (#422)
- **Privacy**: Make telemetry opt-in
- **UI**: Fix the context menu being invisible on first right-click after page load
- **Admin**: Remove the JMAP status from the admin dashboard
- **i18n**: Add missing translation keys across 17 locales
## 1.7.3 (2026-06-04)
### Features
+8 -3
View File
@@ -4,7 +4,9 @@
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
- Gmail-style threading with inline expansion and an optional conversation toggle
- Unified mailbox view across all connected accounts
- Unified mailbox view across all connected accounts combined Inbox, Sent, Drafts, Junk, Archive, and Trash, with group/shared accounts optionally merged in
- Cross-account "All accounts" views All unread, All starred, and All mail spanning every account (including shared/group folders); each aggregate list labels the source folder of every message
- "All Mail" view that merges an account's folders (with a configurable folder selection) into a single list
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
- Attachment upload, download, drag-out to local file system, and inline preview images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning
@@ -69,6 +71,7 @@
- Grid and list views with sorting by name, size, or date
- Previews for images, text, audio, and video
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
- JMAP sharing (RFC 9670) for files and folders share with users or groups at read, read/write, or manager levels via a principal picker, with share indicators and a "Shared with me" sidebar section for folders other principals have shared with you
## Security & Privacy
@@ -102,7 +105,7 @@
## Internationalization
18 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
19 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Română · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
@@ -114,6 +117,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
- Configurable signature position (above or below quoted text)
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
- Shared folders across accounts
- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the unified and "All accounts" views ("Include group inboxes"); their messages are fully actionable there open, mark read, spam / not-spam, move, delete, and archive with folder unread counts kept in sync
- Multiple JMAP servers per deployment with optional auto-pick by email domain
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
@@ -121,6 +125,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
- Web setup wizard for first launch guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
- Admin policy gates for the aggregate mail views enable or disable the "All Mail" and the cross-account "All unread / starred / all" entries org-wide; each gated view still respects the user's own toggle
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
- File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts
- Admin toggle for search-engine indexing (`robots.txt` / `noindex`)
@@ -135,7 +140,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, dynamic manifest, and configurable (per-domain) install screenshots
- Automatic update check with server-side logging of new releases and a non-dismissible update notice
- Structured logging (`text` or `json`) with category-based levels
- Anonymous instance telemetry (opt-out via admin UI or `BULWARK_TELEMETRY=off`) version, platform, bucketed account counts, feature toggles only
- Anonymous instance telemetry (opt-in via admin UI, the installer, or `BULWARK_TELEMETRY=on`; off by default) version, platform, bucketed account counts, feature toggles only
- Release (`main`) and development (`dev`) Docker images on GHCR
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy
- Demo mode with fixture data no mail server required
+2 -2
View File
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.7.3-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.5-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
[![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/)
@@ -81,7 +81,7 @@ The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JM
Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
- **Mail** threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
- **Mail** threading, unified inbox, cross-account "All accounts" views, full-text search, Sieve filters, S/MIME, templates
- **Calendar** month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
- **Contacts** multiple address books, groups, vCard import/export
- **Files** Stalwart's JMAP FileNode storage with previews and folder upload
+1 -1
View File
@@ -1 +1 @@
1.7.3
1.7.5
+40 -4
View File
@@ -4,7 +4,7 @@ import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useAuthStore } from "@/stores/auth-store";
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
import { apiFetch, getPathPrefix, toRouterPath } from "@/lib/browser-navigation";
import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useParams } from "next/navigation";
@@ -32,6 +32,42 @@ function OAuthCallbackInner() {
return;
}
// Step-up re-auth for device pairing: the QR generator sent the user here
// via prompt=login. Don't create a login session — just confirm the fresh
// auth (sets the short-lived pairing proof cookie) and bounce back to the
// Security settings, where the QR generation auto-resumes.
let pairReauthResume = false;
try {
pairReauthResume = sessionStorage.getItem("pair_reauth_resume") === "1";
} catch { /* sessionStorage unavailable */ }
if (pairReauthResume && state) {
try { sessionStorage.removeItem("pair_reauth_resume"); } catch { /* ignore */ }
(async () => {
try {
const res = await apiFetch("/api/auth/reauth/sso/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ code, state }),
});
if (!res.ok) {
setError("token_exchange_failed");
return;
}
try {
sessionStorage.setItem("pair_reauth_done", "1");
// Land back on the Security tab (readPersistedTab reads this key).
sessionStorage.setItem("settings-deep-link-tab", "security");
} catch { /* ignore */ }
const prefix = getPathPrefix(params.locale as string);
router.push(toRouterPath(`${prefix}/${params.locale}/settings`));
} catch {
setError("token_exchange_failed");
}
})();
return;
}
const savedState = sessionStorage.getItem("oauth_state");
if (savedState) {
@@ -69,7 +105,7 @@ function OAuthCallbackInner() {
redirectTo = saved;
}
} catch { /* sessionStorage may be unavailable */ }
router.push(redirectTo);
router.push(toRouterPath(redirectTo));
} else {
setError("token_exchange_failed");
}
@@ -153,7 +189,7 @@ function OAuthCallbackInner() {
redirectTo = saved;
}
} catch { /* sessionStorage may be unavailable */ }
router.push(redirectTo);
router.push(toRouterPath(redirectTo));
} else {
setError("token_exchange_failed");
}
@@ -181,7 +217,7 @@ function OAuthCallbackInner() {
</p>
<Button
variant="outline"
onClick={() => router.push(`${getPathPrefix(params.locale as string)}/${params.locale}/login`)}
onClick={() => router.push(toRouterPath(`${getPathPrefix(params.locale as string)}/${params.locale}/login`))}
>
{t("oauth_error.back_to_login")}
</Button>
+7 -1
View File
@@ -16,6 +16,7 @@ import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store";
import { usePolicyStore } from "@/stores/policy-store";
import { toast } from "@/stores/toast-store";
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
@@ -97,6 +98,7 @@ export default function CalendarPage() {
removeCalendar, clearCalendarEvents,
refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore();
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
@@ -179,10 +181,13 @@ export default function CalendarPage() {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
redirectToLogin();
} else if (client && !calendarEnabled) {
// Calendar disabled by admin policy - send the user back to mail.
router.push("/");
} else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) {
router.push("/");
}
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
}, [initialCheckDone, isAuthenticated, authLoading, client, calendarEnabled, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
useEffect(() => {
if (error) {
@@ -1164,6 +1169,7 @@ export default function CalendarPage() {
) : null;
if (!isAuthenticated) return null;
if (!calendarEnabled) return null;
if (!supportsCalendar) return renderWebcalAccountPicker();
const renderView = () => {
+97 -2
View File
@@ -18,7 +18,9 @@ import { ContactImportDialog } from "@/components/contacts/contact-import-dialog
import { RenameDialog } from "@/components/files/rename-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { savePendingMailto } from "@/lib/protocol-handlers/session";
import { formatRecipient } from "@/lib/email-composer-utils";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { usePolicyStore } from "@/stores/policy-store";
@@ -82,6 +84,7 @@ export default function ContactsPage() {
bulkDeleteContacts,
bulkAddToGroup,
moveContactToAddressBook,
createAddressBook,
renameAddressBook,
removeAddressBook,
shareAddressBook,
@@ -93,6 +96,7 @@ export default function ContactsPage() {
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
const [showImportDialog, setShowImportDialog] = useState(false);
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
const [creatingAddressBook, setCreatingAddressBook] = useState(false);
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
@@ -173,12 +177,13 @@ export default function ContactsPage() {
const addEmail = searchParams.get('addEmail');
const addName = searchParams.get('addName');
const from = searchParams.get('from');
const viewParam = searchParams.get('view');
if (!contactId && !addEmail && !from) return;
intentAppliedRef.current = true;
if (from === 'email') setReturnToEmail(true);
if (contactId) {
setSelectedContact(contactId);
setView('detail');
setView(viewParam === 'edit' ? 'edit' : 'detail');
} else if (addEmail) {
setCreatePrefill({ email: addEmail, name: addName ?? undefined });
setSelectedContact(null);
@@ -297,6 +302,34 @@ export default function ContactsPage() {
}
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
// Refresh address books (and contacts) after a structural change, staying
// multi-account aware so a freshly created book lands in the sidebar.
const refreshAddressBooks = useCallback(async () => {
if (!client) return;
if (multiAccountEnabled && accountClients.length > 0) {
const activeId = useAuthStore.getState().activeAccountId;
if (activeId) {
const { fetchAllAccountsAddressBooks } = useContactStore.getState();
await fetchAllAccountsAddressBooks(accountClients, activeId);
return;
}
}
await useContactStore.getState().fetchAddressBooks(client);
}, [client, multiAccountEnabled, accountClients]);
const handleCreateAddressBook = useCallback(async (name: string) => {
if (!client) return;
try {
await createAddressBook(client, name);
await refreshAddressBooks();
toast.success(t("address_books.created"));
setCreatingAddressBook(false);
} catch (error) {
console.error('Failed to create address book:', error);
toast.error(t("address_books.create_failed"));
}
}, [client, createAddressBook, refreshAddressBooks, t]);
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
return importContacts(
supportsSync && client ? client : null,
@@ -460,6 +493,51 @@ export default function ContactsPage() {
setView("group-edit");
}, []);
// Open the in-app composer in the current session rather than routing through
// a mailto: URL. `window.location='mailto:'` hands off to the OS handler
// (which may open a different mail app), and the mailto protocol round-trip
// reloads the app - dropping the in-memory per-account JMAP clients of a
// multi-account session, which reads as a logout. Stashing the recipients and
// doing a client-side router.push keeps the session and the active account
// intact; the main route consumes the pending compose and opens the composer
// (see consumePendingMailto in page.tsx).
const openComposeInApp = useCallback((recipients: string[], field: "to" | "cc" | "bcc") => {
savePendingMailto({
to: field === "to" ? recipients : [],
cc: field === "cc" ? recipients : [],
bcc: field === "bcc" ? recipients : [],
subject: "",
body: "",
});
router.push("/");
}, [router]);
const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => {
// Format each member as "Name <email>" so the composer keeps the display
// name (round-trips via formatRecipient -> parseRecipientList). Dedupe by
// email, case-insensitively; members without an email are skipped.
const seen = new Set<string>();
const recipients: string[] = [];
for (const member of getGroupMembers(groupId)) {
const email = getContactPrimaryEmail(member).trim();
const key = email.toLowerCase();
if (!email || seen.has(key)) continue;
seen.add(key);
recipients.push(formatRecipient(getContactDisplayName(member), email));
}
if (recipients.length === 0) {
toast.error(t("groups.no_member_emails"));
return;
}
openComposeInApp(recipients, field);
}, [getGroupMembers, t, openComposeInApp]);
const handleComposeContact = useCallback((contact: ContactCard) => {
const email = getContactPrimaryEmail(contact).trim();
if (!email) return;
openComposeInApp([formatRecipient(getContactDisplayName(contact), email)], "to");
}, [openComposeInApp]);
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
const confirmed = await confirmDialog({
title: t("groups.delete_confirm_title"),
@@ -624,6 +702,7 @@ export default function ContactsPage() {
onEdit={handleEditGroup}
onDelete={handleDeleteGroup}
onRemoveMember={handleRemoveGroupMember}
onComposeGroup={(field) => handleComposeGroupFromSidebar(selectedGroup.id, field)}
isMobile={isMobile}
onSelectMember={(id) => {
setSelectedContact(id);
@@ -701,6 +780,11 @@ export default function ContactsPage() {
contact={selectedContact}
onEdit={handleEdit}
onDelete={handleDelete}
onCompose={
selectedContact
? () => handleComposeContact(selectedContact)
: undefined
}
onAddToGroup={
selectedContact
? () => handleAddContactToGroup(selectedContact.id)
@@ -803,9 +887,11 @@ export default function ContactsPage() {
onSelectCategory={handleSelectCategory}
onCreateGroup={handleCreateGroup}
onCreateContact={handleCreateNew}
onCreateAddressBook={client ? () => setCreatingAddressBook(true) : undefined}
onImport={() => setShowImportDialog(true)}
onEditGroup={handleEditGroupFromSidebar}
onDeleteGroup={handleDeleteGroupFromSidebar}
onComposeGroup={handleComposeGroupFromSidebar}
onDropContacts={handleDropContacts}
onDropContactsToCategory={handleDropContactsToCategory}
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
@@ -951,6 +1037,15 @@ export default function ContactsPage() {
}}
/>
)}
{creatingAddressBook && (
<RenameDialog
currentName=""
title={t("address_books.create")}
label={t("address_books.name_label")}
onCancel={() => setCreatingAddressBook(false)}
onConfirm={handleCreateAddressBook}
/>
)}
{renamingAddressBook && (
<RenameDialog
currentName={renamingAddressBook.name}
+14
View File
@@ -22,6 +22,7 @@ import { useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { usePolicyStore } from "@/stores/policy-store";
import { FileBrowser } from "@/components/files/file-browser";
import type { FileNodeRights } from "@/lib/jmap/types";
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
@@ -88,6 +89,7 @@ export default function FilesPage() {
cancelUpload,
undoLastAction,
lastAction,
shareResource,
} = useFileStore();
const isMobile = useIsMobile();
@@ -401,6 +403,14 @@ export default function FilesPage() {
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
// Sharing: the browsing client (store-attached) drives the principal picker
// and share mutations. supportsPrincipals() gates the whole Share affordance.
const sharingEnabled = !!storeClient?.supportsPrincipals();
const filesAccountId = storeClient?.getFilesAccountId() ?? null;
const handleShare = useCallback(async (id: string, principalId: string, rights: FileNodeRights | null) => {
await shareResource(id, principalId, rights);
}, [shareResource]);
// Pro shell only: all connected accounts are equal top-level entries at
// the root. The root path "/" itself is a cross-account picker - no
// account's files are shown until the user enters one.
@@ -540,6 +550,10 @@ export default function FilesPage() {
onSelectAccount={handleSelectAccount}
accountPickerMode={isAccountPicker}
accountLabel={currentAccountLabel}
client={storeClient}
ownAccountId={filesAccountId}
sharingEnabled={sharingEnabled}
onShare={handleShare}
/>
</div>
)}
+3 -3
View File
@@ -11,7 +11,7 @@ import { useAccountStore } from "@/stores/account-store";
import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config";
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
import { apiFetch, getPathPrefix, toRouterPath, withBasePath } from "@/lib/browser-navigation";
import { cn } from "@/lib/utils";
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
import { type OAuthMetadata } from "@/lib/oauth/discovery";
@@ -279,7 +279,7 @@ export default function LoginPage() {
redirectTo = saved;
}
} catch { /* ignore */ }
router.push(redirectTo);
router.push(toRouterPath(redirectTo));
}
}, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
@@ -655,7 +655,7 @@ export default function LoginPage() {
redirectTo = saved;
}
} catch { /* ignore */ }
router.push(redirectTo);
router.push(toRouterPath(redirectTo));
}
};
+209 -49
View File
@@ -11,8 +11,9 @@ import type { ComposerDraftData } from "@/components/email/email-composer";
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, ALL_MAIL_MAILBOX_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types";
import { useAccountStore } from "@/stores/account-store";
import { usePolicyStore } from "@/stores/policy-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore, buildUnifiedAccountClients } from "@/stores/email-store";
@@ -31,6 +32,7 @@ import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navi
import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound";
import { cn } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import {
ErrorBoundary,
SidebarErrorFallback,
@@ -318,7 +320,10 @@ export default function Home() {
clearPendingUndoSend,
pendingUndoSend,
fetchUnifiedEmails: fetchUnifiedEmailsAction,
fetchCrossView: fetchCrossViewAction,
refreshUnifiedCounts,
refreshCrossCounts,
crossUnreadCount,
exitUnifiedView,
emptyMailbox,
markMailboxAsRead,
@@ -334,6 +339,7 @@ export default function Home() {
viewingAccountId,
selectAccountMailbox,
setViewingAccount,
refreshCurrentMailbox,
} = useEmailStore();
// Pro shell: populate per-account mailbox cache so the sidebar can render
@@ -341,7 +347,23 @@ export default function Home() {
useProMultiAccountMailboxes();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
const enableAllMailView = useSettingsStore((s) => s.enableAllMailView);
const delayedSendSupported = client?.hasDelayedSend() ?? true;
const allMailViewEnabled = usePolicyStore((s) => s.isFeatureEnabled('allMailViewEnabled'));
const showAllMailMailbox = allMailViewEnabled && enableAllMailView;
// Cross-account "All accounts" views: a sub-feature of the unified mailbox, so
// they require Unified Mailbox to be enabled, plus the admin gate and the
// per-user setting. Hooks are called unconditionally.
const crossUnreadGate = usePolicyStore((s) => s.isFeatureEnabled('crossUnreadViewEnabled'));
const crossStarredGate = usePolicyStore((s) => s.isFeatureEnabled('crossStarredViewEnabled'));
const crossAllGate = usePolicyStore((s) => s.isFeatureEnabled('crossAllViewEnabled'));
const enableCrossUnreadView = useSettingsStore((s) => s.enableCrossUnreadView);
const enableCrossStarredView = useSettingsStore((s) => s.enableCrossStarredView);
const enableCrossAllView = useSettingsStore((s) => s.enableCrossAllView);
const showCrossUnread = enableUnifiedMailbox && crossUnreadGate && enableCrossUnreadView;
const showCrossStarred = enableUnifiedMailbox && crossStarredGate && enableCrossStarredView;
const showCrossAll = enableUnifiedMailbox && crossAllGate && enableCrossAllView;
const activeEmails = isScheduledView ? scheduledEmails : emails;
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
@@ -713,7 +735,7 @@ export default function Home() {
// Mailbox view
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
if (mailbox) {
const mailboxName = mailbox.name;
const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`));
const unreadCount = mailbox.unreadEmails || 0;
title = unreadCount > 0
? `${mailboxName} (${unreadCount}) - ${appName}`
@@ -981,14 +1003,16 @@ export default function Home() {
// recounting happened"). The Pro shell always renders the unified mailbox
// regardless of the user setting, so refresh when embedded too.
useEffect(() => {
if (!enableUnifiedMailbox && !isEmbedded) return;
const anyCross = showCrossUnread || showCrossStarred || showCrossAll;
if (!enableUnifiedMailbox && !isEmbedded && !anyCross) return;
if (!isAuthenticated || !client) return;
buildPopulatedUnifiedAccounts().then((built) => {
const hasGroupEntry = built.some((b) => b.isShared);
if (anyCross) refreshCrossCounts(built);
if (built.length < 2 && !hasGroupEntry && !isEmbedded) return;
refreshUnifiedCounts(built);
});
}, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts]);
}, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts, refreshCrossCounts, showCrossUnread, showCrossStarred, showCrossAll]);
// System-notification click handler. The push SW navigates the user back
// here with `?email=<id>` (specific email it built the toast from) or
@@ -1034,8 +1058,8 @@ export default function Home() {
// Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before
// calling selectEmail on the stub), to avoid a duplicate request.
if (!selectedEmail.bodyValues && !isLoadingEmail) {
const perAccountClient = isUnifiedView && selectedEmail.accountId
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
const perAccountClient = isUnifiedView && selectedEmail.sourceClientAccountId
? useAuthStore.getState().getClientForAccount(selectedEmail.sourceClientAccountId)
: undefined;
const fetchClient = perAccountClient ?? client;
setLoadingEmail(true);
@@ -1164,7 +1188,33 @@ export default function Home() {
}
// Refresh the current mailbox to update the UI
if (!isScheduledView) await fetchEmails(client, selectedMailbox);
if (!isScheduledView) {
await refreshCurrentMailbox(client);
// Re-fetch the replied thread's cross-folder data so the expanded
// view shows the newly sent reply without collapsing.
if (originalEmailId) {
const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
// Route to the email's own account so shared/group threads refresh
// from the right server, not the active one. (#281)
const threadClient = emailState.isUnifiedView && repliedEmail.sourceClientAccountId
? (useAuthStore.getState().getClientForAccount(repliedEmail.sourceClientAccountId) ?? client)
: client;
const accountId = emailState.isUnifiedView && repliedEmail.sourceAccountId
? repliedEmail.sourceAccountId
: client.getAccountId();
const fullEmails = await threadClient.getThreadEmails(repliedEmail.threadId, accountId);
if (fullEmails.length > 0) {
useEmailStore.setState((state) => {
const c = new Map(state.threadEmailsCache);
c.set(repliedEmail.threadId!, fullEmails);
return { threadEmailsCache: c };
});
}
}
}
}
} catch (error) {
console.error("Failed to send email:", error);
}
@@ -1390,10 +1440,11 @@ export default function Home() {
if (!client || !emailToDelete) return;
// In unified view the trash destination and current-folder check must come
// from the email's own account, not the active one. (#281)
// from the email's own account, not the active one. The owning account's
// mailbox list is cached under its JMAP id (`sourceAccountId`). (#281)
const actionMailboxes =
isUnifiedView && emailToDelete.accountId
? (accountMailboxes[emailToDelete.accountId] ?? mailboxes)
isUnifiedView && emailToDelete.sourceAccountId
? (accountMailboxes[emailToDelete.sourceAccountId] ?? mailboxes)
: mailboxes;
// Check if we're currently in the trash or junk folder. In unified view the
@@ -1422,11 +1473,17 @@ export default function Home() {
console.error("Failed to permanently delete email:", error);
}
} else {
// Not in trash: always move to trash (in the email's own account).
// Not in trash: always move to trash (in the email's own account). Scope
// the trash lookup to the email's account: for a shared/group source every
// mailbox in the list is `isShared`, so we match by accountId instead of
// excluding shared (otherwise no trash is found and the delete fails). (#281)
const sourceAccountId = isUnifiedView ? emailToDelete.sourceAccountId : undefined;
const matchesScope = (m: Mailbox) =>
sourceAccountId ? m.accountId === sourceAccountId : !m.isShared;
const trashMailbox =
actionMailboxes.find(m => m.role === 'trash' && !m.isShared) ??
actionMailboxes.find(m => m.role === 'trash' && matchesScope(m)) ??
actionMailboxes.find(m => {
if (m.isShared) return false;
if (!matchesScope(m)) return false;
const lower = m.name.toLowerCase();
return lower.includes('trash') || lower.includes('deleted');
});
@@ -1449,11 +1506,14 @@ export default function Home() {
if (!client || !emailToArchive) return;
// In unified view the archive folder (and any year/month subfolders we
// create) must live in the email's own account, reached through that
// account's client. (#281)
const archiveAccountId = isUnifiedView ? emailToArchive.accountId : undefined;
const archiveClient = archiveAccountId
? (useAuthStore.getState().getClientForAccount(archiveAccountId) ?? client)
// create) must live in the email's own account, reached through the login it
// is reachable via (`sourceClientAccountId`) and routed to its owning JMAP
// account (`sourceAccountId`). For personal sources these resolve to the
// account itself, so behavior is unchanged. (#281)
const archiveClientId = isUnifiedView ? emailToArchive.sourceClientAccountId : undefined;
const archiveAccountId = isUnifiedView ? emailToArchive.sourceAccountId : undefined;
const archiveClient = archiveClientId
? (useAuthStore.getState().getClientForAccount(archiveClientId) ?? client)
: client;
// Read fresh mailboxes from the store batch archive calls this in a loop,
// and each iteration needs to see folders created by prior iterations.
@@ -1488,7 +1548,7 @@ export default function Home() {
m => m.name === year && m.parentId === archiveId
);
if (!yearMailbox) {
yearMailbox = await archiveClient.createMailbox(year, archiveId);
yearMailbox = await archiveClient.createMailbox(year, archiveId, archiveAccountId);
await refreshMailboxes();
}
@@ -1501,7 +1561,7 @@ export default function Home() {
m => m.name === month && m.parentId === yearId
);
if (!monthMailbox) {
monthMailbox = await archiveClient.createMailbox(month, yearId);
monthMailbox = await archiveClient.createMailbox(month, yearId, archiveAccountId);
await refreshMailboxes();
}
await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id);
@@ -1594,7 +1654,7 @@ export default function Home() {
});
} else {
const jmapKey = `$label:${color}`;
if (keywords[jmapKey] === true) {
if (keywords[jmapKey]) {
// Toggle off if already active
keywords[jmapKey] = false;
} else {
@@ -1690,6 +1750,28 @@ export default function Home() {
return;
}
if (isCrossViewId(mailboxId)) {
setScheduledView(false);
const view = CROSS_VIEW_BY_ID[mailboxId];
if (!view) return;
selectMailbox(mailboxId);
selectEmail(null);
if (isMobile) {
setSidebarOpen(false);
setActiveView("list");
}
if (isTablet) {
setTabletListVisible(true);
}
const populated = await buildPopulatedUnifiedAccounts();
await fetchCrossViewAction(populated, view);
refreshCrossCounts(populated);
return;
}
if (isUnifiedView) {
exitUnifiedView();
}
@@ -2197,7 +2279,29 @@ export default function Home() {
}
// Refresh emails to show the sent reply
await fetchEmails(client, selectedMailbox);
await refreshCurrentMailbox(client);
// Re-fetch the replied thread's cross-folder data so the expanded
// view shows the newly sent reply without collapsing.
const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
// Route to the email's own account so shared/group threads refresh from
// the right server, not the active one. (#281)
const threadClient = emailState.isUnifiedView && repliedEmail.sourceClientAccountId
? (useAuthStore.getState().getClientForAccount(repliedEmail.sourceClientAccountId) ?? client)
: client;
const accountId = emailState.isUnifiedView && repliedEmail.sourceAccountId
? repliedEmail.sourceAccountId
: client.getAccountId();
const fullEmails = await threadClient.getThreadEmails(repliedEmail.threadId, accountId);
if (fullEmails.length > 0) {
useEmailStore.setState((state) => {
const c = new Map(state.threadEmailsCache);
c.set(repliedEmail.threadId!, fullEmails);
return { threadEmailsCache: c };
});
}
}
};
// Show loading state while checking auth
@@ -2213,7 +2317,16 @@ export default function Home() {
}
// Get current mailbox name for mobile header
const currentMailboxName = isScheduledView ? t('sidebar.scheduled') : mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
const currentMailboxName = isScheduledView
? t('sidebar.scheduled')
: selectedMailbox === ALL_MAIL_MAILBOX_ID
? t('sidebar.mailboxes.all_mail')
: (() => {
const mb = mailboxes.find(m => m.id === selectedMailbox);
return mb
? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`))
: "Inbox";
})();
const isFocusedMailLayout = mailLayout === 'focus';
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
@@ -2230,11 +2343,10 @@ export default function Home() {
setShowComposer(false);
}
// waiting for the body fetch - avoids the loading flicker.
// Find the list-level email for metadata (accountId, scheduled flags, etc.)
// but don't select it yet — wait for the full fetch to avoid a toolbar flash
// caused by rendering with the stub (no bodyValues) then re-rendering with the full email.
const listEmail = activeEmails.find(e => e.id === email.id);
if (listEmail) {
selectEmail(listEmail);
}
setLoadingEmail(true);
@@ -2250,21 +2362,25 @@ export default function Home() {
// Fetch the full content
try {
// In unified view each email carries its own accountId. Use that
// account's client so we fetch from the server that actually owns it.
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
const perAccountClient = emailAccountId
? useAuthStore.getState().getClientForAccount(emailAccountId)
// In unified view each email carries its source reference: the login it is
// reachable through (`sourceClientAccountId`) and its owning JMAP account
// (`sourceAccountId`). Resolve both so we fetch from the server that actually
// owns it — works uniformly for personal and shared/group sources, since for
// personal the owning account equals the client's primary (no-op). (#281)
const sourceClientId = isUnifiedView ? listEmail?.sourceClientAccountId : undefined;
const perAccountClient = sourceClientId
? useAuthStore.getState().getClientForAccount(sourceClientId)
: undefined;
const fetchClient = perAccountClient ?? client;
// For shared folders on the primary client, we still need to pass the
// shared account's id. In unified view we use the per-account client
// directly, so no explicit accountId is needed.
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = perAccountClient
? undefined
: mailbox?.isShared ? mailbox.accountId : undefined;
const accountId = isUnifiedView
? listEmail?.sourceAccountId
: (() => {
// Non-unified: shared folders on the active client still need their
// owner accountId passed explicitly.
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
return mailbox?.isShared ? mailbox.accountId : undefined;
})();
const fullEmail = await fetchClient.getEmail(email.id, accountId);
if (fullEmail) {
@@ -2276,9 +2392,13 @@ export default function Home() {
fullEmail.isScheduled = true;
fullEmail.isSmimeScheduled = listEmail.isSmimeScheduled;
}
if (emailAccountId) {
fullEmail.accountId = emailAccountId;
fullEmail.accountLabel = listEmail?.accountLabel;
// Re-stamp the source reference so later actions on the open email
// resolve to the right account (the fetched object lacks these).
if (isUnifiedView && listEmail) {
fullEmail.accountId = listEmail.accountId;
fullEmail.accountLabel = listEmail.accountLabel;
fullEmail.sourceClientAccountId = listEmail.sourceClientAccountId;
fullEmail.sourceAccountId = listEmail.sourceAccountId;
}
selectEmail(fullEmail);
// Mark-as-read logic is now handled by useEffect
@@ -2327,8 +2447,27 @@ export default function Home() {
setActiveView("viewer");
try {
// Fetch complete thread emails
const emails = await client.getThreadEmails(thread.threadId);
// In unified/aggregate views the thread may belong to another (possibly
// shared/group) account. Route the fetch to the login it's reachable
// through (`sourceClientAccountId`) and pass its owning JMAP account
// (`sourceAccountId`) so the thread loads from the right server instead of
// the active one (which doesn't have it → empty/body-less). (#281)
const ref = thread.emails?.[0];
const threadClient = isUnifiedView && ref?.sourceClientAccountId
? (useAuthStore.getState().getClientForAccount(ref.sourceClientAccountId) ?? client)
: client;
const threadAccountId = isUnifiedView ? ref?.sourceAccountId : undefined;
const emails = await threadClient.getThreadEmails(thread.threadId, threadAccountId);
// Re-stamp the source reference so conversation actions (reply/move/…)
// resolve to the right account; the fetched objects don't carry it.
if (isUnifiedView && ref) {
for (const e of emails) {
e.accountId = ref.accountId;
e.accountLabel = ref.accountLabel;
e.sourceClientAccountId = ref.sourceClientAccountId;
e.sourceAccountId = ref.sourceAccountId;
}
}
setConversationEmails(emails);
} catch (error) {
console.error('Failed to fetch thread emails:', error);
@@ -2475,6 +2614,11 @@ export default function Home() {
selectedKeyword={selectedKeyword}
scheduledTotal={scheduledTotal}
showScheduledMailbox={delayedSendSupported}
showAllMailMailbox={showAllMailMailbox}
showCrossUnread={showCrossUnread}
showCrossStarred={showCrossStarred}
showCrossAll={showCrossAll}
crossUnreadCount={crossUnreadCount}
onMailboxSelect={handleMailboxSelect}
onTagSelect={handleTagSelect}
onUnreadFilterClick={handleUnreadFilterClick}
@@ -2642,19 +2786,19 @@ export default function Home() {
icon={<Paperclip className="w-3.5 h-3.5" />}
label={t("advanced_search.has_attachment")}
value={searchFilters.hasAttachment}
onClick={() => { const next = searchFilters.hasAttachment === null ? true : searchFilters.hasAttachment === true ? false : null; setSearchFilters({ hasAttachment: next }); handleAdvancedSearch(); }}
onClick={() => { const next = searchFilters.hasAttachment === null ? true : searchFilters.hasAttachment ? false : null; setSearchFilters({ hasAttachment: next }); handleAdvancedSearch(); }}
/>
<ToggleChip
icon={<Star className="w-3.5 h-3.5" />}
label={t("advanced_search.starred")}
value={searchFilters.isStarred}
onClick={() => { const next = searchFilters.isStarred === null ? true : searchFilters.isStarred === true ? false : null; setSearchFilters({ isStarred: next }); handleAdvancedSearch(); }}
onClick={() => { const next = searchFilters.isStarred === null ? true : searchFilters.isStarred ? false : null; setSearchFilters({ isStarred: next }); handleAdvancedSearch(); }}
/>
<ToggleChip
icon={searchFilters.isUnread === false ? <MailOpen className="w-3.5 h-3.5" /> : <Mail className="w-3.5 h-3.5" />}
label={searchFilters.isUnread === false ? t("advanced_search.read") : t("advanced_search.unread")}
value={searchFilters.isUnread}
onClick={() => { const next = searchFilters.isUnread === null ? true : searchFilters.isUnread === true ? false : null; setSearchFilters({ isUnread: next }); handleAdvancedSearch(); }}
onClick={() => { const next = searchFilters.isUnread === null ? true : searchFilters.isUnread ? false : null; setSearchFilters({ isUnread: next }); handleAdvancedSearch(); }}
/>
</div>
<div className="flex items-center gap-1">
@@ -3056,7 +3200,17 @@ export default function Home() {
onReply={handleReply}
onReplyAll={handleReplyAll}
onForward={handleForward}
onDelete={() => handleDelete()}
onDelete={() => {
// Deleting the open message returns to the list (Gmail-style),
// not the next email — unless the user turned the setting off.
// Deselect first so the store's remove-and-advance sees no
// selection and doesn't auto-open the next message.
const target = selectedEmail;
if (useSettingsStore.getState().returnToListAfterAction) {
handleMobileBack();
}
handleDelete(target);
}}
onArchive={() => handleArchive()}
onToggleStar={handleToggleStar}
onSetColorTag={handleSetColorTag}
@@ -3065,6 +3219,12 @@ export default function Home() {
onMarkAsRead={async (emailId, read) => {
if (client) {
await markAsRead(client, emailId, read);
// Marking the open message unread returns to the list
// (Gmail-style, gated on returnToListAfterAction). Staying
// in the reading pane would just re-mark it read on view.
if (!read && useSettingsStore.getState().returnToListAfterAction) {
handleMobileBack();
}
}
}}
onDownloadAttachment={handleDownloadAttachment}
@@ -3099,7 +3259,7 @@ export default function Home() {
}}
currentUserEmail={client?.getUsername()}
currentUserName={client?.getUsername()?.split("@")[0]}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined)}
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
onMoveToMailbox={async (mailboxId) => {
+65 -9
View File
@@ -74,6 +74,7 @@ import { useEmailStore } from '@/stores/email-store';
import { usePluginStore } from '@/stores/plugin-store';
import { useThemeStore } from '@/stores/theme-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import { useIsDesktop } from '@/hooks/use-media-query';
import { NavigationRail } from '@/components/layout/navigation-rail';
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
@@ -182,6 +183,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
'settings.appearance.hide_account_switcher',
'settings.appearance.show_rail_account_list',
'settings.appearance.unified_mailbox',
'settings.appearance.all_mail',
'settings.appearance.colorful_sidebar_icons',
'settings.email_behavior.mail_layout',
],
@@ -198,11 +200,11 @@ const tabSearchPaths: Record<Tab, string[]> = {
'settings.email_behavior.hover_actions',
'settings.email_behavior.permanently_delete_junk',
'settings.email_behavior.show_preview',
'settings.email_behavior.plain_text_mode',
],
composing: [
'settings.email_behavior.attachment_reminder',
'settings.email_behavior.auto_select_reply_identity',
'settings.email_behavior.plain_text_mode',
'settings.email_behavior.default_mail_program',
'settings.email_behavior.signature_position',
'settings.email_behavior.sub_address_delimiter',
@@ -385,6 +387,12 @@ export default function SettingsPage() {
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
const proInterface = useSettingsStore((s) => s.proInterface);
// When set, the settings panel is scoped to a shared/group account: a reduced
// tab list and a "Managing: <name>" header. null = the user's own account.
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const managedAccount = useManagedAccountStore((s) => s.managedAccount);
const clearManagedAccount = useManagedAccountStore((s) => s.clear);
// Build a per-tab haystack for fulltext search and a list of sub-results
// (individual settings) per tab. Sub-results come from translation entries
// that have a `label`/`title` field, plus dynamic content (installed
@@ -488,6 +496,10 @@ export default function SettingsPage() {
return () => window.removeEventListener('settings-tab-change', handler);
}, []);
// Leaving the settings panel drops any shared-account scope so it never
// leaks into the next visit or another session.
useEffect(() => () => clearManagedAccount(), [clearManagedAccount]);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
@@ -615,7 +627,7 @@ export default function SettingsPage() {
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
// Apps
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
...(supportsCalendar && isFeatureEnabled('calendarEnabled') ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
@@ -626,12 +638,28 @@ export default function SettingsPage() {
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
];
// In scoped (shared-account) mode, restrict to the account-relevant tabs the
// account actually advertises. Folders is intentionally excluded (mailbox CRUD
// is hardwired to the active account). Gated on both the per-account
// capability and the session-level support/feature flags.
const scopedTabIds: Tab[] = managedAccount
? ([
managedAccount.capabilities.sieve && supportsSieve ? 'filters' : null,
managedAccount.capabilities.mail && supportsVacation ? 'vacation' : null,
managedAccount.capabilities.calendars && supportsCalendar && isFeatureEnabled('calendarEnabled') ? 'calendar' : null,
managedAccount.capabilities.contacts && isFeatureEnabled('contactsEnabled') ? 'contacts' : null,
].filter(Boolean) as Tab[])
: [];
const visibleTabs = managedAccountId
? tabs.filter((tab) => scopedTabIds.includes(tab.id))
: tabs;
// Group tabs by category
const groupedTabs = tabGroupOrder
.map((group) => ({
group,
label: t(`tab_groups.${group}`),
items: tabs.filter((tab) => tab.group === group),
items: visibleTabs.filter((tab) => tab.group === group),
}))
.filter((g) => g.items.length > 0);
@@ -659,9 +687,13 @@ export default function SettingsPage() {
.filter((g) => g.items.length > 0)
: groupedTabs;
// If active tab is not in the visible list (e.g., feature disabled), fall back.
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
// If active tab is not in the visible list (e.g., feature disabled, or scoped
// mode hides it), fall back. In scoped mode fall back to the first scoped tab;
// otherwise the usual 'appearance' default.
const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab);
const effectiveActiveTab: Tab = isActiveVisible
? activeTab
: (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance');
const handleTabSelect = (tabId: Tab) => {
setActiveTab(tabId);
@@ -676,10 +708,26 @@ export default function SettingsPage() {
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
};
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
const renderTabContent = () => (
<>
{managedAccountId && managedAccount && (
<button
type="button"
onClick={() => {
clearManagedAccount();
handleTabSelect('account');
}}
className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-left transition-colors"
>
<ArrowLeft className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-muted-foreground">{t('scoped.back')}</span>
<span className="ml-auto text-sm font-medium truncate">
{t('scoped.managing', { name: managedAccount.name })}
</span>
</button>
)}
{effectiveActiveTab === 'account' && <AccountSettings />}
{effectiveActiveTab === 'language' && <LanguageSettings />}
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
@@ -697,8 +745,16 @@ export default function SettingsPage() {
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{effectiveActiveTab === 'calendar' && (
managedAccountId
? <CalendarManagementSettings />
: <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>
)}
{effectiveActiveTab === 'contacts' && (
managedAccountId
? <AddressBookManagementSettings />
: <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>
)}
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
-20
View File
@@ -32,7 +32,6 @@ export function DashboardTab() {
const [themeCount, setThemeCount] = useState(0);
const [policyRuleCount, setPolicyRuleCount] = useState(0);
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
useEffect(() => {
fetchDashboardData();
@@ -82,15 +81,6 @@ export function DashboardTab() {
}
}
if (configData?.jmapServerUrl) {
try {
const jmapRes = await apiFetch('/api/config');
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
} catch {
setJmapHealth('error');
}
}
const w: string[] = [];
if (adminConfigRes.ok) {
const sources = await adminConfigRes.json();
@@ -128,16 +118,6 @@ export function DashboardTab() {
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
<span className="text-sm text-foreground">{jmapHostname}</span>
</SettingItem>
<SettingItem label="JMAP Connection">
<span className={`inline-flex items-center gap-1.5 text-sm font-medium ${
jmapHealth === 'ok' ? 'text-green-600 dark:text-green-400' : jmapHealth === 'error' ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground'
}`}>
<span className={`w-2 h-2 rounded-full ${
jmapHealth === 'ok' ? 'bg-green-500' : jmapHealth === 'error' ? 'bg-red-500' : 'bg-muted-foreground/40'
}`} />
{jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : 'Unknown'}
</span>
</SettingItem>
<SettingItem label="Last Login">
<span className="text-sm text-foreground">
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
+5
View File
@@ -13,6 +13,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
calendarEnabled: { label: 'Calendar', description: 'Enable calendar features and views' },
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
@@ -21,6 +22,10 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
allMailViewEnabled: { label: 'All Mail View', description: 'Show a virtual "All Mail" folder that merges messages from across an accounts folders into one list. Users choose which folders are included. Requires the per-user toggle in Settings → Appearance.' },
crossUnreadViewEnabled: { label: 'All Accounts: Unread', description: 'Allow an "All unread" entry in the All accounts section that lists unread mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
crossStarredViewEnabled: { label: 'All Accounts: Starred', description: 'Allow an "All starred" entry in the All accounts section that lists flagged/starred mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
crossAllViewEnabled: { label: 'All Accounts: All Mail', description: 'Allow an "All mail" entry in the All accounts section that lists all mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
};
const RESTRICTABLE_SETTINGS = [
+6 -5
View File
@@ -118,9 +118,10 @@ export function TelemetryTab() {
<header className="space-y-2">
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
<p className="text-sm text-muted-foreground">
Bulwark sends one anonymous heartbeat per day so we can see how many instances are
running, on what platforms, and which features they use. <strong>Enabled by default</strong>;
one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '}
Bulwark can send one anonymous heartbeat per day so we can see how many instances are
running, on what platforms, and which features they use. It&apos;s <strong>off by
default</strong>; one click below enables it and helps us make the product better. No
email addresses, no hostnames, no IPs are sent.{' '}
<a
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
target="_blank"
@@ -138,8 +139,8 @@ export function TelemetryTab() {
<div className="font-medium">Status</div>
<div className="text-sm text-muted-foreground">
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
{status.consent === 'off' && 'Heartbeats are off.'}
{status.consent === 'on' && 'Heartbeats are enabled. Thanks for helping us improve!'}
{status.consent === 'off' && 'Heartbeats are off (default).'}
{envOverridden && (
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
)}
+16 -1
View File
@@ -38,6 +38,7 @@ interface WizardConfig {
// Security
sessionSecret: string;
settingsSyncEnabled: boolean;
telemetryEnabled: boolean;
// Logging
logFormat: 'text' | 'json';
logLevel: 'error' | 'warn' | 'info' | 'debug';
@@ -66,6 +67,7 @@ const EMPTY_CONFIG: WizardConfig = {
oauthIssuerUrl: '',
sessionSecret: '',
settingsSyncEnabled: true,
telemetryEnabled: false,
logFormat: 'text',
logLevel: 'info',
faviconUrl: '',
@@ -1002,8 +1004,11 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
e.preventDefault();
setSubmitting(true);
try {
const values: Partial<WizardConfig> = {
// telemetryConsent is persisted to the telemetry state file by the API,
// not to admin config - see app/api/setup/step/route.ts.
const values: Record<string, unknown> = {
settingsSyncEnabled: config.settingsSyncEnabled,
telemetryConsent: config.telemetryEnabled ? 'on' : 'off',
};
if (config.sessionSecret) values.sessionSecret = config.sessionSecret;
await onNext('security', values);
@@ -1068,6 +1073,15 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
hint="Stores user preferences server-side, encrypted with the session secret."
disabled={!config.sessionSecret}
/>
<div className="rounded-md border border-border bg-muted/20 p-3">
<Toggle
checked={config.telemetryEnabled}
onChange={(v) => setConfig({ ...config, telemetryEnabled: v })}
label="Send anonymous usage stats to help improve Bulwark"
hint="Off by default. One anonymous heartbeat per day with version, platform, and which features are enabled - never email addresses, hostnames, or IPs. You can change this anytime in admin settings."
/>
</div>
<Footer>
<SecondaryButton onClick={onBack}>Back</SecondaryButton>
<PrimaryButton type="submit" disabled={submitting}>
@@ -1480,6 +1494,7 @@ function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack
: 'Off'
}
/>
<SummaryRow label="Anonymous telemetry" value={config.telemetryEnabled ? 'On' : 'Off'} />
</SummaryGroup>
<SummaryGroup icon={<FileText className="w-4 h-4" />} title="Logging">
+1 -1
View File
@@ -19,7 +19,7 @@ import {
invalidateFrameOriginsCache,
} from '@/lib/admin/csp-frame-origins';
import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS } from '@/lib/plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
import { configManager } from '@/lib/admin/config-manager';
+96
View File
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { buildOAuthParams, getRequiredConfig, getTokenEndpoint } from '@/lib/oauth/token-exchange';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { createPairing } from '@/lib/auth/pairing-store';
import { hasValidPairReauth } from '@/lib/auth/pair-reauth';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
// Desktop side of the cross-device QR login. The caller must be a signed-in
// webmail session (its refresh token lives in the httpOnly jmap_rt cookie). We
// refresh that token to (a) prove the session is live and (b) obtain a fresh
// access token to hand the phone, then stash the bundle under a one-time
// pairing code. The desktop renders the returned code as a QR; the phone
// redeems it at /api/auth/pair/redeem.
//
// Token sharing note: the phone receives the SAME refresh token as the desktop.
// That is correct for OAuth servers (such as Stalwart in its default config)
// that do not rotate refresh tokens on use. If the server rotates refresh
// tokens, the two devices would fight over the latest token — such deployments
// should disable rotation for this client or use a token-exchange grant.
export async function POST(request: NextRequest) {
const cookieStore = await cookies();
try {
// Step-up gate: minting a pairing code grants new-device access, so it
// requires a recent fresh IdP re-authentication (see the reauth SSO flow).
// The client turns this 401 into a re-auth redirect, then retries.
if (!(await hasValidPairReauth())) {
return NextResponse.json({ error: 'reauth_required' }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const slot =
typeof body.slot === 'number' && body.slot >= 0 && body.slot < MAX_ACCOUNT_SLOTS
? body.slot
: 0;
const cookieName = refreshTokenCookieName(slot);
const refreshToken = cookieStore.get(cookieName)?.value;
if (!refreshToken) {
return NextResponse.json({ error: 'Not signed in' }, { status: 401 });
}
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
const tokenEndpoint = await getTokenEndpoint(serverId);
const params = buildOAuthParams({ grant_type: 'refresh_token', refresh_token: refreshToken }, serverId);
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
logger.warn('Pair create: refresh failed', { status: tokenResponse.status, error: errorText });
// Stale session — clear the dead cookie so the user is prompted to log
// back in, mirroring the token route's behaviour.
cookieStore.delete(cookieName);
cookieStore.delete(refreshTokenServerCookieName(slot));
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
}
const tokens = await tokenResponse.json();
if (!tokens.access_token) {
logger.error('Pair create: refresh response missing access_token');
return NextResponse.json({ error: 'Invalid token response' }, { status: 502 });
}
// If the server rotated the refresh token, persist the new one back to the
// desktop's cookie so this very session keeps working. The phone will get
// the same (new) token below.
const effectiveRefreshToken = tokens.refresh_token || refreshToken;
if (tokens.refresh_token) {
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
}
const { clientId, serverUrl } = getRequiredConfig(serverId);
const { code, expiresIn } = createPairing({
accessToken: tokens.access_token,
refreshToken: effectiveRefreshToken,
expiresIn: tokens.expires_in,
tokenEndpoint,
clientId,
serverUrl,
serverId,
});
return NextResponse.json({ pairing_code: code, server_url: serverUrl, expires_in: expiresIn });
} catch (error) {
logger.error('Pair create error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { consumePairing } from '@/lib/auth/pairing-store';
// Phone side of the cross-device QR login. The app POSTs the pairing code it
// scanned; we hand back the OAuth token bundle the desktop stashed at
// /api/auth/pair/create. The code is the only credential required — it is
// high-entropy, single-use, and expires within ~2 minutes — so this route is
// intentionally unauthenticated (the scanning device has no webmail cookies).
export async function POST(request: NextRequest) {
try {
const { pairing_code: pairingCode } = await request.json().catch(() => ({}));
if (!pairingCode || typeof pairingCode !== 'string') {
return NextResponse.json({ error: 'Missing pairing code' }, { status: 400 });
}
const tokens = consumePairing(pairingCode);
if (!tokens) {
// Unknown, expired, or already redeemed — do not distinguish.
return NextResponse.json({ error: 'Invalid or expired pairing code' }, { status: 400 });
}
return NextResponse.json({
flow: 'oauth',
server_url: tokens.serverUrl,
access_token: tokens.accessToken,
...(tokens.refreshToken ? { refresh_token: tokens.refreshToken } : {}),
...(typeof tokens.expiresIn === 'number' ? { expires_in: tokens.expiresIn } : {}),
token_endpoint: tokens.tokenEndpoint,
client_id: tokens.clientId,
});
} catch (error) {
logger.error('Pair redeem error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+70
View File
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { decryptPayload } from '@/lib/auth/crypto';
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
import { setPairReauth } from '@/lib/auth/pair-reauth';
// Completes the step-up re-authentication for device pairing. The user was sent
// to the IdP with prompt=login (see /api/auth/sso/start with purpose=reauth);
// here we verify the returned code against the pending state and exchange it to
// confirm a fresh login actually happened, then set the short-lived pairing
// re-auth proof cookie. We deliberately do NOT issue a login session or write
// any refresh-token cookies — the user is already signed in; this only proves
// recency for the pairing action.
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000;
export async function POST(request: NextRequest) {
const cookieStore = await cookies();
try {
const { code, state } = await request.json();
if (!code || !state) {
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
}
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
if (!pendingCookie) {
return NextResponse.json({ error: 'No pending re-auth session' }, { status: 400 });
}
const pending = decryptPayload(pendingCookie);
cookieStore.delete(SSO_PENDING_COOKIE);
if (!pending) {
return NextResponse.json({ error: 'Invalid re-auth session' }, { status: 400 });
}
// Only honor pending sessions that were started for the reauth purpose, so
// a normal login code can't be redirected into setting a pairing proof.
if (pending.purpose !== 'reauth') {
return NextResponse.json({ error: 'Not a re-auth session' }, { status: 400 });
}
if (pending.state !== state) {
return NextResponse.json({ error: 'State mismatch' }, { status: 400 });
}
const createdAt = pending.created_at as number;
if (!createdAt || Date.now() - createdAt > SSO_PENDING_MAX_AGE_MS) {
return NextResponse.json({ error: 'Re-auth session expired' }, { status: 400 });
}
const codeVerifier = pending.code_verifier as string;
const redirectUri = pending.redirect_uri as string;
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
if (!codeVerifier || !redirectUri) {
return NextResponse.json({ error: 'Invalid re-auth session data' }, { status: 400 });
}
// A successful exchange proves the user just authenticated at the IdP (the
// freshness is enforced by prompt=login on the authorize request). We don't
// keep the resulting tokens.
await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
await setPairReauth();
return NextResponse.json({ ok: true });
} catch (error) {
cookieStore.delete(SSO_PENDING_COOKIE);
logger.error('Reauth complete error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Re-authentication failed' }, { status: 401 });
}
}
+22 -2
View File
@@ -8,6 +8,7 @@ import { discoverOAuth } from '@/lib/oauth/discovery';
import { getOauthScopes } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { configManager } from '@/lib/admin/config-manager';
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
@@ -30,8 +31,14 @@ export async function POST(request: NextRequest) {
server_id: bodyServerId,
mobile_redirect_uri: rawMobileRedirectUri,
mobile_state: rawMobileState,
purpose: rawPurpose,
} = await request.json();
// `reauth` drives the step-up flow for device pairing: it forces a fresh
// IdP login (prompt=login) and the /reauth/sso/complete handler sets the
// short-lived pairing re-auth proof instead of logging the user in again.
const isReauth = rawPurpose === 'reauth';
if (!redirect_uri || typeof redirect_uri !== 'string') {
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
}
@@ -85,6 +92,7 @@ export async function POST(request: NextRequest) {
...(serverId ? { server_id: serverId } : {}),
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
...(mobileState ? { mobile_state: mobileState } : {}),
...(isReauth ? { purpose: 'reauth' } : {}),
};
const encrypted = encryptPayload(pendingData);
@@ -95,8 +103,12 @@ export async function POST(request: NextRequest) {
maxAge: SSO_PENDING_MAX_AGE,
});
// Build authorize URL
const authUrl = new URL(metadata.authorization_endpoint);
// Build authorize URL. OAUTH_AUTHORIZE_URL, when set, overrides only the
// user-facing authorize endpoint (e.g. a per-brand login host). Discovery,
// token exchange and refresh keep using the canonical discovered endpoints.
const authorizeOverride =
configManager.get<string>('oauthAuthorizeUrl', '') || process.env.OAUTH_AUTHORIZE_URL;
const authUrl = new URL(authorizeOverride?.trim() || metadata.authorization_endpoint);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', clientId);
authUrl.searchParams.set('redirect_uri', redirect_uri);
@@ -109,6 +121,14 @@ export async function POST(request: NextRequest) {
authUrl.searchParams.set('ui_locales', locale);
}
// Force a fresh credential entry for step-up re-auth. prompt=login and
// max_age=0 both ask the IdP to re-authenticate even if it has an active
// session; honoring them depends on the IdP supporting these OIDC params.
if (isReauth) {
authUrl.searchParams.set('prompt', 'login');
authUrl.searchParams.set('max_age', '0');
}
return NextResponse.json({
authorize_url: authUrl.toString(),
state,
+334
View File
@@ -0,0 +1,334 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
import { parseISO } from 'date-fns';
import type { CalendarEvent } from '@/lib/jmap/types';
/**
* POST /api/calendar-agenda
*
* Sidecar for the "Calendar Agenda" plugin. Resolves the caller's calendar
* account from the stored Stalwart auth context, queries upcoming
* CalendarEvents over JMAP, expands recurring series server-side, and returns
* a slim, structured-cloneable agenda the plugin can render directly.
*
* Credentials never leave the server the sandboxed plugin only ever sees
* the resulting agenda DTOs.
*
* Body: { days?: number (1-90, default 7), limit?: number (1-200, default 50) }
*/
const CALENDAR_CAP = 'urn:ietf:params:jmap:calendars';
const PRINCIPALS_CAP = 'urn:ietf:params:jmap:principals';
// Mirror of lib/jmap/client.ts CALENDAR_EVENT_PROPERTIES, trimmed to what the
// agenda actually needs (start/recurrence/display fields).
const EVENT_PROPERTIES = [
'id', '@type', 'uid', 'calendarIds', 'title', 'start', 'duration', 'timeZone',
'showWithoutTime', 'utcStart', 'utcEnd', 'status', 'freeBusyStatus', 'color',
'locations', 'recurrenceId', 'recurrenceIdTimeZone', 'recurrenceRule',
'recurrenceOverrides', 'excludedRecurrenceRule',
] as const;
interface JmapSession {
apiUrl?: string;
primaryAccounts?: Record<string, string>;
capabilities?: Record<string, unknown>;
}
interface AgendaEvent {
id: string;
uid: string | null;
title: string;
start: string;
end: string;
allDay: boolean;
status: string | null;
color: string | null;
location: string | null;
calendarId: string | null;
}
// Pure, server-safe equivalents of lib/calendar-utils' getEventStartDate /
// getEventEndDate. We can't import that module here because it transitively
// pulls in a "use client" calendar component (which throws at load on the
// server). Logic mirrors the originals.
function parseDurationMinutes(duration: string | undefined): number {
if (!duration) return 0;
let total = 0;
const week = duration.match(/(\d+)W/);
const day = duration.match(/(\d+)D/);
const hour = duration.match(/(\d+)H/);
const min = duration.match(/(\d+)M/);
if (week) total += parseInt(week[1], 10) * 7 * 24 * 60;
if (day) total += parseInt(day[1], 10) * 24 * 60;
if (hour) total += parseInt(hour[1], 10) * 60;
if (min) total += parseInt(min[1], 10);
return total;
}
function eventStart(event: Partial<CalendarEvent>): Date {
if (!event.showWithoutTime && event.utcStart) {
const utc = parseISO(event.utcStart);
if (!isNaN(utc.getTime())) return utc;
}
return parseISO(event.start as string);
}
function eventEnd(event: Partial<CalendarEvent>): Date {
if (!event.showWithoutTime && event.utcEnd) {
const utc = parseISO(event.utcEnd);
if (!isNaN(utc.getTime())) return utc;
}
const start = eventStart(event);
if (!event.duration) return start;
return new Date(start.getTime() + parseDurationMinutes(event.duration) * 60000);
}
function firstLocationName(event: Partial<CalendarEvent>): string | null {
const locations = event.locations;
if (!locations || typeof locations !== 'object') return null;
for (const loc of Object.values(locations)) {
const name = (loc as { name?: unknown })?.name;
if (typeof name === 'string' && name.trim()) return name.trim();
}
return null;
}
function firstCalendarId(event: Partial<CalendarEvent>): string | null {
const ids = event.calendarIds;
if (!ids || typeof ids !== 'object') return null;
const keys = Object.keys(ids);
return keys.length > 0 ? keys[0] : null;
}
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
let body: { days?: unknown; limit?: unknown } = {};
try {
body = await request.json();
} catch {
/* empty body is fine */
}
const days = clampInt(body.days, 1, 90, 7);
const limit = clampInt(body.limit, 1, 200, 50);
// ── Resolve the calendar account from the JMAP session ──
// Hit Stalwart's canonical session endpoint on the SAME host as serverUrl.
// We deliberately avoid /.well-known/jmap: it 301s to the server's
// configured public hostname, which the host process may not be able to
// resolve (the browser client rewrites those URLs back to the origin for
// the same reason). Fall back to /.well-known/jmap for non-Stalwart servers.
const session = await fetchJmapSession(creds.serverUrl, creds.authHeader);
if (!session) {
return NextResponse.json({ error: 'JMAP session fetch failed' }, { status: 502 });
}
const accountId = session.primaryAccounts?.[CALENDAR_CAP];
if (!accountId) {
// No calendar account for this user — return an empty agenda, not an error.
return NextResponse.json({ events: [], generatedAt: new Date().toISOString() });
}
const using = ['urn:ietf:params:jmap:core', CALENDAR_CAP];
if (session.capabilities && PRINCIPALS_CAP in session.capabilities) {
using.push('urn:ietf:params:jmap:principals:owner');
}
// Send method calls to the same-origin JMAP endpoint the app's passthrough
// uses — never to session.apiUrl's (possibly unreachable) public host.
const apiUrl = `${creds.serverUrl}/jmap/`;
const now = new Date();
const horizon = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
// Expand from the start of today so all-day / already-running events still
// show in the agenda.
const windowStart = new Date(now);
windowStart.setHours(0, 0, 0, 0);
// ── 1) Query event IDs in range + load calendars (colours) ──
const queryReq = {
using,
methodCalls: [
[
'CalendarEvent/query',
{
accountId,
// Mirror the app's calendar store: an { after, before } window lets
// Stalwart evaluate recurrence so masters with occurrences in range
// are returned (a `before`-only filter can drop unbounded series).
filter: { after: windowStart.toISOString(), before: horizon.toISOString() },
limit: 1000,
},
'0',
],
[
'Calendar/get',
{ accountId, ids: null, properties: ['id', 'name', 'color'] },
'c',
],
],
};
const queryRes = await jmapPost(apiUrl, creds.authHeader, queryReq);
const queryResp = findResponse(queryRes, 'CalendarEvent/query', '0');
if (!queryResp) {
const err = findResponse(queryRes, 'error', '0');
return NextResponse.json(
{ error: (err?.description as string) || 'CalendarEvent/query failed' },
{ status: 502 },
);
}
const ids = (queryResp.ids as string[]) || [];
const calColors = new Map<string, { name: string; color: string | null }>();
const calResp = findResponse(queryRes, 'Calendar/get', 'c');
for (const cal of ((calResp?.list as Array<Record<string, unknown>>) || [])) {
if (typeof cal.id === 'string') {
calColors.set(cal.id, {
name: typeof cal.name === 'string' ? cal.name : '',
color: typeof cal.color === 'string' ? cal.color : null,
});
}
}
if (ids.length === 0) {
return NextResponse.json({ events: [], generatedAt: now.toISOString() });
}
// ── 2) Fetch full event objects (batched) ──
const raw: Array<Record<string, unknown>> = [];
const BATCH = 100;
for (let i = 0; i < ids.length; i += BATCH) {
const batch = ids.slice(i, i + BATCH);
const getRes = await jmapPost(apiUrl, creds.authHeader, {
using,
methodCalls: [
['CalendarEvent/get', { accountId, ids: batch, properties: EVENT_PROPERTIES }, '0'],
],
});
const getResp = findResponse(getRes, 'CalendarEvent/get', '0');
if (getResp?.list) raw.push(...(getResp.list as Array<Record<string, unknown>>));
}
// ── 3) Normalize + expand recurrences server-side ──
const normalized = raw
.map((e) => normalizeCalendarEventLike(e as Partial<CalendarEvent>))
.filter((e) => (e['@type'] ?? 'Event') === 'Event')
// Drop malformed events without a parseable start (would crash format()/
// parseISO downstream) — mirrors the calendar store guard (#316).
.filter((e) => typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())) as CalendarEvent[];
const expanded = expandRecurringEvents(
normalized,
windowStart.toISOString(),
horizon.toISOString(),
);
// ── 4) Keep ongoing/upcoming, sort, slice, map to DTOs ──
const agenda: AgendaEvent[] = expanded
.filter((e) => eventEnd(e).getTime() >= now.getTime())
.sort((a, b) => eventStart(a).getTime() - eventStart(b).getTime())
.slice(0, limit)
.map((e) => {
const calId = firstCalendarId(e);
const cal = calId ? calColors.get(calId) : undefined;
return {
id: String(e.id ?? ''),
uid: e.uid ?? null,
title: (e.title ?? '').trim() || '(no title)',
start: eventStart(e).toISOString(),
end: eventEnd(e).toISOString(),
allDay: !!e.showWithoutTime,
status: e.status ?? null,
color: e.color || cal?.color || null,
location: firstLocationName(e),
calendarId: calId,
};
});
return NextResponse.json({ events: agenda, generatedAt: now.toISOString() });
} catch (error) {
// `fetch failed` from undici is too generic to debug — the real reason
// (ENOTFOUND, ECONNREFUSED, self-signed TLS, …) lives on `error.cause`.
const err = error as Error & { cause?: { code?: string; message?: string } };
logger.error('Calendar agenda error', {
error: err?.message ?? 'Unknown',
causeCode: err?.cause?.code,
causeMessage: err?.cause?.message,
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.round(n)));
}
/**
* Fetch the JMAP session from the same host as `serverUrl`. Tries Stalwart's
* canonical /jmap/session first (no redirect), then /.well-known/jmap as a
* fallback for other servers. Returns null if neither yields a usable session.
*/
async function fetchJmapSession(
serverUrl: string,
authHeader: string,
): Promise<JmapSession | null> {
const candidates = [`${serverUrl}/jmap/session`, `${serverUrl}/.well-known/jmap`];
for (const url of candidates) {
try {
const res = await fetch(url, {
method: 'GET',
headers: { Authorization: authHeader },
redirect: 'follow',
});
if (!res.ok) continue;
const session = (await res.json()) as JmapSession;
if (session && typeof session === 'object' && session.primaryAccounts) {
return session;
}
} catch {
// Try the next candidate (e.g. canonical path 404s on a non-Stalwart server).
}
}
return null;
}
async function jmapPost(
apiUrl: string,
authHeader: string,
payload: unknown,
): Promise<unknown> {
const res = await fetch(apiUrl, {
method: 'POST',
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
throw new Error(`JMAP request failed (${res.status})`);
}
return res.json();
}
function findResponse(
res: unknown,
name: string,
callId: string,
): Record<string, unknown> | null {
const responses = (res as { methodResponses?: unknown[] })?.methodResponses;
if (!Array.isArray(responses)) return null;
for (const entry of responses) {
if (Array.isArray(entry) && entry[0] === name && entry[2] === callId) {
return entry[1] as Record<string, unknown>;
}
}
return null;
}
+2 -2
View File
@@ -121,9 +121,9 @@ const emails: MockEmail[] = [
},
{
id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 5100, receivedAt: daysAgo(1),
from: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }],
from: [{ name: 'Dubois, Pierre', email: 'pierre@dubois.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }],
cc: [{ name: 'Karel de Vries', email: 'karel@devries.example' }],
cc: [{ name: 'de Vries, Karel', email: 'karel@devries.example' }],
subject: 'Project Update - Q1 Review',
preview: 'Salut team, I wanted to share the latest project numbers. We are on track to meet our targets for Q1.',
hasAttachment: true,
+26 -1
View File
@@ -4,6 +4,7 @@ import { authenticateWizardRequest } from '@/lib/setup/session';
import { configManager } from '@/lib/admin/config-manager';
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
import { parseJmapServers } from '@/lib/admin/jmap-servers';
import { effectiveConsent, loadState, saveState, reschedule } from '@/lib/telemetry';
import { logger } from '@/lib/logger';
export const dynamic = 'force-dynamic';
@@ -76,8 +77,32 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'values must be an object' }, { status: 400 });
}
const valuesObj = { ...(values as Record<string, unknown>) };
// Telemetry consent lives in the telemetry state file, not admin config, so
// it has no CONFIG_ENV_MAP entry. Pull it out of the security step and
// persist it directly, mirroring POST /api/admin/telemetry (set-consent).
if (step === 'security' && 'telemetryConsent' in valuesObj) {
const consent = valuesObj.telemetryConsent;
delete valuesObj.telemetryConsent;
if (consent !== 'on' && consent !== 'off') {
return NextResponse.json({ error: 'telemetryConsent must be "on" or "off"' }, { status: 400 });
}
// A BULWARK_TELEMETRY env var hard-locks the choice; don't fight it.
const { source } = await effectiveConsent();
if (source !== 'env') {
const tstate = await loadState();
tstate.consent = consent;
if (consent === 'on' && !tstate.consentedAt) {
tstate.consentedAt = new Date().toISOString();
}
await saveState(tstate);
await reschedule();
}
}
const updates: Record<string, unknown> = {};
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
for (const [key, value] of Object.entries(valuesObj)) {
if (!allowedKeys.includes(key)) {
return NextResponse.json({ error: `Key not allowed in step ${step}: ${key}` }, { status: 400 });
}
+1 -2
View File
@@ -5,7 +5,7 @@ import { useTranslations, useFormatter } from "next-intl";
import { format, isToday, isTomorrow, startOfDay } from "date-fns";
import { MapPin, Users } from "lucide-react";
import { cn } from "@/lib/utils";
import { parseDuration, getEventColor } from "./event-card";
import { getEventColor } from "./event-card";
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { getParticipantCount } from "@/lib/calendar-participants";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
@@ -147,7 +147,6 @@ export function CalendarAgendaView({
const calendar = calId ? calendarMap.get(calId) : undefined;
const color = getEventColor(ev, calendar);
const start = getEventStartDate(ev);
const durMin = parseDuration(ev.duration);
const end = getEventEndDate(ev);
const locationName = ev.locations
? Object.values(ev.locations)[0]?.name
+21 -2
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react";
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -96,6 +96,7 @@ export function CalendarSidebarPanel({
);
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
@@ -219,6 +220,16 @@ export function CalendarSidebarPanel({
}
};
const handleSetDefault = async (calendarId: string) => {
if (!client) return;
try {
await setDefaultCalendar(client, calendarId);
toast.success(tMgmt('default_updated'));
} catch {
toast.error(tMgmt('error_default'));
}
};
if (calendars.length === 0 && !onSubscribe) return null;
const renderCalendarItem = (cal: Calendar) => {
@@ -306,12 +317,13 @@ export function CalendarSidebarPanel({
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
const canSetDefault = !!client && !isBirthday && !cal.isShared && !cal.isDefault;
const canChangeColor = !!onColorChange;
const hasColorOverride = !!cal.isShared && !!sharedCalendarColors[sharedCalendarColorKey(cal)];
const canResetColor = !!onResetColor && hasColorOverride;
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
const showSeparator = (canCreate || canShare || canChangeColor || canResetColor) && (canClear || canDelete);
const showSeparator = (canCreate || canShare || canSetDefault || canChangeColor || canResetColor) && (canClear || canDelete);
const color = cal.color || "#3b82f6";
return (
@@ -330,6 +342,13 @@ export function CalendarSidebarPanel({
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
/>
)}
{canSetDefault && (
<ContextMenuItem
icon={Star}
label={tMgmt('set_default')}
onClick={() => { closeContextMenu(); handleSetDefault(cal.id); }}
/>
)}
{canChangeColor && (
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
<div className="px-2 py-1.5 w-[200px]">
+1 -1
View File
@@ -71,7 +71,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
return el;
}
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesAfter = false, className, style }: EventCardProps) {
const t = useTranslations("calendar");
const [isBeingDragged, setIsBeingDragged] = useState(false);
const color = getEventColor(event, calendar);
+7 -12
View File
@@ -1,18 +1,19 @@
"use client";
import { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect } from "react";
import { useTranslations } from "next-intl";
import { useTranslations, useLocale } from "next-intl";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import {
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
Pencil, Trash2, Copy, Send, Check,
} from "lucide-react";
import { format, isSameDay, parseISO } from "date-fns";
import { format, isSameDay } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { buildRecurrenceSummary } from "./recurrence-editor";
import {
isOrganizer,
getUserParticipantId,
@@ -101,16 +102,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
return null;
}
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
if (!event.recurrenceRules?.length) return null;
const freq = event.recurrenceRules[0].frequency;
const labels: Record<string, string> = {
daily: t("recurrence.daily"),
weekly: t("recurrence.weekly"),
monthly: t("recurrence.monthly"),
yearly: t("recurrence.yearly"),
};
return labels[freq] || null;
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
}
export function EventDetailPopover({
@@ -130,6 +124,7 @@ export function EventDetailPopover({
isMobile,
}: EventDetailPopoverProps) {
const t = useTranslations("calendar");
const locale = useLocale();
const popoverRef = useRef<HTMLDivElement>(null);
const noteInputRef = useRef<HTMLTextAreaElement>(null);
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
@@ -158,7 +153,7 @@ export function EventDetailPopover({
}, [event.virtualLocations]);
const participants = useMemo(() => getParticipantList(event), [event]);
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t), [event, t]);
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t, locale), [event, t, locale]);
const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]);
const userIsOrganizer = useMemo(() => {
+85 -28
View File
@@ -1,12 +1,13 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { useTranslations, useLocale } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
@@ -75,7 +76,7 @@ function buildDuration(startDate: Date, endDate: Date): string {
return dur;
}
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly";
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly" | "custom";
type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks";
@@ -157,16 +158,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
return labels.join(", ");
}
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
if (!event.recurrenceRules?.length) return null;
const freq = event.recurrenceRules[0].frequency;
const labels: Record<string, string> = {
daily: t("recurrence.daily"),
weekly: t("recurrence.weekly"),
monthly: t("recurrence.monthly"),
yearly: t("recurrence.yearly"),
};
return labels[freq] || null;
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
}
export function EventModal({
@@ -186,6 +180,7 @@ export function EventModal({
isMobile = false,
}: EventModalProps) {
const t = useTranslations("calendar");
const locale = useLocale();
const timeFormat = useSettingsStore((s) => s.timeFormat);
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const isEdit = !!event;
@@ -270,8 +265,35 @@ export function EventModal({
});
const [recurrence, setRecurrence] = useState<RecurrenceOption>(() => {
if (!event?.recurrenceRules?.length) return "none";
return event.recurrenceRules[0].frequency as RecurrenceOption;
const rule = event.recurrenceRules[0];
return isSimpleRecurrenceRule(rule) ? (rule.frequency as RecurrenceOption) : "custom";
});
const [customRule, setCustomRule] = useState<CalendarRecurrenceRule | null>(() => {
if (!event?.recurrenceRules?.length) return null;
const rule = event.recurrenceRules[0];
return isSimpleRecurrenceRule(rule) ? null : rule;
});
const [showRecurrenceEditor, setShowRecurrenceEditor] = useState(false);
// Dropdown value to restore when the custom editor is cancelled without a saved rule.
const recurrenceBeforeCustomRef = useRef<RecurrenceOption>("none");
const handleRecurrenceEditorSave = useCallback((rule: CalendarRecurrenceRule) => {
setCustomRule(rule);
setRecurrence("custom");
setShowRecurrenceEditor(false);
}, []);
const handleRecurrenceEditorCancel = useCallback(() => {
setShowRecurrenceEditor(false);
if (!customRule) {
setRecurrence(recurrenceBeforeCustomRef.current);
}
}, [customRule]);
const customRuleSummary = useMemo(
() => (customRule ? buildRecurrenceSummary(customRule, t, locale) : null),
[customRule, t, locale]
);
const preservedAlertsRef = useRef<Record<string, CalendarEventAlert>>({});
const [alertRows, setAlertRows] = useState<AlertRow[]>(() => {
if (!event?.alerts) return [];
@@ -444,7 +466,9 @@ export function EventModal({
data.virtualLocations = null;
}
if (recurrence !== "none") {
if (recurrence === "custom" && customRule) {
data.recurrenceRules = [customRule];
} else if (recurrence !== "none" && recurrence !== "custom") {
data.recurrenceRules = [{
"@type": "RecurrenceRule",
frequency: recurrence,
@@ -511,7 +535,7 @@ export function EventModal({
} finally {
setIsSaving(false);
}
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return;
@@ -593,7 +617,6 @@ export function EventModal({
if (isAttendeeMode && event) {
const startD = getEventStartDate(event);
const durMin = parseDuration(event.duration);
const endD = getEventEndDate(event);
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
const participants = getParticipantList(event);
@@ -737,7 +760,7 @@ export function EventModal({
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
const viewParticipants = getParticipantList(event);
const recurrenceLabel = getRecurrenceLabel(event, t);
const recurrenceLabel = getRecurrenceLabel(event, t, locale);
const alertLabel = getAlertLabel(event, t);
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
const color = getEventColor(event, eventCalendar);
@@ -1131,17 +1154,51 @@ export function EventModal({
<div>
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
<select
value={recurrence}
onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="none">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
</select>
<div className="flex items-center gap-2">
<select
value={recurrence}
onChange={(e) => {
const value = e.target.value as RecurrenceOption;
if (value === "custom") {
recurrenceBeforeCustomRef.current = recurrence;
setRecurrence("custom");
setShowRecurrenceEditor(true);
} else {
setRecurrence(value);
setShowRecurrenceEditor(false);
}
}}
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="none">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
<option value="custom">{customRuleSummary || t("recurrence.custom")}</option>
</select>
{recurrence === "custom" && !showRecurrenceEditor && (
<button
type="button"
onClick={() => setShowRecurrenceEditor(true)}
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t("recurrence.edit_custom")}
>
<Pencil className="w-4 h-4" />
</button>
)}
</div>
{showRecurrenceEditor && (
<RecurrenceEditor
rule={customRule}
eventStart={(() => {
const d = new Date(`${startDate}T${allDay ? "00:00" : (startTime || "00:00")}:00`);
return isNaN(d.getTime()) ? new Date() : d;
})()}
onSave={handleRecurrenceEditorSave}
onCancel={handleRecurrenceEditorCancel}
/>
)}
</div>
<div>
+1 -1
View File
@@ -4,7 +4,7 @@ import { useState, useCallback, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
import { format, parseISO } from "date-fns";
import { format } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { getEventStartDate } from "@/lib/calendar-utils";
+9 -5
View File
@@ -4,6 +4,7 @@ import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHand
import { useTranslations } from "next-intl";
import { X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Avatar } from "@/components/ui/avatar";
import { useContactStore } from "@/stores/contact-store";
interface Participant {
@@ -144,14 +145,17 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
role="option"
aria-selected={i === activeIndex}
onMouseDown={(e) => { e.preventDefault(); addParticipant(s); }}
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
className={`px-3 py-2 text-sm cursor-pointer transition-colors flex items-center gap-2 ${
i === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
}`}
>
<div className="font-medium truncate">{s.name || s.email}</div>
{s.name && (
<div className="text-xs text-muted-foreground truncate">{s.email}</div>
)}
<Avatar name={s.name} email={s.email} size="sm" className="shrink-0 w-7 h-7 text-[10px]" />
<div className="min-w-0">
<div className="font-medium truncate">{s.name || s.email}</div>
{s.name && (
<div className="text-xs text-muted-foreground truncate">{s.email}</div>
)}
</div>
</li>
))}
</ul>
+427
View File
@@ -0,0 +1,427 @@
"use client";
import { useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { addYears, format } from "date-fns";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { CalendarRecurrenceRule } from "@/lib/jmap/types";
type EditorFrequency = "daily" | "weekly" | "monthly" | "yearly";
type MonthlyMode = "day" | "nth";
type EndsMode = "never" | "on" | "after";
const EDITOR_FREQUENCIES: EditorFrequency[] = ["daily", "weekly", "monthly", "yearly"];
// 2024-01-01 is a Monday - used to render localized weekday names via Intl.
const WEEKDAYS: string[] = ["mo", "tu", "we", "th", "fr", "sa", "su"];
const DAY_TO_REF_DATE: Record<string, number> = { mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6, su: 7 };
const INDEX_TO_DAY = ["su", "mo", "tu", "we", "th", "fr", "sa"];
const UNIT_LABEL_KEYS: Record<EditorFrequency, string> = {
daily: "recurrence.editor_unit_days",
weekly: "recurrence.editor_unit_weeks",
monthly: "recurrence.editor_unit_months",
yearly: "recurrence.editor_unit_years",
};
type CalendarT = ReturnType<typeof useTranslations>;
function weekdayName(day: string, locale: string, style: "long" | "short" = "long"): string {
const ref = new Date(2024, 0, DAY_TO_REF_DATE[day] ?? 1);
return new Intl.DateTimeFormat(locale, { weekday: style }).format(ref);
}
function monthName(month: number, locale: string): string {
return new Intl.DateTimeFormat(locale, { month: "long" }).format(new Date(2024, month - 1, 1));
}
function nthLabel(nth: number, t: CalendarT): string {
if (nth === -1) return t("recurrence.nth_last");
if (nth >= 1 && nth <= 4) return t(`recurrence.nth_${nth}`);
return String(nth);
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/**
* Extract an "nth weekday" pattern from a rule, accepting both the
* byDay+nthOfPeriod encoding and the byDay+bySetPosition encoding.
*/
function getNthDay(rule: CalendarRecurrenceRule): { day: string; nth: number } | null {
if (rule.byDay?.length === 1) {
const nd = rule.byDay[0];
if (nd.nthOfPeriod) return { day: nd.day, nth: nd.nthOfPeriod };
if (rule.bySetPosition?.length === 1) return { day: nd.day, nth: rule.bySetPosition[0] };
}
return null;
}
/**
* True when the rule is exactly what the plain Daily/Weekly/Monthly/Yearly
* dropdown presets produce, i.e. it needs no custom editor to represent.
*/
export function isSimpleRecurrenceRule(rule: CalendarRecurrenceRule): boolean {
return (
(EDITOR_FREQUENCIES as string[]).includes(rule.frequency) &&
(!rule.interval || rule.interval === 1) &&
!rule.byDay?.length &&
!rule.byMonthDay?.length &&
!rule.byMonth?.length &&
!rule.byYearDay?.length &&
!rule.byWeekNo?.length &&
!rule.bySetPosition?.length &&
!rule.count &&
!rule.until
);
}
/**
* Human-readable summary of a recurrence rule, e.g.
* "Every 2 months on the third Thursday · 12 occurrences".
* Returns null for frequencies the UI cannot describe (hourly etc.).
*/
export function buildRecurrenceSummary(
rule: CalendarRecurrenceRule,
t: CalendarT,
locale: string,
): string | null {
const interval = rule.interval || 1;
let base: string;
switch (rule.frequency) {
case "daily":
base = interval > 1 ? t("recurrence.every_n_days", { count: interval }) : t("recurrence.daily");
break;
case "weekly":
base = interval > 1 ? t("recurrence.every_n_weeks", { count: interval }) : t("recurrence.weekly");
break;
case "monthly":
base = interval > 1 ? t("recurrence.every_n_months", { count: interval }) : t("recurrence.monthly");
break;
case "yearly":
base = interval > 1 ? t("recurrence.every_n_years", { count: interval }) : t("recurrence.yearly");
break;
default:
return null;
}
const parts = [base];
if (rule.frequency === "weekly" && rule.byDay?.length) {
const days = rule.byDay
.filter((d) => WEEKDAYS.includes(d.day))
.sort((a, b) => WEEKDAYS.indexOf(a.day) - WEEKDAYS.indexOf(b.day))
.map((d) => weekdayName(d.day, locale, "short"))
.join(", ");
if (days) parts.push(t("recurrence.on_days", { days }));
}
if (rule.frequency === "monthly" || rule.frequency === "yearly") {
if (rule.frequency === "yearly" && rule.byMonth?.length) {
const m = parseInt(rule.byMonth[0], 10);
if (m >= 1 && m <= 12) parts.push(t("recurrence.in_month", { month: monthName(m, locale) }));
}
const nthDay = getNthDay(rule);
if (nthDay) {
parts.push(t("recurrence.on_the_nth", {
nth: nthLabel(nthDay.nth, t),
day: weekdayName(nthDay.day, locale),
}));
} else if (rule.byMonthDay?.length) {
parts.push(t("recurrence.on_day_n", { day: rule.byMonthDay[0] }));
}
}
let summary = parts.join(" ");
if (rule.count) {
summary += ` · ${t("recurrence.occurrences", { count: rule.count })}`;
} else if (rule.until) {
const d = new Date(rule.until);
if (!isNaN(d.getTime())) {
summary += ` · ${t("recurrence.until")} ${new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(d)}`;
}
}
return summary;
}
interface RecurrenceEditorProps {
rule: CalendarRecurrenceRule | null;
eventStart: Date;
onSave: (rule: CalendarRecurrenceRule) => void;
onCancel: () => void;
}
export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) {
const t = useTranslations("calendar");
const locale = useLocale();
const startDay = INDEX_TO_DAY[eventStart.getDay()];
const initialNthDay = rule ? getNthDay(rule) : null;
const [frequency, setFrequency] = useState<EditorFrequency>(() =>
rule && (EDITOR_FREQUENCIES as string[]).includes(rule.frequency)
? (rule.frequency as EditorFrequency)
: "weekly"
);
const [interval, setIntervalValue] = useState<number>(rule?.interval || 1);
const [weekDays, setWeekDays] = useState<string[]>(() => {
if (rule?.frequency === "weekly" && rule.byDay?.length) {
const days = rule.byDay.map((d) => d.day).filter((d) => WEEKDAYS.includes(d));
if (days.length) return days;
}
return [startDay];
});
const [monthlyMode, setMonthlyMode] = useState<MonthlyMode>(initialNthDay ? "nth" : "day");
const [monthDay, setMonthDay] = useState<number>(() => {
const md = rule?.byMonthDay?.[0];
return md && md >= 1 && md <= 31 ? md : eventStart.getDate();
});
const [nth, setNth] = useState<number>(() => {
if (initialNthDay && (initialNthDay.nth === -1 || (initialNthDay.nth >= 1 && initialNthDay.nth <= 4))) {
return initialNthDay.nth;
}
return Math.min(4, Math.floor((eventStart.getDate() - 1) / 7) + 1);
});
const [nthDay, setNthDay] = useState<string>(() =>
initialNthDay && WEEKDAYS.includes(initialNthDay.day) ? initialNthDay.day : startDay
);
const [month, setMonth] = useState<number>(() => {
const m = rule?.byMonth?.length ? parseInt(rule.byMonth[0], 10) : NaN;
return m >= 1 && m <= 12 ? m : eventStart.getMonth() + 1;
});
const [endsMode, setEndsMode] = useState<EndsMode>(rule?.count ? "after" : rule?.until ? "on" : "never");
const [untilDate, setUntilDate] = useState<string>(() => {
if (rule?.until) {
const d = new Date(rule.until);
if (!isNaN(d.getTime())) return format(d, "yyyy-MM-dd");
}
return format(addYears(eventStart, 1), "yyyy-MM-dd");
});
const [count, setCount] = useState<number>(rule?.count ?? 12);
const toggleWeekDay = (day: string) => {
setWeekDays((prev) =>
prev.includes(day)
? prev.length > 1 ? prev.filter((d) => d !== day) : prev
: [...prev, day]
);
};
const buildRule = (): CalendarRecurrenceRule => {
const built: CalendarRecurrenceRule = {
"@type": "RecurrenceRule",
frequency,
interval: Math.max(1, interval),
rscale: "gregorian",
skip: "omit",
firstDayOfWeek: "mo",
byDay: null,
byMonthDay: null,
byMonth: null,
byYearDay: null,
byWeekNo: null,
byHour: null,
byMinute: null,
bySecond: null,
bySetPosition: null,
count: endsMode === "after" ? Math.max(1, count) : null,
until: endsMode === "on" && untilDate ? `${untilDate}T23:59:59` : null,
};
if (frequency === "weekly") {
const days = weekDays.length ? weekDays : [startDay];
built.byDay = WEEKDAYS.filter((d) => days.includes(d)).map((day) => ({ day }));
} else if (frequency === "monthly" || frequency === "yearly") {
if (monthlyMode === "nth") {
built.byDay = [{ day: nthDay, nthOfPeriod: nth }];
} else {
built.byMonthDay = [Math.min(31, Math.max(1, monthDay))];
}
if (frequency === "yearly") {
built.byMonth = [String(month)];
}
}
return built;
};
const handleSave = () => onSave(buildRule());
const summary = buildRecurrenceSummary(buildRule(), t, locale);
const selectCls = "rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
return (
<div className="mt-2 rounded-md border border-border bg-muted/20 p-3 space-y-3">
<div className="flex items-center gap-2 text-sm">
<span className="shrink-0">{t("recurrence.editor_every")}</span>
<Input
type="number"
min={1}
max={999}
value={interval}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setIntervalValue(Number.isFinite(n) ? Math.max(1, n) : 1);
}}
className="w-16 shrink-0"
aria-label={t("recurrence.editor_every")}
/>
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value as EditorFrequency)}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.title")}
>
{EDITOR_FREQUENCIES.map((f) => (
<option key={f} value={f}>{t(UNIT_LABEL_KEYS[f])}</option>
))}
</select>
</div>
{frequency === "weekly" && (
<div className="flex gap-1">
{WEEKDAYS.map((day) => (
<button
key={day}
type="button"
onClick={() => toggleWeekDay(day)}
title={weekdayName(day, locale)}
aria-pressed={weekDays.includes(day)}
className={
weekDays.includes(day)
? "flex-1 min-w-0 px-1 py-1.5 text-xs font-medium rounded-md border border-primary text-primary bg-primary/10 transition-colors"
: "flex-1 min-w-0 px-1 py-1.5 text-xs font-medium rounded-md border border-input text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
}
>
{weekdayName(day, locale, "short")}
</button>
))}
</div>
)}
{frequency === "yearly" && (
<div className="flex items-center gap-2 text-sm">
<span className="shrink-0">{capitalize(t("recurrence.editor_in"))}</span>
<select
value={month}
onChange={(e) => setMonth(parseInt(e.target.value, 10))}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.editor_in")}
>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<option key={m} value={m}>{capitalize(monthName(m, locale))}</option>
))}
</select>
</div>
)}
{(frequency === "monthly" || frequency === "yearly") && (
<div className="flex items-center gap-2 text-sm">
<select
value={monthlyMode}
onChange={(e) => setMonthlyMode(e.target.value as MonthlyMode)}
className={`${selectCls} shrink-0`}
aria-label={t("recurrence.editor_repeats_on")}
>
<option value="day">{capitalize(t("recurrence.editor_on_day"))}</option>
<option value="nth">{capitalize(t("recurrence.editor_on_the"))}</option>
</select>
{monthlyMode === "day" ? (
<Input
type="number"
min={1}
max={31}
value={monthDay}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setMonthDay(Number.isFinite(n) ? Math.min(31, Math.max(1, n)) : 1);
}}
className="w-16 shrink-0"
aria-label={t("recurrence.editor_on_day")}
/>
) : (
<>
<select
value={nth}
onChange={(e) => setNth(parseInt(e.target.value, 10))}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.editor_on_the")}
>
{[1, 2, 3, 4, -1].map((n) => (
<option key={n} value={n}>{capitalize(nthLabel(n, t))}</option>
))}
</select>
<select
value={nthDay}
onChange={(e) => setNthDay(e.target.value)}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.editor_on_the")}
>
{WEEKDAYS.map((d) => (
<option key={d} value={d}>{capitalize(weekdayName(d, locale))}</option>
))}
</select>
</>
)}
</div>
)}
<div className="flex items-center gap-2 text-sm">
<span className="shrink-0">{t("recurrence.editor_ends")}</span>
<select
value={endsMode}
onChange={(e) => setEndsMode(e.target.value as EndsMode)}
className={`${selectCls} ${endsMode === "never" ? "flex-1" : "shrink-0"} min-w-0`}
aria-label={t("recurrence.editor_ends")}
>
<option value="never">{t("recurrence.editor_never")}</option>
<option value="on">{t("recurrence.until")}</option>
<option value="after">{t("recurrence.editor_ends_after")}</option>
</select>
{endsMode === "on" && (
<input
type="date"
value={untilDate}
onChange={(e) => setUntilDate(e.target.value)}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.editor_ends_on")}
/>
)}
{endsMode === "after" && (
<>
<Input
type="number"
min={1}
max={999}
value={count}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setCount(Number.isFinite(n) ? Math.max(1, n) : 1);
}}
className="w-16 shrink-0"
aria-label={t("recurrence.editor_ends_after")}
/>
<span className="text-muted-foreground truncate">{t("recurrence.editor_occurrences")}</span>
</>
)}
</div>
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
<p className="text-xs text-muted-foreground truncate min-w-0" title={summary ?? undefined}>
{summary}
</p>
<div className="flex gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={onCancel}>
{t("form.cancel")}
</Button>
<Button size="sm" onClick={handleSave}>
{t("form.save")}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,65 @@
import { render, screen } from '@testing-library/react';
import { fireEvent } from '@testing-library/dom';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ContactsSidebar } from '../contacts-sidebar';
import type { ContactCard } from '@/lib/jmap/types';
// next-intl + next/navigation are mocked globally in vitest.setup (t returns the key).
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], activeAccountId: null };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
return { useAccountStore: hook };
});
const group = {
id: 'g1',
kind: 'group',
name: { full: 'Team' },
members: { '1': true },
} as unknown as ContactCard;
function renderSidebar(onComposeGroup = vi.fn()) {
render(
<ContactsSidebar
groups={[group]}
individuals={[]}
addressBooks={[]}
activeCategory="all"
onSelectCategory={vi.fn()}
onCreateGroup={vi.fn()}
onCreateContact={vi.fn()}
onEditGroup={vi.fn()}
onDeleteGroup={vi.fn()}
onComposeGroup={onComposeGroup}
/>,
);
return onComposeGroup;
}
describe('ContactsSidebar — compose to group', () => {
beforeEach(() => vi.clearAllMocks());
it('shows a "Send email to group" submenu in the group context menu', () => {
renderSidebar();
fireEvent.contextMenu(screen.getByText('Team'));
expect(screen.getByText('groups.send_email')).toBeInTheDocument();
// and the existing Edit/Delete entries still render
expect(screen.getByText('groups.edit')).toBeInTheDocument();
expect(screen.getByText('form.delete')).toBeInTheDocument();
});
it('calls onComposeGroup(groupId, field) when a To/Cc/Bcc item is clicked', () => {
const onComposeGroup = renderSidebar();
fireEvent.contextMenu(screen.getByText('Team'));
// Open the submenu (hover) then click "Cc".
const trigger = screen.getByText('groups.send_email').closest('.relative')!;
fireEvent.mouseOver(trigger);
fireEvent.mouseEnter(trigger);
fireEvent.click(screen.getByText('groups.send_email_cc'));
expect(onComposeGroup).toHaveBeenCalledWith('g1', 'cc');
});
});
+1 -1
View File
@@ -172,7 +172,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
const handleOpenEmail = (email: Email) => {
selectEmail(email);
router.push("/mail");
router.push("/");
};
const handleOpenEvent = (event: CalendarEvent) => {
+10 -7
View File
@@ -32,6 +32,8 @@ interface ContactDetailProps {
onDelete: () => void;
onAddToGroup?: () => void;
onDuplicate?: () => void;
/** Compose an email to this contact in the app (no OS mailto handoff). */
onCompose?: () => void;
isMobile?: boolean;
className?: string;
}
@@ -115,7 +117,7 @@ function formatDate(dateInput: AnniversaryDate): string {
return dateStr;
}
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, isMobile, className }: ContactDetailProps) {
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) {
const t = useTranslations("contacts");
const smimeStore = useSmimeStore();
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
@@ -201,7 +203,6 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
const notes = contact.notes ? Object.values(contact.notes) : [];
const titles = contact.titles ? Object.values(contact.titles) : [];
const jobTitles = titles.filter(t => t.kind !== "role");
const roles = titles.filter(t => t.kind === "role");
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : [];
@@ -260,14 +261,16 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
</div>
</div>
<div className="flex gap-2 flex-shrink-0 flex-wrap">
{email && (
<a
href={`mailto:${email}`}
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
{email && onCompose && (
<Button
variant="outline"
size="sm"
onClick={onCompose}
className="touch-manipulation"
>
<Send className="w-4 h-4 mr-1" />
{t("detail.compose_email")}
</a>
</Button>
)}
{phone && (
<a
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
import { useState, useMemo, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
+24 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { Users, Pencil, Trash2, UserMinus } from "lucide-react";
import { Users, Pencil, Trash2, UserMinus, Mail } from "lucide-react";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -15,6 +15,8 @@ interface ContactGroupDetailProps {
onDelete: () => void;
onRemoveMember: (memberId: string) => void;
onSelectMember: (id: string) => void;
/** Compose an email to every member with the recipients placed in `field`. */
onComposeGroup?: (field: "to" | "cc" | "bcc") => void;
isMobile?: boolean;
className?: string;
}
@@ -26,11 +28,13 @@ export function ContactGroupDetail({
onDelete,
onRemoveMember,
onSelectMember,
onComposeGroup,
isMobile,
className,
}: ContactGroupDetailProps) {
const t = useTranslations("contacts");
const groupName = getContactDisplayName(group);
const hasEmailMembers = members.some((m) => getContactPrimaryEmail(m).trim());
return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
@@ -62,6 +66,25 @@ export function ContactGroupDetail({
</Button>
</div>
</div>
{onComposeGroup && hasEmailMembers && (
<div className="flex flex-wrap items-center gap-2 mt-4">
<Mail className="w-4 h-4 text-muted-foreground" aria-hidden />
<span className="text-sm text-muted-foreground">{t("groups.send_email")}</span>
<div className="inline-flex gap-1">
{(["to", "cc", "bcc"] as const).map((field) => (
<Button
key={field}
variant="outline"
size="sm"
onClick={() => onComposeGroup(field)}
className="touch-manipulation"
>
{t(`groups.send_email_${field}`)}
</Button>
))}
</div>
</div>
)}
</div>
<div className="px-6 py-4">
+30 -2
View File
@@ -2,10 +2,10 @@
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { BookUser, User, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
import { BookUser, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils";
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
@@ -22,9 +22,11 @@ interface ContactsSidebarProps {
onSelectCategory: (category: ContactCategory) => void;
onCreateGroup: () => void;
onCreateContact: () => void;
onCreateAddressBook?: () => void;
onImport?: () => void;
onEditGroup?: (groupId: string) => void;
onDeleteGroup?: (groupId: string) => void;
onComposeGroup?: (groupId: string, field: "to" | "cc" | "bcc") => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
onRenameAddressBook?: (addressBook: AddressBook) => void;
@@ -90,9 +92,11 @@ export function ContactsSidebar({
onSelectCategory,
onCreateGroup,
onCreateContact,
onCreateAddressBook,
onImport,
onEditGroup,
onDeleteGroup,
onComposeGroup,
onDropContacts,
onDropContactsToCategory,
onRenameAddressBook,
@@ -296,6 +300,15 @@ export function ContactsSidebar({
<UsersRound className="w-4 h-4" />
{t("groups.create")}
</button>
{onCreateAddressBook && (
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
onClick={() => { setShowMenu(false); onCreateAddressBook(); }}
>
<BookPlus className="w-4 h-4" />
{t("address_books.create")}
</button>
)}
{onImport && (
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
@@ -684,6 +697,21 @@ export function ContactsSidebar({
onEditGroup?.(groupContextMenu.data!.id);
}}
/>
{onComposeGroup && (
<ContextMenuSubMenu icon={Mail} label={t("groups.send_email")}>
{(["to", "cc", "bcc"] as const).map((field) => (
<ContextMenuItem
key={field}
label={t(`groups.send_email_${field}`)}
onClick={() => {
const groupId = groupContextMenu.data!.id;
closeGroupContextMenu();
onComposeGroup(groupId, field);
}}
/>
))}
</ContextMenuSubMenu>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
@@ -0,0 +1,106 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ContactSidebarPanel } from '../email-viewer';
import type { ContactCard } from '@/lib/jmap/types';
const contact: ContactCard = {
id: 'c1',
addressBookIds: {},
name: {
components: [
{ kind: 'given', value: 'Alice' },
{ kind: 'surname', value: 'Smith' },
],
isOrdered: true,
},
emails: { e0: { address: 'alice@example.com' } },
};
const unknownEmail = 'unknown@example.com';
describe('ContactSidebarPanel', () => {
beforeEach(() => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});
it('shows Edit button when contact is known and onEditContact is provided', () => {
render(
<ContactSidebarPanel
email="alice@example.com"
contact={contact}
onClose={vi.fn()}
onEditContact={vi.fn()}
/>,
);
// useTranslations mock returns the key, so we look for the common.edit key
expect(screen.getByTitle('contact_sidebar.action_edit_title')).toBeInTheDocument();
expect(screen.getByText('edit')).toBeInTheDocument();
});
it('calls onEditContact when Edit button is clicked', () => {
const onEditContact = vi.fn();
render(
<ContactSidebarPanel
email="alice@example.com"
contact={contact}
onClose={vi.fn()}
onEditContact={onEditContact}
/>,
);
fireEvent.click(screen.getByTitle('contact_sidebar.action_edit_title'));
expect(onEditContact).toHaveBeenCalledOnce();
});
it('does not show Edit button when contact is null', () => {
render(
<ContactSidebarPanel
email={unknownEmail}
contact={null}
onClose={vi.fn()}
onEditContact={vi.fn()}
/>,
);
expect(screen.queryByTitle('contact_sidebar.action_edit_title')).not.toBeInTheDocument();
});
it('does not show Edit button when onEditContact is not provided', () => {
render(
<ContactSidebarPanel
email="alice@example.com"
contact={contact}
onClose={vi.fn()}
/>,
);
expect(screen.queryByTitle('contact_sidebar.action_edit_title')).not.toBeInTheDocument();
});
it('shows "not in contacts" message and Add button for unknown email', () => {
const onAddToContacts = vi.fn();
render(
<ContactSidebarPanel
email={unknownEmail}
contact={null}
onClose={vi.fn()}
onAddToContacts={onAddToContacts}
/>,
);
expect(screen.getByText('contact_sidebar.not_in_contacts')).toBeInTheDocument();
fireEvent.click(screen.getByText('contact_sidebar.add_to_contacts'));
expect(onAddToContacts).toHaveBeenCalledWith(unknownEmail, undefined);
});
it('calls onClose when the close button is clicked', () => {
const onClose = vi.fn();
render(
<ContactSidebarPanel
email="alice@example.com"
contact={contact}
onClose={onClose}
/>,
);
fireEvent.click(screen.getByLabelText('contact_sidebar.close'));
expect(onClose).toHaveBeenCalledOnce();
});
});
@@ -68,10 +68,12 @@ describe('EmailListItem tag badge', () => {
expect(screen.getByText('Blue')).toBeInTheDocument();
});
it('does not show badge when keyword id not in settings', () => {
it('shows a gray fallback badge when keyword id is not in settings', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } });
render(<EmailListItem email={email} />);
expect(screen.queryByText('unknown-tag')).not.toBeInTheDocument();
// Unknown tags fall back to the raw id as label with a gray dot
// (see email-list-item.tsx: keywordDefs fallback).
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
});
it('shows custom keyword label', () => {
@@ -0,0 +1,364 @@
import { render, screen, act } from '@testing-library/react';
import { fireEvent } from '@testing-library/dom';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
// ─── Heavy component mocks ────────────────────────────────────────────────────
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: ({ onChange }: { onChange?: (html: string) => void }) => (
React.createElement('div', { 'data-testid': 'rich-text-editor', onClick: () => onChange?.('') })
),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ ref: { current: null } }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
// vi.mock factories are hoisted, so all values must be defined inline.
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = { identities: [], defaultIdentityId: null };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/smime-store', () => {
const state = {
certs: [],
signingEnabled: false,
encryptionEnabled: false,
defaultSigningCertId: null,
defaultEncryptionCertId: null,
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSmimeStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onSend: { call: async () => [] },
beforeSend: { call: async () => [] },
},
contactHooks: {
search: { call: async () => [] },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/signature-utils', () => ({
appendPlainTextSignature: (body: string) => body,
getPlainTextSignature: () => '',
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/smime/smime-sign', () => ({ smimeSign: async () => null }));
vi.mock('@/lib/smime/smime-encrypt', () => ({ smimeEncrypt: async () => null }));
vi.mock('@/lib/smime/mime-builder', () => ({
buildMimeMessage: () => null,
wrapCmsAsSmimeMessage: () => null,
}));
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '',
serializeEditorContent: () => '',
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
// ─── DataTransfer polyfill ────────────────────────────────────────────────────
/** jsdom's built-in DataTransfer doesn't fully support setData/getData in synthetic drag events. */
class MockDataTransfer {
private _data: Record<string, string> = {};
types: string[] = [];
effectAllowed = '';
dropEffect = '';
setData(type: string, data: string) {
this._data[type] = data;
if (!this.types.includes(type)) this.types.push(type);
}
getData(type: string): string {
return this._data[type] ?? '';
}
setDragImage(_image: Element, _x: number, _y: number) {
// no-op: jsdom has no rendering, but the chip drag handler calls this.
}
}
// ─── Shared test data ─────────────────────────────────────────────────────────
const BASE_DATA = {
to: 'alice@example.com, ',
cc: '',
bcc: '',
subject: '',
body: '',
showCc: true,
showBcc: true,
selectedIdentityId: null,
subAddressTag: '',
mode: 'compose' as const,
draftId: null,
};
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('RecipientChipInput drag and drop', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('renders recipient chips with draggable="true"', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
const chipText = await screen.findByText('alice@example.com');
const chipSpan = chipText.closest('[draggable]');
expect(chipSpan).not.toBeNull();
expect(chipSpan).toHaveAttribute('draggable', 'true');
});
it('onDragStart encodes the recipient and source field into dataTransfer', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
const chipText = await screen.findByText('alice@example.com');
const chipSpan = chipText.closest('[draggable]') as HTMLElement;
const dt = new MockDataTransfer();
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to' });
});
it('keeps a display name with a comma in a single chip (array model)', async () => {
render(<EmailComposer initialData={{ ...BASE_DATA, to: '"Doo, John" <john@doo.org>, ' }} />);
// One chip, displayed as "Doo, John (john@doo.org)" — not split on the comma.
const chip = await screen.findByText('Doo, John (john@doo.org)');
const chipSpan = chip.closest('[draggable]') as HTMLElement;
expect(chipSpan).not.toBeNull();
const dt = new MockDataTransfer();
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to' });
});
it('onDragEnd clears the opacity class on the chip', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
const chipText = await screen.findByText('alice@example.com');
const chipSpan = chipText.closest('[draggable]') as HTMLElement;
fireEvent.dragStart(chipSpan, { dataTransfer: new MockDataTransfer() });
expect(chipSpan.className).toContain('opacity-50');
fireEvent.dragEnd(chipSpan);
expect(chipSpan.className).not.toContain('opacity-50');
});
it('dragOver on a different field container adds ring indicator; dragLeave removes it', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
await screen.findByText('alice@example.com');
// The flex-wrap containers are the actual drop zones
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
// To-container has the draggable chip; cc-container doesn't
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
const ccContainer = allContainers.find(el => el !== toContainer) as HTMLElement;
if (!ccContainer) return;
const dt = new MockDataTransfer();
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
fireEvent.dragOver(ccContainer, { dataTransfer: dt });
expect(ccContainer.className).toContain('ring-primary');
fireEvent.dragLeave(ccContainer, { relatedTarget: null });
expect(ccContainer.className).not.toContain('ring-primary');
});
it('drop on a different field container moves the chip', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
await screen.findByText('alice@example.com');
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
const ccContainer = allContainers.find(el => el !== toContainer) as HTMLElement;
if (!toContainer || !ccContainer) return;
const dt = new MockDataTransfer();
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
fireEvent.dragOver(ccContainer, { dataTransfer: dt });
act(() => {
fireEvent.drop(ccContainer, { dataTransfer: dt });
});
// Chip should still appear exactly once (moved, not duplicated or lost)
await screen.findByText('alice@example.com');
expect(screen.getAllByText('alice@example.com')).toHaveLength(1);
// The To container must now be empty
expect(toContainer.querySelectorAll('[draggable]')).toHaveLength(0);
});
it('drop on the same field container is a no-op', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
await screen.findByText('alice@example.com');
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
const dt = new MockDataTransfer();
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
fireEvent.dragOver(toContainer, { dataTransfer: dt });
act(() => {
fireEvent.drop(toContainer, { dataTransfer: dt });
});
// Chip stays present exactly once
expect(screen.getAllByText('alice@example.com')).toHaveLength(1);
});
it('dropping a chip onto the hidden Cc button shows the CC field', async () => {
render(<EmailComposer initialData={{ ...BASE_DATA, showCc: false, showBcc: false }} />);
await screen.findByText('alice@example.com');
const ccButton = screen.getByRole('button', { name: 'Cc' });
const dt = new MockDataTransfer();
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
fireEvent.dragOver(ccButton, { dataTransfer: dt });
act(() => {
fireEvent.drop(ccButton, { dataTransfer: dt });
});
// cc_label is rendered by the mock translation as its key string
const ccLabel = await screen.findByText('cc_label');
expect(ccLabel).toBeInTheDocument();
});
});
@@ -0,0 +1,317 @@
import { render, screen } from '@testing-library/react';
import { fireEvent } from '@testing-library/dom';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
// ─── Heavy component mocks (mirrors recipient-chip-drag.test.tsx) ──────────────
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: ({ onChange }: { onChange?: (html: string) => void }) => (
React.createElement('div', { 'data-testid': 'rich-text-editor', onClick: () => onChange?.('') })
),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ ref: { current: null } }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = { identities: [], defaultIdentityId: null };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/smime-store', () => {
const state = {
certs: [],
signingEnabled: false,
encryptionEnabled: false,
defaultSigningCertId: null,
defaultEncryptionCertId: null,
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSmimeStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onSend: { call: async () => [] },
beforeSend: { call: async () => [] },
},
contactHooks: {
search: { call: async () => [] },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/signature-utils', () => ({
appendPlainTextSignature: (body: string) => body,
getPlainTextSignature: () => '',
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/smime/smime-sign', () => ({ smimeSign: async () => null }));
vi.mock('@/lib/smime/smime-encrypt', () => ({ smimeEncrypt: async () => null }));
vi.mock('@/lib/smime/mime-builder', () => ({
buildMimeMessage: () => null,
wrapCmsAsSmimeMessage: () => null,
}));
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '',
serializeEditorContent: () => '',
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
// ─── Shared test data ─────────────────────────────────────────────────────────
const EMPTY_DATA = {
to: '',
cc: '',
bcc: '',
subject: '',
body: '',
showCc: true,
showBcc: true,
selectedIdentityId: null,
subAddressTag: '',
mode: 'compose' as const,
draftId: null,
};
/** next-intl is mocked to return the key, so the To placeholder is "to_placeholder". */
const toInput = () => screen.getByPlaceholderText('to_placeholder') as HTMLInputElement;
const ccInput = () => screen.getByPlaceholderText('cc_placeholder') as HTMLInputElement;
const paste = (input: HTMLElement, text: string) =>
fireEvent.paste(input, { clipboardData: { getData: () => text } });
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('RecipientChipInput paste', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('splits a pasted list (comma / semicolon / whitespace) into one chip per address', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
const input = toInput(); // capture once — placeholder disappears once chips exist
paste(input, 'a@x.com b@y.com; c@z.com');
expect(screen.getByText('a@x.com')).toBeInTheDocument();
expect(screen.getByText('b@y.com')).toBeInTheDocument();
expect(screen.getByText('c@z.com')).toBeInTheDocument();
// all consumed → input cleared
expect(input.value).toBe('');
});
it('chips the valid addresses and leaves invalid text in the input', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
const input = toInput();
paste(input, 'foo bar a@x.com');
expect(screen.getByText('a@x.com')).toBeInTheDocument();
expect(input.value).toBe('foo bar');
});
it('does not pre-empt a single-address paste (no delimiter → normal editing)', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
paste(toInput(), 'single@x.com');
// The handler bailed out, so no chip was created from the single token.
expect(screen.queryByText('single@x.com')).not.toBeInTheDocument();
});
it('keeps display names from a fully-quoted "Name <email>" list', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
const input = toInput();
paste(input, '"Alice Smith <alice@x.com>", "Alex Smith <alex@x.com>"');
// Chips render as "Name (email)" when a display name is present.
expect(screen.getByText('Alice Smith (alice@x.com)')).toBeInTheDocument();
expect(screen.getByText('Alex Smith (alex@x.com)')).toBeInTheDocument();
expect(input.value).toBe('');
});
it('keeps a `Name <email>` pair as a single named chip', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
const input = toInput();
paste(input, 'John Doe <j@x.com>, jane@y.com');
expect(screen.getByText('John Doe (j@x.com)')).toBeInTheDocument();
expect(screen.getByText('jane@y.com')).toBeInTheDocument();
expect(input.value).toBe('');
});
it('keeps a comma inside a quoted display name intact', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
const input = toInput();
paste(input, '"Doe, John" <j@x.com>; bob@z.com');
expect(screen.getByText('Doe, John (j@x.com)')).toBeInTheDocument();
expect(screen.getByText('bob@z.com')).toBeInTheDocument();
expect(input.value).toBe('');
});
it('splits a newline-separated block (e.g. a spreadsheet column)', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
const input = toInput();
paste(input, 'a@x.com\nb@y.com\nc@z.com');
expect(screen.getByText('a@x.com')).toBeInTheDocument();
expect(screen.getByText('b@y.com')).toBeInTheDocument();
expect(screen.getByText('c@z.com')).toBeInTheDocument();
expect(input.value).toBe('');
});
it('dedupes case-insensitively within the paste and against existing chips', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
const input = toInput(); // DOM node persists across pastes (placeholder just clears)
paste(input, 'a@x.com, A@X.com, b@y.com'); // within-paste dup collapses
paste(input, 'A@X.COM, c@z.com'); // dup of an existing chip is dropped
expect(screen.getByText('b@y.com')).toBeInTheDocument();
expect(screen.getByText('c@z.com')).toBeInTheDocument();
// a@x.com appears exactly once despite three case variants across two pastes.
expect(screen.getAllByText('a@x.com')).toHaveLength(1);
expect(screen.queryByText('A@X.COM')).not.toBeInTheDocument();
expect(input.value).toBe('');
});
it('chips the valid (named) entries and leaves a non-address token behind', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
const input = toInput();
paste(input, '"VIP" <vip@x.com>, not-an-email, b@y.com');
expect(screen.getByText('VIP (vip@x.com)')).toBeInTheDocument();
expect(screen.getByText('b@y.com')).toBeInTheDocument();
expect(input.value).toBe('not-an-email');
});
it('works on the Cc field (shared handler)', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
paste(ccInput(), 'x@a.com; y@b.com');
expect(screen.getByText('x@a.com')).toBeInTheDocument();
expect(screen.getByText('y@b.com')).toBeInTheDocument();
});
});
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { SignatureBlock, buildSignatureBlock, SIGNATURE_BLOCK_MARKER } from '../signature-block';
import { serializeEditorContent } from '../quoted-html';
describe('signature-block', () => {
it('buildSignatureBlock wraps html in the marker div', () => {
expect(buildSignatureBlock('<b>x</b>')).toBe(`<div ${SIGNATURE_BLOCK_MARKER}><b>x</b></div>`);
});
it('preserves an inline-styled signature through parse + serialize (no schema flattening)', () => {
const styled =
'<table style="background:#0a0e16;border-radius:8px"><tbody><tr>' +
'<td style="color:#c6f24e;font-family:\'Courier New\'">MV</td>' +
'</tr></tbody></table>';
const editor = new Editor({
element: document.createElement('div'),
extensions: [StarterKit, SignatureBlock],
content: `<p>Hello</p>${buildSignatureBlock(styled)}`,
});
try {
const out = serializeEditorContent(editor);
// Original inline styling survives - it is NOT re-parsed into the schema.
expect(out).toContain('background:#0a0e16');
expect(out).toContain('border-radius:8px');
expect(out).toContain('color:#c6f24e');
expect(out).toContain(SIGNATURE_BLOCK_MARKER);
// Surrounding body is preserved.
expect(out).toContain('Hello');
// The signature did not get the editor's generic table styling.
expect(out).not.toContain('rgb(204, 204, 204)');
} finally {
editor.destroy();
}
});
});
@@ -553,40 +553,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const replyToForRsvp = parsedEvent.replyTo
|| (parsedEvent.organizerCalendarAddress ? { imip: parsedEvent.organizerCalendarAddress } : null);
const imipStatus = status.toUpperCase() as 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
const organizerEmail = parsedEvent?.replyTo?.imip?.replace('mailto:', '')
|| parsedEvent?.organizerCalendarAddress?.replace('mailto:', '')
|| summary?.organizerEmail
|| null;
// Send the iMIP REPLY email to the organizer (client-side scheduling).
// Called after updating the local calendar event. Best-effort - if it
// fails we still report the RSVP as sent since the calendar was updated.
const sendImipReply = async () => {
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
return;
}
try {
await client.sendImipReply({
organizerEmail,
organizerName: summary?.organizer || undefined,
attendeeEmail: currentUserEmail,
attendeeName: myParticipant?.participant.name || undefined,
uid: parsedEvent.uid,
summary: parsedEvent.title,
dtStart: parsedEvent.start || undefined,
dtEnd: summary?.end || undefined,
timeZone: parsedEvent.timeZone || undefined,
isAllDay: parsedEvent.showWithoutTime || false,
sequence: parsedEvent.sequence,
status: imipStatus,
});
} catch {
// Best-effort: don't block the RSVP success notification
}
};
// The iMIP REPLY to the organizer is sent by the server: rsvpEvent /
// updateEvent below pass sendSchedulingMessages=true to CalendarEvent/set
// and Stalwart queues the iTIP REPLY itself. A manual client-side reply
// here produced duplicate emails.
try {
const eventForRsvp = existingEvent
? await resolveExistingEventForRsvp()
@@ -601,7 +571,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
);
if (eventForRsvp && existingEventParticipant) {
await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp);
await sendImipReply();
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
setState('parsed');
@@ -615,7 +584,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
participants: repairedParticipants,
replyTo: replyToForRsvp ?? undefined,
}, true);
await sendImipReply();
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
setState('parsed');
@@ -632,7 +600,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|| (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null);
if (newEvent && participant) {
await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp);
await sendImipReply();
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
} else {
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -34,6 +34,7 @@ import {
XCircle,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
interface Position {
@@ -143,6 +144,7 @@ export function EmailContextMenu({
onRescheduleScheduled,
}: EmailContextMenuProps) {
const t = useTranslations("context_menu");
const tSidebar = useTranslations("sidebar");
const _tColor = useTranslations("email_viewer.color_tag");
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const isUnread = !email.keywords?.$seen;
@@ -302,12 +304,13 @@ export function EmailContextMenu({
return nodes.map((node) => {
const Icon = getMailboxIcon(node.role);
const isTarget = moveTargetIds.has(node.id);
const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
return (
<div key={node.id}>
{isTarget ? (
<ContextMenuItem
icon={Icon}
label={node.name}
label={nodeLabel}
onClick={() =>
handleAction(() =>
showBatchActions
@@ -319,7 +322,7 @@ export function EmailContextMenu({
) : (
<div className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground">
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{node.name}</span>
<span>{nodeLabel}</span>
</div>
)}
{node.children.length > 0 && (
+20 -5
View File
@@ -4,7 +4,7 @@ import { Email } from "@/lib/jmap/types";
import { useSettingsStore } from "@/stores/settings-store";
import type { HoverAction } from "@/stores/settings-store";
import { cn } from "@/lib/utils";
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react";
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert, ShieldCheck } from "lucide-react";
import { useTranslations } from "next-intl";
import { useIsMobile } from "@/hooks/use-media-query";
@@ -17,6 +17,10 @@ interface EmailHoverActionsProps {
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onMarkAsSpam?: () => void;
// When the email lives in a junk folder (incl. the aggregate "All Junk" view)
// the spam quick-action flips to "not spam".
isInJunk?: boolean;
onUndoSpam?: () => void;
}
const ACTION_CONFIG: Record<HoverAction, {
@@ -72,6 +76,8 @@ export function EmailHoverActions({
onArchive,
onSetColorTag,
onMarkAsSpam,
isInJunk = false,
onUndoSpam,
}: EmailHoverActionsProps) {
const hoverActions = useSettingsStore((state) => state.hoverActions);
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
@@ -106,7 +112,8 @@ export function EmailHoverActions({
onSetColorTag?.(null);
break;
case "spam":
onMarkAsSpam?.();
if (isInJunk) onUndoSpam?.();
else onMarkAsSpam?.();
break;
}
};
@@ -116,20 +123,28 @@ export function EmailHoverActions({
if (!config) return null;
const Icon = config.icon;
// In a junk context the spam action becomes "not spam".
const isNotSpam = actionId === "spam" && isInJunk;
const DisplayIcon = actionId === "markRead"
? (isUnread ? MailOpen : Mail)
: actionId === "star" && isStarred
? Star
: Icon;
: isNotSpam
? ShieldCheck
: Icon;
const title = isNotSpam ? t("not_spam") : t(config.titleKey);
const className = isNotSpam
? "hover:text-green-600 dark:hover:text-green-400"
: config.className;
return (
<button
key={actionId}
onClick={(e) => handleAction(e, actionId)}
title={t(config.titleKey)}
title={title}
className={cn(
"p-1.5 rounded-md transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10",
config.className,
className,
)}
>
<DisplayIcon
+13 -7
View File
@@ -29,11 +29,12 @@ interface EmailListItemProps {
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
}
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) {
const t = useTranslations('email_viewer');
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -46,8 +47,11 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
const isImportant = email.keywords?.["$important"];
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
// In Sent/Drafts folders, show recipient instead of sender (which is always "me").
// In aggregate role-views the selected mailbox is virtual → fall back to the
// unified role so junk-contextual UI (spam ↔ not-spam) and avatar hiding work.
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const isMobile = useUIStore((state) => state.isMobile);
@@ -162,7 +166,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
{/* Unread indicator */}
{isUnread && (
<div className="absolute left-1 top-1/2 -translate-y-1/2">
<div className="absolute left-0.5 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-unread text-unread" />
</div>
)}
@@ -191,13 +195,13 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={cn(
'shrink-0 truncate',
'min-w-0 truncate',
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
)}>
{email.subject || t('no_subject')}
</span>
{inlinePreview && (
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
)}
</div>
</div>
@@ -321,6 +325,8 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
onArchive={onArchive}
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
/>
</div>
);
+32 -5
View File
@@ -100,19 +100,32 @@ export function EmailList({
isLoadingThread,
toggleThreadExpansion,
fetchThreadEmails,
markThreadAsRead,
collapseAllThreads,
threadEmailCounts,
searchFilters,
setSearchFilters,
clearSearchFilters,
advancedSearch,
searchQuery,
isUnifiedView,
unifiedRole,
} = useEmailStore();
// In aggregate role-views (e.g. "All Junk") the selected mailbox is virtual, so
// there is no concrete mailbox to read the role from. Fall back to the unified
// role so contextual actions (e.g. mark-as-spam ↔ not-spam) behave as if inside
// that role's folder.
const effectiveMailboxRole =
mailboxes.find(m => m.id === selectedMailbox)?.role
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
const disableThreading = useSettingsStore((state) => state.disableThreading);
const threadGroups = useMemo(() => {
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView);
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView, threadEmailCounts);
return sortThreadGroups(groups);
}, [emails, disableThreading, isScheduledView]);
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
@@ -250,10 +263,12 @@ export function EmailList({
if (!isExpanded && client) {
toggleThreadExpansion(threadId);
await fetchThreadEmails(client, threadId);
// Mark all unread emails in this thread as read
void markThreadAsRead(client, threadId);
} else {
toggleThreadExpansion(threadId);
}
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead]);
// Range-based load more: trigger when last visible item is near the end.
// Debounce to prevent rapid cascade when thread grouping reduces item
@@ -473,7 +488,18 @@ export function EmailList({
isLoading={isLoadingThread === thread.threadId}
expandedEmails={threadEmailsCache.get(thread.threadId)}
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
onEmailSelect={(email) => onEmailSelect?.(email)}
onCollapseAllThreads={collapseAllThreads}
onEmailSelect={(email) => {
// Collapse expanded threads when selecting an email outside the expanded thread
const currentExpanded = useEmailStore.getState().expandedThreadIds;
if (currentExpanded.size > 0) {
// Check if the selected email belongs to any expanded thread via its threadId
if (!email.threadId || !currentExpanded.has(email.threadId)) {
collapseAllThreads();
}
}
onEmailSelect?.(email);
}}
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
onContextMenu={openContextMenu}
onOpenConversation={onOpenConversation}
@@ -483,6 +509,7 @@ export function EmailList({
onArchive={onArchive ? (email) => onArchive(email) : undefined}
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
/>
</div>
);
@@ -516,7 +543,7 @@ export function EmailList({
menuRef={menuRef}
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
currentMailboxRole={effectiveMailboxRole}
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
selectedCount={selectedEmailIds.size}
onReply={() => onReply?.(contextMenu.data!)}
+279 -165
View File
@@ -3,9 +3,9 @@
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
import DOMPurify from "dompurify";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { emailExportFilename, attachmentDownloadFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { EMAIL_IFRAME_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { withBasePath } from "@/lib/browser-navigation";
import { Button } from "@/components/ui/button";
@@ -609,19 +609,23 @@ function renderClickableRecipients(
}
// Contact sidebar panel that slides in from the right on desktop
function ContactSidebarPanel({
export function ContactSidebarPanel({
email,
contact,
senderName,
onClose,
onAddToContacts,
onEditContact,
}: {
email: string;
contact: ContactCard | null;
senderName?: string;
onClose: () => void;
onAddToContacts?: (email: string, name?: string) => void;
onEditContact?: () => void;
}) {
const t = useTranslations('email_viewer');
const tCommon = useTranslations('common');
const name = contact ? getContactDisplayName(contact) : senderName || null;
const primaryEmail = contact ? getContactPrimaryEmail(contact) : email;
const emails = contact?.emails ? Object.values(contact.emails) : [];
@@ -633,9 +637,9 @@ function ContactSidebarPanel({
const handleCopy = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success("Copied!");
toast.success(t('contact_sidebar.copied'));
} catch {
toast.error("Failed to copy");
toast.error(t('contact_sidebar.copy_failed'));
}
};
@@ -643,11 +647,11 @@ function ContactSidebarPanel({
<div className="w-[320px] shrink-0 border-l border-border bg-background flex flex-col h-full animate-in slide-in-from-right-5 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-foreground truncate">Contact</h3>
<h3 className="text-sm font-semibold text-foreground truncate">{t('contact_sidebar.title')}</h3>
<button
onClick={onClose}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label="Close sidebar"
aria-label={t('contact_sidebar.close')}
>
<PanelRightClose className="w-4 h-4 text-muted-foreground" />
</button>
@@ -685,19 +689,29 @@ function ContactSidebarPanel({
<a
href={`mailto:${primaryEmail}`}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
title="Send email"
title={t('contact_sidebar.action_email_title')}
>
<Send className="w-3.5 h-3.5" />
Email
{t('contact_sidebar.action_email')}
</a>
<button
onClick={() => handleCopy(primaryEmail)}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
title="Copy email"
title={t('contact_sidebar.action_copy_title')}
>
<Copy className="w-3.5 h-3.5" />
Copy
{t('contact_sidebar.action_copy')}
</button>
{contact && onEditContact && (
<button
onClick={onEditContact}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
title={t('contact_sidebar.action_edit_title')}
>
<EditIcon className="w-3.5 h-3.5" />
{tCommon('edit')}
</button>
)}
</div>
{/* Details sections */}
@@ -705,7 +719,7 @@ function ContactSidebarPanel({
<div className="px-4 pb-4 space-y-4">
{/* Emails */}
{emails.length > 0 && (
<SidebarSection icon={Mail} title="Emails">
<SidebarSection icon={Mail} title={t('contact_sidebar.section_emails')}>
{emails.map((e, i) => (
<div key={i} className="flex items-center gap-2 group">
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline truncate">
@@ -725,7 +739,7 @@ function ContactSidebarPanel({
{/* Phones */}
{phones.length > 0 && (
<SidebarSection icon={Phone} title="Phones">
<SidebarSection icon={Phone} title={t('contact_sidebar.section_phones')}>
{phones.map((p, i) => (
<div key={i} className="flex items-center gap-2 group">
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
@@ -745,7 +759,7 @@ function ContactSidebarPanel({
{/* Organizations */}
{orgs.length > 1 && (
<SidebarSection icon={Building} title="Organizations">
<SidebarSection icon={Building} title={t('contact_sidebar.section_organizations')}>
{orgs.map((o, i) => (
<div key={i} className="text-sm">
{o.name}
@@ -759,7 +773,7 @@ function ContactSidebarPanel({
{/* Addresses */}
{addresses.length > 0 && (
<SidebarSection icon={MapPin} title="Addresses">
<SidebarSection icon={MapPin} title={t('contact_sidebar.section_addresses')}>
{addresses.map((a, i) => (
<div key={i} className="text-sm text-muted-foreground">
{a.full || a.fullAddress
@@ -774,7 +788,7 @@ function ContactSidebarPanel({
{/* Notes */}
{notes.length > 0 && (
<SidebarSection icon={StickyNote} title="Notes">
<SidebarSection icon={StickyNote} title={t('contact_sidebar.section_notes')}>
{notes.map((n, i) => (
<p key={i} className="text-sm text-muted-foreground whitespace-pre-wrap">{n.note}</p>
))}
@@ -787,7 +801,7 @@ function ContactSidebarPanel({
{!contact && (
<div className="px-4 pb-4 text-center space-y-3">
<p className="text-xs text-muted-foreground">
Not in your contacts
{t('contact_sidebar.not_in_contacts')}
</p>
{onAddToContacts && (
<button
@@ -795,7 +809,7 @@ function ContactSidebarPanel({
className="inline-flex items-center gap-1.5 text-xs font-medium text-primary hover:text-primary/80 px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
>
<Mail className="w-3.5 h-3.5" />
Add to contacts
{t('contact_sidebar.add_to_contacts')}
</button>
)}
</div>
@@ -990,6 +1004,7 @@ export function EmailViewer({
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
const [showAllBelowHeaderAttachments, setShowAllBelowHeaderAttachments] = useState(false);
const [isDownloadingAll, setIsDownloadingAll] = useState(false);
const [visibleBelowHeaderCount, setVisibleBelowHeaderCount] = useState<number | null>(null);
const belowHeaderRowRef = useRef<HTMLDivElement>(null);
const belowHeaderGhostRef = useRef<HTMLDivElement>(null);
@@ -1034,12 +1049,29 @@ export function EmailViewer({
const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState<PostalMimeAttachment[]>([]);
const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false);
// Plugin detail sidebar state
// Plugin detail sidebar state. Collapsed/width persist across opens and
// sessions so the panel reopens the way the user last left it.
const detailSlots = usePluginSlotOffers('email-detail-sidebar');
const hasDetailSidebar = detailSlots.length > 0;
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(false);
const [detailSidebarWidth, setDetailSidebarWidth] = useState(280);
const detailSidebarWidthRef = useRef(280);
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
try { return localStorage.getItem('emailDetailSidebarCollapsed') === '1'; } catch { return false; }
});
const [detailSidebarWidth, setDetailSidebarWidth] = useState<number>(() => {
if (typeof window === 'undefined') return 280;
try {
const n = parseInt(localStorage.getItem('emailDetailSidebarWidth') ?? '', 10);
return Number.isFinite(n) ? Math.max(200, Math.min(500, n)) : 280;
} catch { return 280; }
});
const detailSidebarWidthRef = useRef(detailSidebarWidth);
useEffect(() => {
try { localStorage.setItem('emailDetailSidebarCollapsed', detailSidebarCollapsed ? '1' : '0'); } catch { /* ignore */ }
}, [detailSidebarCollapsed]);
useEffect(() => {
try { localStorage.setItem('emailDetailSidebarWidth', String(detailSidebarWidth)); } catch { /* ignore */ }
}, [detailSidebarWidth]);
// Ensure S/MIME key records are loaded from IndexedDB
useLayoutEffect(() => {
@@ -1300,6 +1332,10 @@ export function EmailViewer({
}, markAsReadDelay);
return () => clearTimeout(timeout);
// Keyed to email id + $seen only: depending on the whole `email` object
// would reset the mark-as-read delay timer whenever any unrelated email
// field updates (e.g. a background re-fetch).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email?.id, email?.keywords?.$seen, onMarkAsRead]);
// Reset external content permission and quick reply when email changes
@@ -2229,6 +2265,11 @@ export function EmailViewer({
}));
return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted];
// The memo derives only from `email.attachments` (findCalendarAttachment
// scans that array); depending on the whole `email` object would rebuild the
// attachment list — and its downstream layout measurement — on every email
// field change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments, calendarInvitationParsingEnabled, hideInlineImageAttachments]);
// Measure attachment chips in the below-header row to determine how many fit
@@ -2422,7 +2463,7 @@ export function EmailViewer({
// Sanitize and prepare email HTML content
const emailContent = useMemo(() => {
if (!email) return { html: "", isHtml: false, hasStyleTag: false };
if (!email) return { html: "", isHtml: false, hasStyleTag: false, externalBlocked: false };
// Check if we have body values
if (email.bodyValues) {
@@ -2490,38 +2531,14 @@ export function EmailViewer({
}
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
const htmlNode = node as HTMLElement;
if (shouldBlockExternal) {
if (node.tagName === 'IMG') {
const src = node.getAttribute('src');
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
node.setAttribute('data-blocked-src', src);
node.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4=');
node.setAttribute('alt', '');
htmlNode.style.display = 'none';
blockedExternalContent = true;
}
}
const bgAttr = node.getAttribute?.('background');
if (bgAttr && (bgAttr.startsWith('http://') || bgAttr.startsWith('https://') || bgAttr.startsWith('//'))) {
node.setAttribute('data-blocked-background', bgAttr);
node.removeAttribute('background');
// Blocks every external-resource vector (img src incl.
// whitespace/newline tricks, srcset, <source>, <video poster>,
// media src, background attr, inline style url() incl. CSS
// escapes). The strict iframe CSP below is the network backstop.
if (blockExternalResourcesOnNode(node)) {
blockedExternalContent = true;
}
if (htmlNode.style) {
const style = htmlNode.style.cssText;
if (style && style.includes('url(')) {
const urlMatch = style.match(/url\(['"]?(https?:\/\/[^'")\s]+)['"]?\)/gi);
if (urlMatch) {
node.setAttribute('data-blocked-style', style);
htmlNode.style.cssText = style.replace(/url\(['"]?https?:\/\/[^'")\s]+['"]?\)/gi, 'url()');
blockedExternalContent = true;
}
}
}
}
if (node.tagName === 'A') {
@@ -2552,6 +2569,11 @@ export function EmailViewer({
html: cleanHtml,
isHtml: true,
hasStyleTag: /<style[\s>]/i.test(htmlContent),
// Drives the strict iframe CSP: when we're in blocking mode the
// iframe forbids external img/media/font fetches entirely, so any
// vector the DOM walk above missed (e.g. <style>-tag url()) still
// can't phone home. Cleared once the user allows / trusts.
externalBlocked: shouldBlockExternal,
};
}
@@ -2563,6 +2585,7 @@ export function EmailViewer({
html: plainTextToSafeHtml(textContent),
isHtml: false,
hasStyleTag: false,
externalBlocked: false,
};
}
}
@@ -2578,6 +2601,7 @@ export function EmailViewer({
html: `<div style="color: var(--color-muted-foreground); font-style: italic;">${previewHtml}</div>`,
isHtml: false,
hasStyleTag: false,
externalBlocked: false,
};
}
@@ -2585,12 +2609,16 @@ export function EmailViewer({
html: `<p style="color: var(--color-muted-foreground); font-style: italic;">${t('no_body_content')}</p>`,
isHtml: false,
hasStyleTag: false,
externalBlocked: false,
};
// Intentionally omit allowExternalContent and trust state from deps:
// toggling permission imperatively unblocks content via restoreBlockedContent
// in an effect below, so the iframe srcDoc stays stable and doesn't reload/flash.
// Recompute when permission changes so the srcDoc rebuilds with the
// unblocked content AND the permissive CSP. The strict blocking-mode CSP
// can't be relaxed in place (a document's CSP is fixed at load), so the
// "Load images" / "Trust sender" buttons (both flip allowExternalContent)
// intentionally trigger a fresh srcDoc. Trust selectors are read inside and
// re-read on that rebuild, so they're deliberately omitted from deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email, externalContentPolicy, cidBlobUrls, t]);
}, [email, externalContentPolicy, allowExternalContent, cidBlobUrls, t]);
// Override email content with S/MIME decrypted content when available
const effectiveEmailContent = useMemo(() => {
@@ -2602,26 +2630,26 @@ export function EmailViewer({
}
);
const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(smimeDecryptedHtml) };
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(smimeDecryptedHtml), externalBlocked: false };
}
if (smimeDecryptedText) {
return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false, hasStyleTag: false };
return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false, hasStyleTag: false, externalBlocked: false };
}
// TNEF (winmail.dat) extracted content
if (tnefHtml) {
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(tnefHtml) };
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(tnefHtml), externalBlocked: false };
}
if (tnefText) {
return { html: plainTextToSafeHtml(tnefText), isHtml: false, hasStyleTag: false };
return { html: plainTextToSafeHtml(tnefText), isHtml: false, hasStyleTag: false, externalBlocked: false };
}
// Embedded message/rfc822 unwrapped content
if (embeddedEmailHtml) {
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(embeddedEmailHtml) };
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(embeddedEmailHtml), externalBlocked: false };
}
if (embeddedEmailText) {
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false, hasStyleTag: false };
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false, hasStyleTag: false, externalBlocked: false };
}
return emailContent;
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
@@ -2762,6 +2790,86 @@ export function EmailViewer({
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}, [onDownloadAttachment, email?.id, resolveAttachmentName]);
// Bundle every attachment of this email into a single .zip and download it.
// Fetches blob-backed attachments through the JMAP client and reuses already
// decoded bytes for S/MIME-decrypted and TNEF-extracted ones. Individual
// failures are skipped so a single bad blob doesn't sink the whole archive.
const handleDownloadAllAttachments = useCallback(async () => {
if (isDownloadingAll || effectiveAttachments.length === 0) return;
setIsDownloadingAll(true);
try {
const { default: JSZip } = await import('jszip');
const zip = new JSZip();
const used = new Set<string>();
// Zip entries must be unique; suffix collisions with " (n)" before the
// extension so duplicates stay recognisable.
const uniqueName = (raw: string): string => {
const base = raw || 'attachment';
if (!used.has(base)) { used.add(base); return base; }
const dot = base.lastIndexOf('.');
const stem = dot > 0 ? base.slice(0, dot) : base;
const ext = dot > 0 ? base.slice(dot) : '';
let i = 1;
let candidate = `${stem} (${i})${ext}`;
while (used.has(candidate)) { i++; candidate = `${stem} (${i})${ext}`; }
used.add(candidate);
return candidate;
};
let added = 0;
for (const attachment of effectiveAttachments) {
const entryName = uniqueName(getAttachmentDisplayName(attachment.name, attachment.type));
try {
if (attachment.blobId && client) {
const blob = await client.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type);
zip.file(entryName, blob);
added++;
} else if (attachment.tnefData) {
zip.file(entryName, attachment.tnefData);
added++;
} else if (attachment.decryptedAttachment) {
const bytes = getAttachmentContentBytes(attachment.decryptedAttachment);
if (bytes && bytes.byteLength > 0) {
zip.file(entryName, bytes);
added++;
}
}
} catch {
// Skip individual failures; remaining attachments still bundle.
}
}
if (added === 0) return;
const zipBlob = await zip.generateAsync({ type: 'blob', mimeType: 'application/zip' });
const objectUrl = URL.createObjectURL(zipBlob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = attachmentsBundleFilename(email);
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
} finally {
setIsDownloadingAll(false);
}
}, [isDownloadingAll, effectiveAttachments, client, email]);
// Shared "Download all" chip, shown only when bundling is worthwhile (2+).
const downloadAllButton = effectiveAttachments.length > 1 ? (
<button
onClick={(e) => { e.stopPropagation(); handleDownloadAllAttachments(); }}
disabled={isDownloadingAll}
title={t('download_all')}
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/60 rounded-md border border-border/50 transition-colors flex-shrink-0 disabled:opacity-60 disabled:cursor-wait"
>
{isDownloadingAll
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: <FileArchive className="w-3.5 h-3.5" />}
{t('download_all')}
</button>
) : null;
// Pre-fetch object URLs for image attachments so their actual contents can be
// rendered as thumbnails inside the chip. Skips images larger than 10 MB.
useEffect(() => {
@@ -2842,31 +2950,44 @@ export function EmailViewer({
// If email has native dark mode, let it handle its own theming
// Otherwise, use CSS filter inversion for dark mode (preserves layout)
// Re-invert leaf media elements so they appear normal.
// Container selectors (bgcolor, background, etc.) use :not(:has(...)) to avoid
// double re-inverting images nested inside those containers.
// Nested bgcolor containers must NOT add another invert layer: each filter
// toggles the inversion, so an odd number of stacked filters (e.g. body +
// outer bgcolor table + inner bgcolor table) produces an inverted result -
// i.e. light-on-light. The second rule disables filter on bgcolor-like
// elements that are descendants of another bgcolor-like element.
//
// Background-COLOR containers (bgcolor, background: shorthand) are
// re-inverted so their fill flips to a dark tone, but skipped when they
// wrap media: the inner image already gets its own re-invert, and a filter
// on the container would double-invert it. Nested color containers must NOT
// stack another invert either - each filter toggles the inversion, so an
// odd number of stacked filters (body + outer + inner) lands back on
// light-on-light; the descendant rule disables filter on a color container
// nested inside another.
//
// Background-IMAGE containers are different: a real photo and whatever is
// composited over it (headline text, logos) was designed by the sender to
// read together, so the whole subtree should return to its original
// light-mode appearance - even when it wraps media. We therefore re-invert
// these unconditionally (no :has guard) and cancel the per-image re-invert
// for media inside them, otherwise those images would flip back to inverted.
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
html { background: #121212; }
body { filter: invert(1) hue-rotate(180deg); background: #ededed; }
img, video, svg, canvas, object, embed, input[type="image"] {
filter: invert(1) hue-rotate(180deg);
}
[style*="background-image"]:not(:has(img, video, svg, canvas, object, embed)),
[style*="background:"]:not(:has(img, video, svg, canvas, object, embed)),
[background]:not(:has(img, video, svg, canvas, object, embed)),
[bgcolor]:not(:has(img, video, svg, canvas, object, embed)),
td[background]:not(:has(img, video, svg, canvas, object, embed)),
table[background]:not(:has(img, video, svg, canvas, object, embed)) {
[bgcolor]:not(:has(img, video, svg, canvas, object, embed)) {
filter: invert(1) hue-rotate(180deg);
}
:where([style*="background-image"], [style*="background:"], [background], [bgcolor])
:where([style*="background-image"], [style*="background:"], [background], [bgcolor]):not(:has(img, video, svg, canvas, object, embed)) {
:where([style*="background:"], [bgcolor])
:where([style*="background:"], [bgcolor]):not(:has(img, video, svg, canvas, object, embed)) {
filter: none !important;
}
[style*="background-image"],
[background] {
filter: invert(1) hue-rotate(180deg);
}
[style*="background-image"] :where(img, video, svg, canvas, object, embed, input[type="image"]),
[background] :where(img, video, svg, canvas, object, embed, input[type="image"]) {
filter: none;
}
` : '';
const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light';
@@ -2888,89 +3009,59 @@ export function EmailViewer({
p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0 0 6px; }
` : '';
// Defense-in-depth CSP inside srcDoc: even if the sanitizer ever lets a
// <script> tag through, the iframe document forbids script execution
// (default-src 'none'). img/style/font remain permissive to match what the
// sanitizer is allowed to emit and what the host already permits when
// external content is loaded.
const iframeCsp = "default-src 'none'; img-src data: blob: http: https:; style-src 'unsafe-inline'; font-src data: http: https:; media-src data: blob: http: https:; base-uri 'none'; form-action 'none'; frame-src 'none'";
// Defense-in-depth CSP inside srcDoc. default-src 'none' forbids script
// execution even if the sanitizer ever lets a <script> through.
//
// When external content is blocked, img/media/font are restricted to
// data:/blob: only — this is the network-level backstop for every tracking
// vector, including ones the DOM-walk blocker can't see (CSS escapes,
// <style>-tag url(), @font-face). When the user loads/trusts the sender the
// srcDoc is rebuilt (see emailContent) with the permissive variant so real
// images, web fonts and media load. cid:/inline images are pre-rewritten to
// blob: URLs, so they survive the strict variant.
const iframeCsp = effectiveEmailContent.externalBlocked
? "default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:; media-src data: blob:; base-uri 'none'; form-action 'none'; frame-src 'none'"
: "default-src 'none'; img-src data: blob: http: https:; style-src 'unsafe-inline'; font-src data: http: https:; media-src data: blob: http: https:; base-uri 'none'; form-action 'none'; frame-src 'none'";
return `<!DOCTYPE html>
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${iframeCsp}">
<meta name="referrer" content="no-referrer">
<meta http-equiv="x-dns-prefetch-control" content="off">
<style>
/* Force content height: some emails set html/body { height: 100% }, which -
combined with overflow:hidden and our scrollHeight-based auto-resize -
collapses the measured height and clips everything below the fold. */
html, body { overflow: hidden; height: auto !important; }
body { margin: 0; padding: ${bodyPadding}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
collapses the measured height and clips everything below the fold. The
height reset is repeated in a trailing <style> after the body content
(see below): the email's own <style> is injected inside our <body> and so
lands later in source order, where - at equal specificity and !important -
it would otherwise win the cascade. */
/* overflow-y stays hidden so our scrollHeight-based height measurement works;
overflow-x is auto on the body so an intrinsically wide table (e.g. a
20-column data table) can scroll horizontally instead of being crushed to
fit - the latter wraps header text to one character per line, which reads
as 90deg-rotated vertical headers (issue #409). */
html { overflow: hidden; height: auto !important; }
body { margin: 0; padding: ${bodyPadding}; overflow-x: auto; overflow-y: hidden; height: auto !important; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
@media (max-width: 640px) { body { padding-left: ${mobileBodyPaddingX}; padding-right: ${mobileBodyPaddingX}; } }
img { max-width: 100% !important; height: auto !important; }
a { color: #1a73e8; }
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
td, th { overflow-wrap: anywhere; }
/* break-word (not anywhere): break only over-long single words, and keep each
word's min-content width so columns are not collapsed to a single char. */
td, th { overflow-wrap: break-word; }
pre { white-space: pre-wrap; word-wrap: break-word; }
${wordHtmlCSS}
${darkModeCSS}
</style></head><body>${effectiveEmailContent.html}</body></html>`;
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]);
</style></head><body>${effectiveEmailContent.html}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body></html>`;
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode]);
// Imperatively restore blocked external content inside the iframe document.
// Avoids re-rendering the iframe srcDoc (which would reload and flash) when
// the user clicks "Load images" or "Trust sender".
const restoreBlockedContent = useCallback(() => {
const doc = iframeRef.current?.contentDocument;
if (!doc) return;
doc.querySelectorAll('img[data-blocked-src]').forEach((node) => {
const el = node as HTMLImageElement;
const src = el.getAttribute('data-blocked-src');
if (src) {
el.setAttribute('src', src);
el.style.display = '';
el.removeAttribute('data-blocked-src');
}
});
doc.querySelectorAll('[data-blocked-style]').forEach((node) => {
const el = node as HTMLElement;
const style = el.getAttribute('data-blocked-style');
if (style !== null) {
el.style.cssText = style;
el.removeAttribute('data-blocked-style');
}
});
doc.querySelectorAll('[data-blocked-background]').forEach((node) => {
const el = node as HTMLElement;
const bg = el.getAttribute('data-blocked-background');
if (bg) {
el.setAttribute('background', bg);
el.removeAttribute('data-blocked-background');
}
});
doc.querySelectorAll('[data-blocked-collapsed-style]').forEach((node) => {
const el = node as HTMLElement;
const style = el.getAttribute('data-blocked-collapsed-style');
if (style !== null) {
el.style.cssText = style;
el.removeAttribute('data-blocked-collapsed-style');
}
});
}, []);
// Whenever permission is granted (allow toggled, or sender becomes trusted),
// restore blocked content in the existing iframe - no srcDoc rebuild.
const senderEmailLower = email?.from?.[0]?.email?.toLowerCase();
const senderIsTrustedNow = senderEmailLower
? isSenderTrusted(senderEmailLower) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmailLower))
: false;
useEffect(() => {
if (!hasBlockedContent) return;
if (!allowExternalContent && !senderIsTrustedNow) return;
restoreBlockedContent();
}, [allowExternalContent, senderIsTrustedNow, hasBlockedContent, restoreBlockedContent]);
// Unblocking external content is handled by rebuilding the iframe srcDoc:
// toggling allowExternalContent (both "Load images" and "Trust sender" set
// it) recomputes emailContent without blocking and swaps the strict CSP for
// the permissive one. In-place restore isn't possible because a document's
// CSP is fixed at load — the strict blocking-mode CSP would keep refusing the
// restored URLs.
// Tracks the last rendered body height so the loading skeleton can hold
// the same size - avoids the body shrink/expand flash when switching emails.
@@ -3051,10 +3142,13 @@ export function EmailViewer({
if (['IMG', 'VIDEO', 'SVG', 'CANVAS', 'OBJECT', 'EMBED'].includes(tag)) return;
const computed = win.getComputedStyle(htmlEl);
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
// Only re-invert if this container doesn't have media children
if (!htmlEl.querySelector('img, video, svg, canvas, object, embed')) {
htmlEl.style.filter = 'invert(1) hue-rotate(180deg)';
}
// Re-invert so the background image returns to its original
// appearance. Matches the CSS background-image rules: re-invert
// even when media is nested, then cancel the per-image
// re-invert on that media so it isn't left double-inverted.
htmlEl.style.filter = 'invert(1) hue-rotate(180deg)';
htmlEl.querySelectorAll('img, video, svg, canvas, object, embed, input[type="image"]')
.forEach(m => { (m as HTMLElement).style.filter = 'none'; });
}
});
@@ -3356,6 +3450,9 @@ export function EmailViewer({
if (autoMdnRef.current === email.id) return;
autoMdnRef.current = email.id;
sendReadReceiptNow(true).catch(() => { autoMdnRef.current = null; });
// email is already captured via email?.id and sendReadReceiptNow (which
// depends on `email`); the autoMdnRef guard prevents a double send.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [readReceiptResponse, shouldOfferReadReceipt, email?.id, sendReadReceiptNow]);
// Show loading skeleton while email is being fetched
@@ -3499,9 +3596,19 @@ export function EmailViewer({
)}
{isScheduled && canCancelScheduled && (
<>
<Button variant="default" size="sm" onClick={onCancelScheduled} className="sm:flex sm:h-8" title={t('cancel_scheduled_send')}>
<Button
variant="default"
size="sm"
onClick={() => onRescheduleScheduled?.(new Date(Date.now() + 1000).toISOString())}
className="sm:flex sm:h-8"
title={t('send_now')}
>
<Send className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('send_now')}</span>}
</Button>
<Button variant="ghost" size="sm" onClick={onCancelScheduled} className="sm:flex sm:h-8" title={t('cancel_scheduled_send')}>
<X className="w-4 h-4" />
<span className="hidden sm:inline text-sm">{t('cancel_scheduled_send')}</span>
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('cancel_scheduled_send')}</span>}
</Button>
<Button
variant="ghost"
@@ -3788,17 +3895,17 @@ export function EmailViewer({
{/* Dark/light mode toggle for HTML emails */}
{effectiveEmailContent.isHtml && (
<Button
variant="ghost"
size="sm"
onClick={() => setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev)}
data-overflow-item
data-overflow-priority="11"
className="hidden sm:inline-flex h-8 gap-1.5"
title={isDark ? 'View in light mode' : 'View in dark mode'}
>
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev)}
data-overflow-item
data-overflow-priority="11"
className="hidden sm:inline-flex h-8 gap-1.5"
title={isDark ? 'View in light mode' : 'View in dark mode'}
>
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</Button>
)}
{/* More menu - click-based */}
@@ -4040,9 +4147,8 @@ export function EmailViewer({
return (
<div
key={email.id}
data-tour="email-viewer"
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)}
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden relative", className)}
>
{/* Mobile More menu sidebar overlay */}
{!isScheduled && isMobile && moreMenuOpen && (
@@ -4508,6 +4614,7 @@ export function EmailViewer({
+{effectiveAttachments.length - 2} {t('more')}
</button>
)}
{downloadAllButton}
{/* Floating popup for remaining attachments */}
{showAllBesideAttachments && effectiveAttachments.length > 2 && (
<>
@@ -5014,6 +5121,7 @@ export function EmailViewer({
<div className="flex items-center gap-2">
{canCancelScheduled && (
<>
<Button size="sm" variant="default" onClick={() => onRescheduleScheduled?.(new Date(Date.now() + 1000).toISOString())}>{t('send_now')}</Button>
<Button size="sm" variant="outline" onClick={onCancelScheduled}>{t('cancel_scheduled_send')}</Button>
<Button
size="sm"
@@ -5155,8 +5263,8 @@ export function EmailViewer({
{/* === ATTACHMENTS below header (below-header mode, desktop only) === */}
{attachmentPosition === 'below-header' && effectiveAttachments.length > 0 && (
<div className="hidden lg:block bg-background border-b border-border px-4 lg:px-6 py-2">
<div className="relative">
<div ref={belowHeaderRowRef} className="relative flex items-center gap-2 overflow-hidden">
<div className="relative flex items-center gap-2">
<div ref={belowHeaderRowRef} className="relative flex items-center gap-2 overflow-hidden flex-1 min-w-0">
{/* Hidden ghost row used purely for measuring chip widths */}
<div
ref={belowHeaderGhostRef}
@@ -5277,6 +5385,7 @@ export function EmailViewer({
</button>
)}
</div>
{downloadAllButton}
{showAllBelowHeaderAttachments && visibleBelowHeaderCount !== null && effectiveAttachments.length > visibleBelowHeaderCount && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowAllBelowHeaderAttachments(false)} />
@@ -5414,6 +5523,7 @@ export function EmailViewer({
+{effectiveAttachments.length - 2} {t('more')}
</button>
)}
{downloadAllButton}
{showAllMobileAttachments && effectiveAttachments.length > 2 && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowAllMobileAttachments(false)} />
@@ -5796,6 +5906,10 @@ export function EmailViewer({
return allRecipients.find(r => r.email.toLowerCase() === contactSidebarEmail.toLowerCase())?.name;
})()}
onClose={() => setContactSidebarEmail(null)}
onEditContact={sidebarContact ? () => {
router.push(`/contacts?contactId=${sidebarContact.id}&view=edit`);
setContactSidebarEmail(null);
} : undefined}
onAddToContacts={(addr, name) => {
const { createContact, addLocalContact, supportsSync } = useContactStore.getState();
const client = useAuthStore.getState().client;
+9
View File
@@ -4,6 +4,8 @@ import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
import { DOMSerializer } from "@tiptap/pm/model";
import type { Editor } from "@tiptap/react";
import { buildSignatureBlock } from "@/components/email/signature-block";
// Marker attribute that identifies the quoted-original wrapper in serialized
// HTML, so parseHTML can recognise it on the way back in.
export const QUOTED_HTML_MARKER = "data-quoted-html";
@@ -166,6 +168,13 @@ export function serializeEditorContent(editor: Editor): string {
parts.push(buildQuotedHtmlBlock((node.attrs.html as string) || ""));
return;
}
if (node.type.name === "signatureBlock") {
// Same rationale as quotedHtml: inline the verbatim signature HTML so the
// styled signature reaches the recipient (and a saved draft round-trips)
// instead of the schema-flattened version.
parts.push(buildSignatureBlock((node.attrs.html as string) || ""));
return;
}
const fragment = serializer.serializeNode(node);
const tmp = document.createElement("div");
tmp.appendChild(fragment);
+5
View File
@@ -17,6 +17,7 @@ import { TableRow } from "@tiptap/extension-table-row";
import { TableHeader } from "@tiptap/extension-table-header";
import { TableCell } from "@tiptap/extension-table-cell";
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
import { SignatureBlock } from "@/components/email/signature-block";
import { cn } from "@/lib/utils";
import {
Bold,
@@ -235,6 +236,10 @@ export function RichTextEditor({
// Quoted/forwarded original email body - held verbatim as an atomic
// node so layout-heavy HTML survives 1:1 (see quoted-html.ts).
QuotedHtml,
// Identity signature - held verbatim as a non-editable atomic node so
// rich/branded signatures keep their inline styling in the editor and
// in the sent mail (see signature-block.ts).
SignatureBlock,
],
content,
editorProps: {
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
// Marker attribute that identifies the signature wrapper in serialized HTML,
// so parseHTML can recognise it on the way back in (initial content, drafts).
export const SIGNATURE_BLOCK_MARKER = "data-signature-block-node";
/**
* SignatureBlock an atomic, NON-editable block node that carries the
* *verbatim* HTML of the user's identity signature in its `html` attribute.
*
* Why: the signature is embedded into the composer so it stays in the body,
* but parsing rich, table-based "brand" signatures into the ProseMirror schema
* strips their inline CSS (background/text colors, fonts, border-radius). By
* holding the signature as an atom it is never parsed into the schema, so the
* styling survives 1:1 both in the editor (rendered by the NodeView below)
* and in the sent mail (emitted by serializeEditorContent in quoted-html.ts).
*
* Mirrors QuotedHtml, but the inner region is read-only: a signature is meant
* to be inserted/removed as a unit, not edited inline. Select the node and
* press Backspace/Delete to drop the whole signature.
*/
export const SignatureBlock = TiptapNode.create({
name: "signatureBlock",
group: "block",
atom: true,
selectable: true,
draggable: false,
// Isolating keeps selection/gapcursor behaviour sane at the boundary.
isolating: true,
addAttributes() {
return {
html: {
default: "",
// Capture the verbatim inner HTML when parsing. Because the node is an
// atom, ProseMirror does NOT descend into the children, so the rich
// signature markup never hits (and is never mangled by) the schema.
parseHTML: (el) => el.innerHTML,
// The real content round-trips via the custom serializer
// (serializeEditorContent); renderHTML below only needs the wrapper.
renderHTML: () => ({}),
},
};
},
parseHTML() {
return [{ tag: `div[${SIGNATURE_BLOCK_MARKER}]` }];
},
renderHTML({ HTMLAttributes }) {
// Only used for ProseMirror's internal/clipboard round-trip. The send /
// draft path uses serializeEditorContent() which inlines attrs.html.
return ["div", mergeAttributes(HTMLAttributes, { [SIGNATURE_BLOCK_MARKER]: "" })];
},
addNodeView() {
return ({ node }) => {
const dom = document.createElement("div");
dom.setAttribute(SIGNATURE_BLOCK_MARKER, "");
dom.className = "signature-block-island";
// CRITICAL: render the signature inside a Shadow Root. The app's global
// CSS (Tailwind preflight, .tiptap table/td rules, box-sizing resets)
// would otherwise cascade INTO the signature and destroy its layout -
// exactly the corruption we are fixing. Shadow DOM isolates both
// directions, so only the browser's UA defaults + the signature's own
// inline styles apply and the in-editor preview matches the sent mail.
const shadow = dom.attachShadow({ mode: "open" });
const inner = document.createElement("div");
// Read-only: a signature is inserted/removed as a unit, not edited inline.
inner.contentEditable = "false";
inner.innerHTML = node.attrs.html || "";
shadow.appendChild(inner);
return {
dom,
// ProseMirror must not try to reconcile the foreign shadow content.
ignoreMutation: () => true,
// Let ProseMirror handle all events so clicking selects the atom and
// Backspace/Delete removes the whole signature.
stopEvent: () => false,
update: (updatedNode) => {
if (updatedNode.type.name !== "signatureBlock") return false;
if (inner.innerHTML !== (updatedNode.attrs.html || "")) {
inner.innerHTML = updatedNode.attrs.html || "";
}
return true;
},
};
};
},
});
/**
* Build the editor-content wrapper that embeds the signature as a single
* SignatureBlock node. The inner HTML must be pre-sanitized
* (sanitizeSignatureHtml). The `data-signature-block-node` marker is what
* parseHTML keys on, so this exact form must be what serializeEditorContent
* emits too (round-trip consistency).
*/
export function buildSignatureBlock(sanitizedInnerHtml: string): string {
return `<div ${SIGNATURE_BLOCK_MARKER}>${sanitizedInnerHtml}</div>`;
}
+92 -49
View File
@@ -2,10 +2,10 @@
import React, { useCallback } from "react";
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock } from "lucide-react";
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store";
@@ -17,6 +17,23 @@ import { ThreadEmailItem } from "./thread-email-item";
import { EmailHoverActions } from "./email-hover-actions";
import { useTranslations } from "next-intl";
/**
* Small chip showing the originating folder of a message, rendered in the
* aggregate "All …" views (All Mail / unified / cross-account) where rows come
* from different folders. `email.sourceFolder` is stamped at fetch time.
*/
function SourceFolderTag({ name }: { name: string }) {
return (
<span
className="inline-flex max-w-[8rem] shrink-0 items-center gap-1 truncate rounded-full border border-border bg-muted/40 px-1.5 py-0.5 text-[11px] text-muted-foreground"
title={name}
>
<Folder className="h-3 w-3 shrink-0" />
<span className="truncate">{name}</span>
</span>
);
}
interface ThreadListItemProps {
thread: ThreadGroup;
isExpanded: boolean;
@@ -24,6 +41,7 @@ interface ThreadListItemProps {
isLoading?: boolean;
expandedEmails?: Email[];
onToggleExpand: () => void;
onCollapseAllThreads?: () => void;
onEmailSelect: (email: Email) => void;
onEmailDoubleClick?: (email: Email) => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
@@ -34,6 +52,7 @@ interface ThreadListItemProps {
onArchive?: (email: Email) => void;
onSetColorTag?: (emailId: string, color: string | null) => void;
onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void;
}
interface SingleEmailItemProps {
@@ -50,18 +69,22 @@ interface SingleEmailItemProps {
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
}
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) {
const t = useTranslations('email_viewer');
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
// In Sent/Drafts folders, show recipient instead of sender (which is always
// "me"). In aggregate role-views the selected mailbox is virtual → fall back
// to the unified role so junk-contextual UI and avatar hiding work.
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
@@ -70,7 +93,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const timeFormat = useSettingsStore((state) => state.timeFormat);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
// Show the originating folder in the aggregate "All …" views.
const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!email.sourceFolder;
const getAccountById = useAccountStore((state) => state.getAccountById);
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
const isChecked = selectedEmailIds.has(email.id);
@@ -187,7 +211,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)}
{isUnread && (
<div className="absolute left-1 top-1/2 -translate-y-1/2">
<div className="absolute left-0.5 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-unread text-unread" />
</div>
)}
@@ -221,13 +245,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={cn(
'shrink-0 truncate',
'min-w-0 truncate',
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
)}>
{email.subject || '(no subject)'}
</span>
{inlinePreview && (
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
)}
</div>
</div>
@@ -245,6 +269,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300"
@@ -313,6 +338,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{kd.label}
</span>
))}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium tabular-nums text-sky-700 dark:text-sky-300"
@@ -369,6 +395,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
onArchive={onArchive}
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
/>
)}
</div>
@@ -384,6 +412,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
isLoading = false,
expandedEmails,
onToggleExpand,
onCollapseAllThreads,
onEmailSelect,
onEmailDoubleClick,
onContextMenu,
@@ -394,6 +423,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onArchive,
onSetColorTag,
onMarkAsSpam,
onUndoSpam,
}, ref) {
const t = useTranslations('threads');
const tEmailViewer = useTranslations('email_viewer');
@@ -412,11 +442,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
? formatDateTime(latestEmail.scheduledSendAt, timeFormat)
: null;
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!latestEmail.sourceFolder;
const getAccountById = useAccountStore((state) => state.getAccountById);
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
// In Sent/Drafts folders, show recipient instead of sender (which is always
// "me"). Aggregate role-views use a virtual selected mailbox → fall back to
// the unified role so junk-contextual UI and avatar hiding work.
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const displayNames = showRecipient
? Array.from(new Set(
@@ -470,6 +504,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
/>
);
}
@@ -515,6 +550,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
} else {
if (selectedEmailIds.size > 0) clearSelection();
if (!isExpanded) {
onCollapseAllThreads?.();
onToggleExpand();
}
onEmailSelect(latestEmail);
@@ -581,46 +617,49 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</button>
)}
{!isMobile && !isFocusedMailLayout && (
<button
data-expand-toggle
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
className={cn(
"p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"text-muted-foreground hover:text-foreground"
)}
aria-expanded={isExpanded}
aria-label={t('toggle_thread')}
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
)}
{hasUnread && (
<div className="absolute left-1 top-1/2 -translate-y-1/2">
<div className="absolute left-0.5 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-unread text-unread" />
</div>
)}
{density !== 'extra-compact' && (
<Avatar
name={avatarPerson?.name}
email={avatarPerson?.email}
size={isFocusedMailLayout ? "sm" : "md"}
className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
/>
<div className="relative flex-shrink-0">
<Avatar
name={avatarPerson?.name}
email={avatarPerson?.email}
size={isFocusedMailLayout ? "sm" : "md"}
className="shadow-sm"
disableImages={hideJunkAvatarImages}
/>
{!isMobile && !isFocusedMailLayout && (
<button
data-expand-toggle
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
className={cn(
"absolute -bottom-2.5 left-1/2 -translate-x-1/2 p-0.5 rounded-full",
"transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"text-muted-foreground hover:text-foreground",
"bg-background border border-border"
)}
aria-expanded={isExpanded}
aria-label={t('toggle_thread')}
>
{isLoading ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</button>
)}
</div>
)}
<div className="flex-1 min-w-0">
@@ -652,13 +691,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={cn(
'shrink-0 truncate',
'min-w-0 truncate',
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
)}>
{latestEmail.subject || '(no subject)'}
</span>
{inlinePreview && (
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
)}
</div>
</div>
@@ -676,6 +715,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{keywordDef && (
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
)}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300"
@@ -756,6 +796,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{keywordDef.label}
</span>
)}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium tabular-nums text-sky-700 dark:text-sky-300"
@@ -812,6 +853,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
isInJunk={currentMailboxRole === 'junk'}
/>
)}
</div>
+84 -3
View File
@@ -11,7 +11,7 @@ import {
AlertCircle, Star, Clock, FolderUp,
FileArchive, FileSpreadsheet, Presentation, FileCode,
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
Menu,
Menu, Users, Share2,
} from "lucide-react";
import { useIsDesktop } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
@@ -26,6 +26,9 @@ import { ResizeHandle } from "@/components/layout/resize-handle";
import { Avatar } from "@/components/ui/avatar";
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
import type { FileResource } from "@/stores/file-store";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { FileNodeRights } from "@/lib/jmap/types";
type SortKey = "name" | "size" | "modified";
type SortDir = "asc" | "desc";
@@ -95,6 +98,14 @@ interface FileBrowserProps {
accountPickerMode?: boolean;
/** Pro shell only: label of the currently-attached account, shown as a breadcrumb segment after Home. */
accountLabel?: string | null;
/** JMAP client for the browsing account, used by the share dialog to list principals. */
client?: IJMAPClient | null;
/** Files account id of the browsing account; used to exclude self from the share picker. */
ownAccountId?: string | null;
/** True when the server supports JMAP Sharing (principals); gates the Share action. */
sharingEnabled?: boolean;
/** Add/update/remove a principal's share on a node. Set null rights to revoke. */
onShare?: (id: string, principalId: string, rights: FileNodeRights | null) => Promise<void>;
}
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
@@ -230,6 +241,33 @@ function getGridIcon(resource: FileResource) {
return getFileIconByName(resource.name, "lg");
}
// Small inline indicator: a node shared out by the user (shareWith has entries)
// or a node shared *with* the user by another principal (isShared).
function ShareBadge({ resource, t }: { resource: FileResource; t: (key: string) => string }) {
const sharedOut = !!resource.shareWith && Object.keys(resource.shareWith).length > 0;
if (resource.isShared) {
return (
<Share2
className="w-3.5 h-3.5 text-primary shrink-0"
aria-label={t("shared_with_me")}
>
<title>{t("shared_with_me")}</title>
</Share2>
);
}
if (sharedOut) {
return (
<Users
className="w-3.5 h-3.5 text-primary shrink-0"
aria-label={t("shared")}
>
<title>{t("shared")}</title>
</Users>
);
}
return null;
}
function Thumbnail({ name, getImageUrl: fetchUrl, size = "sm" }: {
name: string;
getImageUrl: (n: string) => Promise<string>;
@@ -342,11 +380,25 @@ export function FileBrowser({
onSelectAccount,
accountPickerMode,
accountLabel,
client,
ownAccountId,
sharingEnabled,
onShare,
}: FileBrowserProps) {
const t = useTranslations("files");
const [showNewFolder, setShowNewFolder] = useState(false);
const [renameTarget, setRenameTarget] = useState<string | null>(null);
const [shareTargetId, setShareTargetId] = useState<string | null>(null);
const [isDraggingOver, setIsDraggingOver] = useState(false);
// The share dialog is bound to a node id (not name) so its shareWith stays
// live after a share refresh re-derives the resource list.
const shareTarget = shareTargetId ? resources.find(r => r.id === shareTargetId) ?? null : null;
// A node is shareable when the server supports JMAP Sharing, the viewer owns
// it (not a shared-with-me node), and holds the mayShare right (owned nodes
// report full rights; treat missing myRights as allowed).
const canShare = useCallback((r: FileResource | null | undefined): boolean =>
!!(sharingEnabled && onShare && client && r && !r.isShared && (r.myRights?.mayShare ?? true)),
[sharingEnabled, onShare, client]);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null);
const [showNewTextFile, setShowNewTextFile] = useState(false);
@@ -1440,8 +1492,9 @@ export function FileBrowser({
{showThumbnails && isImageFile(resource.name)
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="lg" />
: getGridIcon(resource)}
<span className="text-xs truncate w-full text-center" title={resource.name}>
{resource.name}
<span className="text-xs truncate w-full text-center flex items-center justify-center gap-1" title={resource.name}>
<span className="truncate">{resource.name}</span>
<ShareBadge resource={resource} t={t} />
</span>
</div>
))}
@@ -1590,6 +1643,7 @@ export function FileBrowser({
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="sm" />
: getFileIcon(resource)}
<span className="truncate">{resource.name}</span>
<ShareBadge resource={resource} t={t} />
</div>
</td>
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell tabular-nums">
@@ -1697,6 +1751,19 @@ export function FileBrowser({
{t("duplicate")}
</button>
)}
{canShare(resources.find(r => r.name === contextMenu.name)) && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
onClick={() => {
const r = resources.find(res => res.name === contextMenu.name);
if (r) setShareTargetId(r.id);
setContextMenu(null);
}}
>
<Share2 className="w-4 h-4" />
{t("share")}
</button>
)}
<div className="h-px bg-border my-1" />
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
@@ -1936,6 +2003,20 @@ export function FileBrowser({
onCancel={() => setRenameTarget(null)}
/>
)}
{/* Share dialog */}
{shareTarget && client && onShare && (
<ShareCollectionDialog
client={client}
kind="file"
collectionName={shareTarget.name}
shareWith={shareTarget.shareWith}
ownAccountId={ownAccountId || ""}
onShare={(principalId, rights) =>
onShare(shareTarget.id, principalId, rights as FileNodeRights | null)}
onClose={() => setShareTargetId(null)}
/>
)}
</div>
);
}
+39
View File
@@ -8,6 +8,7 @@ import {
ChevronRight,
ChevronDown,
Home,
Share2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useFileStore, type FileResource } from "@/stores/file-store";
@@ -29,6 +30,8 @@ interface FolderTreeSidebarProps {
export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, width = 256, isResizing }: FolderTreeSidebarProps) {
const t = useTranslations("files");
const client = useFileStore(s => s.client);
const sharedRoots = useFileStore(s => s.sharedRoots);
const loadSharedRoots = useFileStore(s => s.loadSharedRoots);
const [rootChildren, setRootChildren] = useState<FolderNode[] | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(["root"]));
const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
@@ -81,6 +84,8 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
useEffect(() => {
if (client) {
loadChildren(null, "/");
// Discover folders shared with the user by other principals.
loadSharedRoots();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client]);
@@ -180,6 +185,40 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
/>
))
)}
{/* Shared with me: folders another principal has shared with the user */}
{sharedRoots.filter(r => r.isDirectory).length > 0 && (
<div className="mt-2 pt-2 border-t border-border/60">
<div className="px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
<span className="truncate">{t("shared_with_me")}</span>
</div>
{sharedRoots.filter(r => r.isDirectory).map(r => {
const path = `/${r.name}`;
const isSelected = currentPath === path;
return (
<div
key={r.id}
style={{ paddingBlock: "var(--density-sidebar-py)" }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 px-2",
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted text-foreground"
)}
>
<button
onClick={() => handleFolderClick(path, r.id)}
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left min-w-0"
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
title={r.ownerName ? t("shared_by", { name: r.ownerName }) : r.name}
>
<Folder className="w-4 h-4 flex-shrink-0 mr-2 text-primary" />
<span className="truncate">{r.name}</span>
</button>
</div>
);
})}
</div>
)}
</div>
</div>
);
+3 -3
View File
@@ -166,7 +166,6 @@ export function NavigationRail({
collapsed = false,
className,
quota,
isPushConnected,
onLogout,
onShowShortcuts,
onManageApps,
@@ -191,6 +190,7 @@ export function NavigationRail({
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
@@ -269,7 +269,7 @@ export function NavigationRail({
const navItems: NavItem[] = [
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar || !calendarEnabled },
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts", hidden: !supportsContacts || !contactsEnabled },
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: !supportsFiles || !filesEnabled },
];
@@ -546,7 +546,7 @@ export function NavigationRail({
})}
{/* Manage apps button */}
{onManageApps && (
{sidebarAppsEnabled && onManageApps && (
<button
onClick={onManageApps}
className={cn(
+52 -5
View File
@@ -33,13 +33,16 @@ import {
NotebookPen,
CalendarClock,
BellOff,
Mails,
MailOpen,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { Mailbox } from "@/lib/jmap/types";
import { useContextMenu } from "@/hooks/use-context-menu";
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
import { useAccountStore } from '@/stores/account-store';
import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types';
import { UNIFIED_MAILBOX_IDS, CROSS_VIEW_IDS } from '@/lib/jmap/types';
import type { UnifiedMailboxRole } from '@/lib/jmap/types';
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
@@ -76,6 +79,14 @@ interface SidebarProps {
onRefreshMailboxes?: () => void;
scheduledTotal?: number;
showScheduledMailbox?: boolean;
/** Gated "All Mail" virtual folder that merges all of the account's folders. */
showAllMailMailbox?: boolean;
/** Gated cross-account views in the "All accounts" section. */
showCrossUnread?: boolean;
showCrossStarred?: boolean;
showCrossAll?: boolean;
/** Unread total across all cross-view folders (badge for unread/all). */
crossUnreadCount?: number;
className?: string;
/**
* Multi-account (Pro) mode props. When `multiAccountMode` is true, the
@@ -166,7 +177,6 @@ function getIconClass(isSelected: boolean, isVirtual: boolean, colorful: boolean
function SidebarRowCounts({
unread,
total,
isSelected,
onUnreadClick,
}: {
unread?: number;
@@ -430,12 +440,14 @@ function MailboxTreeItem({
onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void;
}) {
const tNotifications = useTranslations('notifications');
const tSidebar = useTranslations('sidebar');
const hasChildren = node.children.length > 0;
const isExpanded = expandedFolders.has(node.id);
const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id);
const isVirtualNode = node.id.startsWith('shared-');
const isSelected = selectedMailbox === node.id;
const roleKey = resolveRoleKey(node.role, node.name);
const label = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
const { isDragging: globalDragging } = useDragDropContext();
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
@@ -462,7 +474,7 @@ function MailboxTreeItem({
<>
<SidebarRow
icon={<Icon className={getIconClass(isSelected, isVirtualNode, colorful, roleKey)} />}
label={node.name}
label={label}
depth={node.depth}
isSelected={isSelected}
isVirtual={isVirtualNode}
@@ -678,6 +690,11 @@ export function Sidebar({
onRefreshMailboxes,
scheduledTotal = 0,
showScheduledMailbox = false,
showAllMailMailbox = false,
showCrossUnread = false,
showCrossStarred = false,
showCrossAll = false,
crossUnreadCount = 0,
className,
multiAccountMode = false,
accountMailboxes,
@@ -978,7 +995,17 @@ export function Sidebar({
{/* Mailbox List */}
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
{showUnified && (
{showAllMailMailbox && (
<SidebarRow
icon={<Mails className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__all_mail__' ? "text-foreground" : "text-muted-foreground")} />}
label={t('mailboxes.all_mail')}
depth={0}
isSelected={!selectedKeyword && selectedMailbox === '__all_mail__'}
onClick={() => onMailboxSelect?.('__all_mail__')}
isCollapsed={isCollapsed}
/>
)}
{(showUnified || showCrossUnread || showCrossStarred || showCrossAll) && (
<div>
<SidebarSectionHeader
label={t("all_accounts")}
@@ -989,7 +1016,7 @@ export function Sidebar({
/>
{((unifiedExpanded && !isCollapsed) || isCollapsed) && (
<>
{unifiedCounts.map((count) => {
{showUnified && unifiedCounts.map((count) => {
const unifiedId = UNIFIED_MAILBOX_IDS[count.role];
const Icon = getUnifiedIcon(count.role);
const isSelected = !selectedKeyword && selectedMailbox === unifiedId;
@@ -1007,6 +1034,26 @@ export function Sidebar({
/>
);
})}
{[
{ show: showCrossUnread, id: CROSS_VIEW_IDS.unread, Icon: MailOpen, label: t('unified_all_unread'), unread: crossUnreadCount },
{ show: showCrossStarred, id: CROSS_VIEW_IDS.starred, Icon: Star, label: t('unified_all_starred'), unread: undefined as number | undefined },
{ show: showCrossAll, id: CROSS_VIEW_IDS.all, Icon: Mails, label: t('unified_all_mail'), unread: crossUnreadCount },
].map(({ show, id, Icon, label, unread }) => {
if (!show) return null;
const isSelected = !selectedKeyword && selectedMailbox === id;
return (
<SidebarRow
key={id}
icon={<Icon className={getIconClass(isSelected, false, colorfulSidebarIcons)} />}
label={label}
depth={0}
isSelected={isSelected}
unread={unread}
onClick={() => onMailboxSelect?.(id)}
isCollapsed={isCollapsed}
/>
);
})}
</>
)}
</div>
+20 -4
View File
@@ -25,10 +25,9 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
const t = useTranslations();
const client = useAuthStore((s) => s.client);
const sendEmail = useEmailStore((s) => s.sendEmail);
const fetchEmails = useEmailStore((s) => s.fetchEmails);
const refreshCurrentMailbox = useEmailStore((s) => s.refreshCurrentMailbox);
const fetchScheduledEmails = useEmailStore((s) => s.fetchScheduledEmails);
const refreshScheduledMetadata = useEmailStore((s) => s.refreshScheduledMetadata);
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
const isScheduledView = useEmailStore((s) => s.isScheduledView);
const closeTab = useProTabStore((s) => s.closeTab);
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
@@ -92,13 +91,30 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
// Refresh the currently-active mail list so the new sent message /
// updated keyword status shows up.
await fetchEmails(client, selectedMailbox);
await refreshCurrentMailbox(client);
// Re-fetch the replied thread's cross-folder data so the expanded
// view shows the newly sent reply without collapsing.
if (data.sourceEmailId) {
const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === data.sourceEmailId);
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
const accountId = client.getAccountId();
const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId);
if (fullEmails.length > 0) {
useEmailStore.setState((state) => {
const c = new Map(state.threadEmailsCache);
c.set(repliedEmail.threadId!, fullEmails);
return { threadEmailsCache: c };
});
}
}
}
closeTab(tabIdRef.current);
} catch (error) {
console.error('Failed to send email:', error);
toast.error(t('notifications.error_sending'));
}
}, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t, handleScheduledSendCreated]);
}, [client, sendEmail, closeTab, data.sourceEmailId, data.mode, t, handleScheduledSendCreated, refreshCurrentMailbox]);
const handleClose = useCallback(() => {
closeTab(tabIdRef.current);
+7 -1
View File
@@ -8,7 +8,9 @@ import daMessages from '@/locales/da/common.json';
import deMessages from '@/locales/de/common.json';
import enMessages from '@/locales/en/common.json';
import esMessages from '@/locales/es/common.json';
import faMessages from '@/locales/fa/common.json';
import frMessages from '@/locales/fr/common.json';
import huMessages from '@/locales/hu/common.json';
import itMessages from '@/locales/it/common.json';
import jaMessages from '@/locales/ja/common.json';
import koMessages from '@/locales/ko/common.json';
@@ -16,6 +18,7 @@ import lvMessages from '@/locales/lv/common.json';
import nlMessages from '@/locales/nl/common.json';
import plMessages from '@/locales/pl/common.json';
import ptMessages from '@/locales/pt/common.json';
import roMessages from '@/locales/ro/common.json';
import ruMessages from '@/locales/ru/common.json';
import trMessages from '@/locales/tr/common.json';
import ukMessages from '@/locales/uk/common.json';
@@ -28,7 +31,9 @@ const ALL_MESSAGES = {
de: deMessages,
en: enMessages,
es: esMessages,
fa: faMessages,
fr: frMessages,
hu: huMessages,
it: itMessages,
ja: jaMessages,
ko: koMessages,
@@ -36,6 +41,7 @@ const ALL_MESSAGES = {
nl: nlMessages,
pl: plMessages,
pt: ptMessages,
ro: roMessages,
ru: ruMessages,
tr: trMessages,
uk: ukMessages,
@@ -86,7 +92,7 @@ export function IntlProvider({ locale: initialLocale, children }: IntlProviderPr
return (
<NextIntlClientProvider
locale={activeLocale}
messages={ALL_MESSAGES[activeLocale as keyof typeof ALL_MESSAGES]}
messages={ALL_MESSAGES[activeLocale as keyof typeof ALL_MESSAGES] ?? ALL_MESSAGES.en}
timeZone={timeZone}
>
{children}
@@ -1,15 +1,18 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import QRCode from 'qrcode';
import * as OTPAuth from 'otpauth';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor, Terminal } from 'lucide-react';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor, Terminal, QrCode } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { useAccountSecurityStore, type AppPasswordInfo, type ApiKeyInfo, type AppCredentialInput } from '@/stores/account-security-store';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
import { toast } from '@/stores/toast-store';
import { cn } from '@/lib/utils';
import { sanitizeI18nHtml } from '@/lib/email-sanitization';
@@ -638,14 +641,156 @@ function EmailClientSection() {
);
}
// Cross-device QR login. A signed-in (OAuth/SSO) session mints a short-lived
// pairing code via /api/auth/pair/create; we render it as a QR that the mobile
// app scans to sign in without re-typing credentials. The QR payload carries
// only the server URL and the one-time code — never tokens.
function LinkDeviceSection() {
const t = useTranslations('settings.security');
const params = useParams();
const locale = params.locale as string;
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [remaining, setRemaining] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasGenerated, setHasGenerated] = useState(false);
// Tick the countdown down to zero, then drop the (now useless) QR so the
// user is nudged to generate a fresh one.
useEffect(() => {
if (remaining <= 0) {
setQrDataUrl(null);
return;
}
const timer = setInterval(() => setRemaining((r) => Math.max(0, r - 1)), 1000);
return () => clearInterval(timer);
}, [remaining]);
// Send the user to the IdP for a fresh login (prompt=login). On return the
// callback page sets the pairing re-auth proof and bounces back here, where
// the resume effect below re-runs generate().
const startReauth = useCallback(async () => {
try {
sessionStorage.setItem('pair_reauth_resume', '1');
} catch { /* sessionStorage unavailable */ }
const prefix = getPathPrefix(locale);
const redirectUri = `${window.location.origin}${prefix}/${locale}/auth/callback`;
const res = await apiFetch('/api/auth/sso/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ redirect_uri: redirectUri, locale, purpose: 'reauth' }),
});
if (!res.ok) {
setError(t('link_device.error'));
return;
}
const { authorize_url } = await res.json();
window.location.href = authorize_url;
}, [locale, t]);
// `fromResume` guards against a redirect loop: if we just completed re-auth
// and pair/create still demands it, surface an error instead of bouncing to
// the IdP again.
const generate = useCallback(async (fromResume = false) => {
setLoading(true);
setError(null);
try {
// Pair the account whose session cookie we'll actually refresh — the
// active account's slot.
const slot = useAccountStore.getState().getActiveAccount()?.cookieSlot ?? 0;
const res = await apiFetch('/api/auth/pair/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ slot }),
});
if (!res.ok) {
const errBody = await res.json().catch(() => null);
if (res.status === 401 && errBody?.error === 'reauth_required' && !fromResume) {
await startReauth();
return;
}
setError(t('link_device.error'));
return;
}
const data = await res.json();
const code = data.pairing_code as string;
const expiresIn = typeof data.expires_in === 'number' ? data.expires_in : 120;
// The phone redeems the code against THIS webmail (where the pairing
// record lives), so the QR carries the webmail base — origin plus any
// mount prefix — not the JMAP server URL. The JMAP server_url comes back
// in the redeem response.
const webmailBase = `${window.location.origin}${getPathPrefix()}`;
const payload = `bulwarkmail://pair?server=${encodeURIComponent(webmailBase)}&code=${encodeURIComponent(code)}`;
const dataUrl = await QRCode.toDataURL(payload, { width: 240, margin: 1 });
setQrDataUrl(dataUrl);
setRemaining(expiresIn);
setHasGenerated(true);
} catch {
setError(t('link_device.error'));
} finally {
setLoading(false);
}
}, [t, startReauth]);
// Auto-resume after returning from the step-up re-auth round-trip.
useEffect(() => {
let resume = false;
try {
resume = sessionStorage.getItem('pair_reauth_done') === '1';
if (resume) sessionStorage.removeItem('pair_reauth_done');
} catch { /* sessionStorage unavailable */ }
if (resume) void generate(true);
}, [generate]);
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<QrCode className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('link_device.title')}</h4>
</div>
<p className="text-xs text-muted-foreground">{t('link_device.description')}</p>
{qrDataUrl && remaining > 0 && (
<div className="p-3 bg-muted/70 dark:bg-muted/40 rounded-md space-y-2">
<div className="flex justify-center">
<img src={qrDataUrl} alt="Pairing QR code" className="rounded bg-white p-2" />
</div>
<p className="text-xs text-muted-foreground text-center">{t('link_device.instructions')}</p>
<p className="text-[11px] text-muted-foreground text-center">
{t('link_device.expires_in', { seconds: remaining })}
</p>
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
<Button variant="outline" size="sm" onClick={() => void generate()} disabled={loading}>
{loading ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<QrCode className="w-3 h-3 mr-1" />
)}
{hasGenerated ? t('link_device.regenerate') : t('link_device.generate')}
</Button>
</div>
);
}
export function AccountSecuritySettings() {
const t = useTranslations('settings.security');
const { isStalwart, isProbing, probe, fetchAll, fetchAuthInfo } = useAccountSecurityStore();
const { isAuthenticated, authMode } = useAuthStore();
const { isAuthenticated, authMode, client } = useAuthStore();
const isOAuth = authMode === 'oauth';
// Wait for `client` before probing. On reload the persisted `isAuthenticated`
// flips true before the async OAuth reconnect sets `client`; probing in that
// window reads a null client, decides the server isn't Stalwart, and caches
// that wrong verdict. Gating on `client` (which is set only after connect()
// populates the session capabilities) makes the probe run with real data.
useEffect(() => {
if (isAuthenticated && isStalwart === null) {
if (isAuthenticated && client && isStalwart === null) {
probe().then((detected) => {
if (detected) {
if (isOAuth) {
@@ -656,7 +801,7 @@ export function AccountSecuritySettings() {
}
});
}
}, [isAuthenticated, isStalwart, probe, fetchAll, fetchAuthInfo, isOAuth]);
}, [isAuthenticated, client, isStalwart, probe, fetchAll, fetchAuthInfo, isOAuth]);
if (isProbing) {
return (
@@ -670,9 +815,25 @@ export function AccountSecuritySettings() {
}
if (isStalwart === false) {
// Even when Stalwart account-management isn't exposed (common for OAuth
// sessions, whose tokens may lack the management capability), the
// cross-device mobile pairing still works — it only needs the OAuth
// refresh-token cookie, not `urn:stalwart:jmap`. So surface the QR linker
// for OAuth sessions and show the "not available" note for the rest.
// Use t.raw (not t) because the message is hand-injected HTML; passing it
// through t() makes next-intl try to parse the <a> tag and throw
// INVALID_TAG.
return (
<SettingsSection title={t('title')} description={t('description')}>
<div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: sanitizeI18nHtml(t('not_available')) }} />
{isOAuth ? (
<div className="space-y-6">
<LinkDeviceSection />
<div className="border-t border-border" />
<div className="text-sm text-muted-foreground" dangerouslySetInnerHTML={{ __html: sanitizeI18nHtml(t.raw('not_available')) }} />
</div>
) : (
<div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: sanitizeI18nHtml(t.raw('not_available')) }} />
)}
</SettingsSection>
);
}
@@ -706,6 +867,8 @@ export function AccountSecuritySettings() {
<>
<div className="border-t border-border" />
<EmailClientSection />
<div className="border-t border-border" />
<LinkDeviceSection />
</>
)}
+71 -3
View File
@@ -1,11 +1,13 @@
"use client";
import { useState, useRef, useCallback } from 'react';
import { useState, useRef, useCallback, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Check, GripVertical, Plus, Star, AlertCircle } from 'lucide-react';
import { Check, GripVertical, Plus, Star, AlertCircle, ChevronRight } from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import type { SharedAccount } from '@/lib/jmap/types';
import { SettingsSection, SettingItem } from './settings-section';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
@@ -17,12 +19,30 @@ function hostnameOf(serverUrl: string): string {
try { return new URL(serverUrl).hostname; } catch { return serverUrl; }
}
// First scoped settings tab to land on for a shared account, by capability.
// Mirrors the scoped-tab gating in the settings page. null = nothing editable.
function firstScopedTab(caps: SharedAccount['capabilities']): string | null {
if (caps.sieve) return 'filters';
if (caps.mail) return 'vacation';
if (caps.calendars) return 'calendar';
if (caps.contacts) return 'contacts';
return null;
}
export function AccountSettings() {
const t = useTranslations('settings.account');
const router = useRouter();
const { username, serverUrl, isDemoMode, primaryIdentity, authMode } = useAuthStore();
const { username, serverUrl, isDemoMode, primaryIdentity, authMode, client } = useAuthStore();
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount);
const setManagedAccount = useManagedAccountStore((s) => s.setManagedAccount);
// Shared/group accounts delegated to this session (excludes the user's own
// primary account). These can be drilled into for scoped settings editing.
const sharedAccounts = useMemo<SharedAccount[]>(
() => (client?.getSharedAccounts() ?? []).filter((a) => !a.isPrimary),
[client],
);
const { quota } = useEmailStore();
const accounts = useAccountStore((s) => s.accounts);
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
@@ -82,6 +102,16 @@ export function AccountSettings() {
router.push(`/login?mode=add-account` as never);
}, [router]);
// Enter scoped settings mode for a shared/group account: set the managed
// account, then steer the settings panel to its first editable tab via the
// existing 'settings-tab-change' event the page already listens for.
const handleManageShared = useCallback((account: SharedAccount) => {
const tab = firstScopedTab(account.capabilities);
if (!tab) return;
setManagedAccount(account);
window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: tab }));
}, [setManagedAccount]);
return (
<div className="space-y-8">
<SettingsSection title={t('title')} description={t('description')}>
@@ -197,6 +227,44 @@ export function AccountSettings() {
</div>
</SettingsSection>
)}
{/* Shared / group accounts delegated to this session. Clicking one drills
into a scoped settings view (filters, vacation, calendars, contacts). */}
{sharedAccounts.length > 0 && (
<SettingsSection title={t('shared_accounts.title')} description={t('shared_accounts.description')}>
<div className="space-y-2">
{sharedAccounts.map((acc) => {
const editable = firstScopedTab(acc.capabilities) !== null;
return (
<button
key={acc.id}
type="button"
onClick={() => handleManageShared(acc)}
disabled={!editable}
className={cn(
'flex items-center gap-3 w-full p-3 border border-border rounded-lg text-left transition-colors',
editable ? 'hover:bg-muted/50 cursor-pointer' : 'opacity-60 cursor-not-allowed',
)}
>
<Avatar
name={acc.name}
size="sm"
className="w-9 h-9 text-sm flex-shrink-0"
disableFavicon
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{acc.name}</p>
<p className="text-xs text-muted-foreground truncate">
{t('shared_accounts.shared_label')}
</p>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
</button>
);
})}
</div>
</SettingsSection>
)}
</div>
);
}
@@ -2,14 +2,16 @@
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Book, Pencil, Share2, Tag, Users } from "lucide-react";
import { Book, BookPlus, Pencil, Share2, Tag, Users } from "lucide-react";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { useManagedAccountStore } from "@/stores/managed-account-store";
import { toast } from "@/stores/toast-store";
import { SettingsSection } from "./settings-section";
import { cn } from "@/lib/utils";
import type { AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "./share-collection-dialog";
import { RenameDialog } from "@/components/files/rename-dialog";
function AddressBookEditRow({
initial,
@@ -71,10 +73,12 @@ export function AddressBookManagementSettings() {
const tContacts = useTranslations("contacts");
const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore();
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const { addressBooks, contacts, supportsSync, fetchAddressBooks, createAddressBook, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
const [editingId, setEditingId] = useState<string | null>(null);
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
const [sharingId, setSharingId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
@@ -83,6 +87,21 @@ export function AddressBookManagementSettings() {
}
}, [client, addressBooks.length, fetchAddressBooks]);
const handleCreate = async (name: string) => {
if (!client) return;
setIsLoading(true);
try {
await createAddressBook(client, name);
await fetchAddressBooks(client);
setCreating(false);
toast.success(t("created"));
} catch {
toast.error(t("create_failed"));
} finally {
setIsLoading(false);
}
};
const handleUpdate = async (book: AddressBook, newName: string) => {
if (!client) return;
setIsLoading(true);
@@ -205,9 +224,13 @@ export function AddressBookManagementSettings() {
<>
<SettingsSection title={tSettings("manage_title")} description={tSettings("manage_description")}>
<div className="space-y-2">
{personal.map(renderBook)}
{/* In scoped mode (managing a shared account) hide the user's own
address books and show only the managed account's group. */}
{(managedAccountId ? [] : personal).map(renderBook)}
{Array.from(sharedGroups.entries()).map(([accountId, group]) => (
{Array.from(sharedGroups.entries())
.filter(([accountId]) => !managedAccountId || accountId === managedAccountId)
.map(([accountId, group]) => (
<div key={accountId} className="mt-4 space-y-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
@@ -220,9 +243,25 @@ export function AddressBookManagementSettings() {
{addressBooks.length === 0 && (
<p className="text-sm text-muted-foreground py-2">{tSettings("no_address_books")}</p>
)}
{/* Creating targets the user's own account, so hide it while scoped to a
managed (shared) account. */}
{!managedAccountId && client && (
<button
type="button"
onClick={() => setCreating(true)}
className="flex items-center gap-2 py-2.5 px-3 w-full rounded-md border border-dashed border-border text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<BookPlus className="w-4 h-4 flex-shrink-0" />
{t("create")}
</button>
)}
</div>
</SettingsSection>
{/* Contact categories come from the active account's contacts, not the
shared account, so hide them while scoped to a shared account. */}
{!managedAccountId && (
<div className="mt-8">
<SettingsSection title={tSettings("categories_title")} description={tSettings("categories_description")}>
<div className="space-y-2">
@@ -267,6 +306,17 @@ export function AddressBookManagementSettings() {
</div>
</SettingsSection>
</div>
)}
{creating && (
<RenameDialog
currentName=""
title={t("create")}
label={t("name_label")}
onCancel={() => setCreating(false)}
onConfirm={handleCreate}
/>
)}
{sharingId && client && (() => {
const book = addressBooks.find((b) => b.id === sharingId);
@@ -14,6 +14,7 @@ import { cn, formatDateTime, redactUrlCredentials } from '@/lib/utils';
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
import { useSettingsStore } from '@/stores/settings-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import { apiFetch } from '@/lib/browser-navigation';
import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors';
@@ -137,6 +138,7 @@ export { CalendarColorPicker, CALENDAR_COLORS };
export function CalendarManagementSettings() {
const t = useTranslations('calendar.management');
const { client, serverUrl, username } = useAuthStore();
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions: allSubs, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
// Subscriptions are persisted globally but scoped per JMAP account via
// accountId. Legacy entries with no accountId show in the active account.
@@ -388,7 +390,10 @@ export function CalendarManagementSettings() {
return (
<SettingsSection title={t('title')} description={t('description')}>
<div className="space-y-2">
{calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => {
{calendars
.filter(cal => !isSubscriptionCalendar(cal.id))
.filter(cal => !managedAccountId || cal.accountId === managedAccountId)
.map((cal) => {
const color = (cal.isShared && sharedCalendarColors[sharedCalendarColorKey(cal)]) || cal.color || '#3b82f6';
if (editingId === cal.id) {
@@ -22,6 +22,7 @@ export function ComposingSettings() {
const {
autoSelectReplyIdentity,
plainTextMode,
attachmentReminderEnabled,
attachmentReminderKeywords,
sendDelaySeconds,
@@ -44,6 +45,13 @@ export function ComposingSettings() {
/>
</SettingItem>
<SettingItem label={t('plain_text_mode.label')} description={t('plain_text_mode.description')}>
<ToggleSwitch
checked={plainTextMode}
onChange={(checked) => updateSetting('plainTextMode', checked)}
/>
</SettingItem>
<SettingItem label={t('send_delay.label')} description={t('send_delay.description')}>
<div className="flex flex-col items-end gap-1">
<Select
+61 -5
View File
@@ -12,7 +12,9 @@ import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import type { FilterRule } from "@/lib/jmap/sieve-types";
import type { Mailbox } from "@/lib/jmap/types";
import { useVacationStore } from "@/stores/vacation-store";
import { useManagedAccountStore } from "@/stores/managed-account-store";
import {
Plus,
GripVertical,
@@ -157,7 +159,8 @@ export function FilterSettings() {
const t = useTranslations("settings.filters");
const tNotifications = useTranslations("notifications");
const { client } = useAuthStore();
const mailboxes = useEmailStore((s) => s.mailboxes);
const storeMailboxes = useEmailStore((s) => s.mailboxes);
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
const expandedFilterView = useSettingsStore((s) => s.expandedFilterView);
const updateSetting = useSettingsStore((s) => s.updateSetting);
@@ -170,7 +173,7 @@ export function FilterSettings() {
isOpaque,
rawScript,
vacationSettings,
fetchFilters,
selectAccount,
saveFilters,
addRule,
updateRule,
@@ -182,7 +185,57 @@ export function FilterSettings() {
validateScript,
} = useFilterStore();
const vacationEnabled = useVacationStore((s) => s.isEnabled) || vacationSettings?.isEnabled;
// Scoped to a shared/group account when the settings panel is managing one.
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const isPrimaryAccount = !managedAccountId;
// Folders offered as "move to" targets in the rule editor must belong to the
// account whose filters we're editing. For a shared account, fetch that
// account's mailboxes (the email store only holds the active account's). They
// are this account's own folders within its Sieve context, so present them as
// non-shared — otherwise the rule modal filters them out (it drops isShared
// mailboxes, which are normally other accounts' folders merged into the view).
const [scopedMailboxes, setScopedMailboxes] = useState<Mailbox[]>([]);
useEffect(() => {
if (!client || !managedAccountId) {
setScopedMailboxes([]);
return;
}
let cancelled = false;
void client
.getMailboxes(managedAccountId)
.then((mbs) => {
if (!cancelled) setScopedMailboxes(mbs.map((mb) => ({ ...mb, isShared: false })));
})
.catch(() => {
if (!cancelled) setScopedMailboxes([]);
});
return () => { cancelled = true; };
}, [client, managedAccountId]);
// The "move to" folder list for the primary account comes from the email
// store, which is normally populated when the mail view mounts. When the app
// is opened or refreshed directly on Settings (the mail view never mounted),
// that store is empty, leaving the rule editor's folder dropdown blank. Fetch
// mailboxes on demand here so Filters never depends on having visited Inbox
// first. fetchMailboxes guards against transient empty results and selecting
// an inbox, so it's safe to call independently; a ref keeps it to one attempt
// per client.
const primaryFetchClientRef = useRef<typeof client | null>(null);
useEffect(() => {
if (managedAccountId || !client || storeMailboxes.length > 0) return;
if (primaryFetchClientRef.current === client) return;
primaryFetchClientRef.current = client;
void fetchMailboxes(client);
}, [client, managedAccountId, storeMailboxes.length, fetchMailboxes]);
const mailboxes = managedAccountId ? scopedMailboxes : storeMailboxes;
const vacationStoreEnabled = useVacationStore((s) => s.isEnabled);
// Vacation uses a separate per-(primary)-account mechanism (RFC 9661), so the
// "vacation active" banner only applies when editing the personal account.
const vacationEnabled =
isPrimaryAccount && (vacationStoreEnabled || vacationSettings?.isEnabled);
const [editingRule, setEditingRule] = useState<FilterRule | undefined>();
const [showRuleModal, setShowRuleModal] = useState(false);
@@ -194,9 +247,12 @@ export function FilterSettings() {
useEffect(() => {
if (client && isSupported) {
void fetchFilters(client);
// Always pass a concrete account id (managed shared account, or the
// primary Sieve account) so switching never falls back to a stale
// previously-selected account.
void selectAccount(client, managedAccountId ?? client.getSieveAccountId());
}
}, [client, isSupported, fetchFilters]);
}, [client, isSupported, managedAccountId, selectAccount]);
const handleToggle = useCallback(
async (ruleId: string) => {
+111 -2
View File
@@ -2,6 +2,7 @@
import { useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Check, Folder } from 'lucide-react';
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils';
@@ -117,11 +118,46 @@ function MailLayoutPreview({
export function LayoutSettings() {
const t = useTranslations('settings.appearance');
const tEmail = useTranslations('settings.email_behavior');
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
const activeAccountId = useAccountStore(s => s.activeAccountId);
const mailboxes = useEmailStore(s => s.mailboxes);
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
const allMailViewAllowed = isFeatureEnabled('allMailViewEnabled');
// Cross-account "All accounts" views, each gated independently by the admin.
const crossViews = [
{ setting: 'enableCrossUnreadView', value: enableCrossUnreadView, allowed: isFeatureEnabled('crossUnreadViewEnabled'), labelKey: 'cross_unread.label', descKey: 'cross_unread.description' },
{ setting: 'enableCrossStarredView', value: enableCrossStarredView, allowed: isFeatureEnabled('crossStarredViewEnabled'), labelKey: 'cross_starred.label', descKey: 'cross_starred.description' },
{ setting: 'enableCrossAllView', value: enableCrossAllView, allowed: isFeatureEnabled('crossAllViewEnabled'), labelKey: 'cross_all.label', descKey: 'cross_all.description' },
] as const;
// Own (non-shared) folders and the active account's All Mail selection. The
// selection is per account: a missing entry = never configured, which
// defaults to all no-role folders; an explicit [] = no folders.
const ownMailboxes = useMemo(() => mailboxes.filter(m => !m.isShared), [mailboxes]);
const currentAllMailEntry = activeAccountId ? allMailFolderIds[activeAccountId] : undefined;
const allMailSelected = new Set(
currentAllMailEntry === undefined
? ownMailboxes.filter(m => !m.role).map(m => m.id)
: currentAllMailEntry
);
const toggleAllMailFolder = (id: string) => {
if (!activeAccountId) return;
const next = new Set(allMailSelected);
if (next.has(id)) next.delete(id);
else next.add(id);
updateSetting('allMailFolderIds', {
...allMailFolderIds,
[activeAccountId]: ownMailboxes.filter(m => next.has(m.id)).map(m => m.id),
});
};
// Name the account the selection applies to, but only when more than one is
// logged in (otherwise it's unambiguous).
const activeAccount = accounts.find(a => a.id === activeAccountId);
const allMailAccountHint = accounts.length > 1 && activeAccount
? t('all_mail.account_hint', { account: activeAccount.displayName || activeAccount.email })
: null;
return (
<SettingsSection title={t('title')} description={t('description')}>
@@ -209,6 +245,79 @@ export function LayoutSettings() {
</div>
)}
{enableUnifiedMailbox && crossViews.some(c => c.allowed) && (
<div className="ml-4 border-l-2 border-border pl-4 -mt-2 space-y-2">
{crossViews.map(({ setting, value, allowed, labelKey, descKey }) => (
allowed && !isSettingHidden(setting) && (
<SettingItem
key={setting}
label={t(labelKey)}
description={t(descKey)}
locked={isSettingLocked(setting)}
>
<ToggleSwitch
checked={value}
onChange={(v) => updateSetting(setting, v)}
/>
</SettingItem>
)
))}
</div>
)}
{allMailViewAllowed && !isSettingHidden('enableAllMailView') && (
<SettingItem
label={t('all_mail.label')}
description={t('all_mail.description')}
locked={isSettingLocked('enableAllMailView')}
>
<ToggleSwitch
checked={enableAllMailView}
onChange={(v) => updateSetting('enableAllMailView', v)}
/>
</SettingItem>
)}
{allMailViewAllowed && enableAllMailView && (
<div className="ml-4 border-l-2 border-border pl-4 -mt-2 space-y-2">
<div>
<div className="text-sm font-medium text-foreground">{t('all_mail.folders_label')}</div>
<div className="text-xs text-muted-foreground">{t('all_mail.folders_description')}</div>
{allMailAccountHint && (
<div className="text-xs italic text-muted-foreground mt-0.5">{allMailAccountHint}</div>
)}
</div>
{ownMailboxes.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('all_mail.no_folders')}</p>
) : (
<div className="space-y-0.5">
{ownMailboxes.map((mb) => {
const checked = allMailSelected.has(mb.id);
return (
<button
key={mb.id}
type="button"
onClick={() => toggleAllMailFolder(mb.id)}
className="w-full flex items-center gap-2.5 py-1.5 px-2 rounded-md hover:bg-muted/50 text-left"
role="checkbox"
aria-checked={checked}
>
<span className={cn(
"flex items-center justify-center w-4 h-4 rounded border flex-shrink-0 transition-colors",
checked ? "bg-primary border-primary text-primary-foreground" : "border-border"
)}>
{checked && <Check className="w-3 h-3" />}
</span>
<Folder className={cn("w-4 h-4 flex-shrink-0", mb.role ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm text-foreground truncate">{mb.name}</span>
</button>
);
})}
</div>
)}
</div>
)}
<SettingItem label={t('pro_interface.label')} description={t('pro_interface.description')}>
<ToggleSwitch
checked={proInterface}
+8 -8
View File
@@ -22,10 +22,10 @@ export function ReadingSettings() {
markAsReadDelay,
deleteAction,
permanentlyDeleteJunk,
returnToListAfterAction,
showPreview,
mailLayout,
disableThreading,
plainTextMode,
emailsPerPage,
mailAttachmentAction,
attachmentPosition,
@@ -177,6 +177,13 @@ export function ReadingSettings() {
/>
</SettingItem>
<SettingItem label={t('return_to_list_after_action.label')} description={t('return_to_list_after_action.description')}>
<ToggleSwitch
checked={returnToListAfterAction}
onChange={(checked) => updateSetting('returnToListAfterAction', checked)}
/>
</SettingItem>
{!isSettingHidden('showPreview') && (
<SettingItem
label={t('show_preview.label')}
@@ -194,13 +201,6 @@ export function ReadingSettings() {
/>
</SettingItem>
<SettingItem label={t('plain_text_mode.label')} description={t('plain_text_mode.description')}>
<ToggleSwitch
checked={plainTextMode}
onChange={(checked) => updateSetting('plainTextMode', checked)}
/>
</SettingItem>
<SettingItem label={t('hide_inline_image_attachments.label')} description={t('hide_inline_image_attachments.description')}>
<ToggleSwitch
checked={hideInlineImageAttachments}
+45 -13
View File
@@ -6,11 +6,11 @@ import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "lucide-react";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { Principal, CalendarRights, AddressBookRights } from "@/lib/jmap/types";
import type { Principal, CalendarRights, AddressBookRights, FileNodeRights } from "@/lib/jmap/types";
import { toast } from "@/stores/toast-store";
type ShareKind = "calendar" | "addressBook";
type AnyRights = CalendarRights | AddressBookRights;
type ShareKind = "calendar" | "addressBook" | "file";
type AnyRights = CalendarRights | AddressBookRights | FileNodeRights;
type RolePreset = "freeBusy" | "read" | "readWrite" | "manager" | "custom";
@@ -39,6 +39,21 @@ const ADDRESS_BOOK_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, A
manager: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true },
};
const FILE_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, FileNodeRights> = {
read: {
mayRead: true, mayAddChildren: false, mayRename: false,
mayDelete: false, mayModifyContent: false, mayShare: false,
},
readWrite: {
mayRead: true, mayAddChildren: true, mayRename: true,
mayDelete: true, mayModifyContent: true, mayShare: false,
},
manager: {
mayRead: true, mayAddChildren: true, mayRename: true,
mayDelete: true, mayModifyContent: true, mayShare: true,
},
};
function detectCalendarPreset(r: CalendarRights): RolePreset {
for (const [name, preset] of Object.entries(CALENDAR_PRESETS) as [Exclude<RolePreset, "custom">, CalendarRights][]) {
if ((Object.keys(preset) as (keyof CalendarRights)[]).every((k) => preset[k] === r[k])) {
@@ -58,6 +73,29 @@ function detectAddressBookPreset(r: AddressBookRights): RolePreset {
return "custom";
}
function detectFilePreset(r: FileNodeRights): RolePreset {
for (const [name, preset] of Object.entries(FILE_PRESETS) as [Exclude<RolePreset, "custom" | "freeBusy">, FileNodeRights][]) {
const keys = Object.keys(preset) as (keyof FileNodeRights)[];
if (keys.every((k) => preset[k] === (r[k] ?? false))) {
return name;
}
}
return "custom";
}
function presetRights(kind: ShareKind, preset: RolePreset): AnyRights | undefined {
if (preset === "custom") return undefined;
if (kind === "calendar") return CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS];
if (kind === "file") return FILE_PRESETS[preset as keyof typeof FILE_PRESETS];
return ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS];
}
function detectPreset(kind: ShareKind, rights: AnyRights): RolePreset {
if (kind === "calendar") return detectCalendarPreset(rights as CalendarRights);
if (kind === "file") return detectFilePreset(rights as FileNodeRights);
return detectAddressBookPreset(rights as AddressBookRights);
}
interface ShareCollectionDialogProps {
client: IJMAPClient;
kind: ShareKind;
@@ -126,9 +164,7 @@ export function ShareCollectionDialog({
const handleSetRights = async (principalId: string, preset: RolePreset) => {
if (preset === "custom") return; // custom is read-only here
const rights = kind === "calendar"
? CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS]
: ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS];
const rights = presetRights(kind, preset);
if (!rights) return;
setSavingId(principalId);
try {
@@ -154,10 +190,8 @@ export function ShareCollectionDialog({
};
const handleAdd = async (principal: Principal) => {
const defaultPreset: RolePreset = "read";
const rights = kind === "calendar"
? CALENDAR_PRESETS[defaultPreset]
: ADDRESS_BOOK_PRESETS[defaultPreset];
const rights = presetRights(kind, "read");
if (!rights) return;
setSavingId(principal.id);
try {
await onShare(principal.id, rights);
@@ -226,9 +260,7 @@ export function ShareCollectionDialog({
<ul className="divide-y divide-border rounded-md border border-border overflow-hidden">
{sharedEntries.map(([principalId, rights]) => {
const principal = allPrincipalsById.get(principalId);
const preset = kind === "calendar"
? detectCalendarPreset(rights as CalendarRights)
: detectAddressBookPreset(rights as AddressBookRights);
const preset = detectPreset(kind, rights);
return (
<li key={principalId} className="flex items-center gap-3 px-3 py-2.5">
<Avatar
+5 -3
View File
@@ -6,6 +6,7 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { useVacationStore } from '@/stores/vacation-store';
import { useAuthStore } from '@/stores/auth-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react';
import { toast } from '@/stores/toast-store';
@@ -19,6 +20,7 @@ export function VacationSettings() {
const t = useTranslations('settings.vacation');
const tNotifications = useTranslations('notifications');
const { client } = useAuthStore();
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const {
isEnabled,
fromDate,
@@ -43,9 +45,9 @@ export function VacationSettings() {
useEffect(() => {
if (client && isSupported) {
void fetchVacationResponse(client);
void fetchVacationResponse(client, managedAccountId ?? undefined);
}
}, [client, isSupported, fetchVacationResponse]);
}, [client, isSupported, managedAccountId, fetchVacationResponse]);
useEffect(() => {
setLocalEnabled(isEnabled);
@@ -101,7 +103,7 @@ export function VacationSettings() {
toDate: localToDate || null,
subject: localSubject,
textBody: localTextBody,
});
}, managedAccountId ?? undefined);
toast.success(tNotifications('vacation_saved'));
} catch (error) {
+3 -1
View File
@@ -6,6 +6,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { useWebDAVStore } from "@/stores/webdav-store";
import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { getTourSteps, type TourStep } from "./tour-steps";
import { TourOverlay } from "./tour-overlay";
@@ -38,6 +39,7 @@ export function TourProvider({ children }: { children: ReactNode }) {
const pathname = usePathname();
const { isDemoMode } = useAuthStore();
const { supportsCalendar } = useCalendarStore();
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
const { supportsWebDAV } = useWebDAVStore();
const tourCompleted = useSettingsStore((s) => s.tourCompleted);
const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices);
@@ -47,7 +49,7 @@ export function TourProvider({ children }: { children: ReactNode }) {
const [currentStep, setCurrentStep] = useState(0);
const [hasCompletedTour, setHasCompletedTour] = useState(false);
const steps = getTourSteps({ isDemoMode, supportsCalendar, supportsWebDAV: supportsWebDAV !== false });
const steps = getTourSteps({ isDemoMode, supportsCalendar: supportsCalendar && calendarEnabled, supportsWebDAV: supportsWebDAV !== false });
useEffect(() => {
// One-time migration: if the legacy per-device flag is set but the synced
+4 -1
View File
@@ -32,6 +32,9 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
// Measure the rendered menu and clamp it inside the viewport before the
// browser paints. We hide the element until this runs so the user never
// sees the menu jump from an unclamped position to a clamped one.
// `mounted` must be a dependency: on the very first open the menu isn't
// in the DOM yet (mounted is still false), so this effect has to re-run
// after the mount flip or the menu stays visibility:hidden.
useLayoutEffect(() => {
if (!isOpen) {
setAdjustedPosition(null);
@@ -57,7 +60,7 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
y = Math.max(VIEWPORT_MARGIN, y);
setAdjustedPosition({ x, y });
}, [isOpen, position.x, position.y]);
}, [isOpen, mounted, position.x, position.y]);
const setRefs = (node: HTMLDivElement | null) => {
localRef.current = node;
+25
View File
@@ -222,6 +222,29 @@ export function FlagDK(props: FlagProps) {
);
}
/** Romania Vertical blue, yellow, red tricolour */
export function FlagRO(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
<rect width="1" height="2" fill="#002B7F" />
<rect x="1" width="1" height="2" fill="#FCD116" />
<rect x="2" width="1" height="2" fill="#CE1126" />
</svg>
);
}
/** Iran Green, White, Red horizontal with emblem (simplified) */
export function FlagIR(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 3" width={W} height={H} className={flagClass} {...props}>
<rect width="4" height="1" fill="#239F40" />
<rect y="1" width="4" height="1" fill="#fff" />
<rect y="2" width="4" height="1" fill="#DA0000" />
<circle cx="2" cy="1.5" r="0.3" fill="#DA0000" />
</svg>
);
}
/** Map locale codes to flag components */
export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = {
cs: FlagCS,
@@ -238,8 +261,10 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
nl: FlagNL,
pl: FlagPL,
pt: FlagBR,
ro: FlagRO,
ru: FlagRU,
tr: FlagTR,
uk: FlagUA,
zh: FlagCN,
fa: FlagIR,
};
+2
View File
@@ -12,6 +12,7 @@ const languages = [
{ value: 'da', label: 'Dansk' },
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
{ value: 'fa', label: 'فارسی' },
{ value: 'es', label: 'Español' },
{ value: 'fr', label: 'Français' },
{ value: 'it', label: 'Italiano' },
@@ -20,6 +21,7 @@ const languages = [
{ value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' },
{ value: 'ro', label: 'Română' },
{ value: 'tr', label: 'Türkçe' },
{ value: 'ru', label: 'Русский' },
{ value: 'uk', label: 'Українська' },
+1 -1
View File
@@ -222,7 +222,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
} finally {
endDrag();
}
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, crossAccountMoveEmails, draggedEmails, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]);
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, crossAccountMoveEmails, draggedEmails, sourceMailboxId, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]);
const valid = isValidTarget();
+9
View File
@@ -23,9 +23,15 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'es':
messages = (await import('../locales/es/common.json')).default;
break;
case 'fa':
messages = (await import('../locales/fa/common.json')).default;
break;
case 'fr':
messages = (await import('../locales/fr/common.json')).default;
break;
case 'hu':
messages = (await import('../locales/hu/common.json')).default;
break;
case 'it':
messages = (await import('../locales/it/common.json')).default;
break;
@@ -47,6 +53,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'pt':
messages = (await import('../locales/pt/common.json')).default;
break;
case 'ro':
messages = (await import('../locales/ro/common.json')).default;
break;
case 'ru':
messages = (await import('../locales/ru/common.json')).default;
break;
+1 -1
View File
@@ -12,7 +12,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'always'
| 'as-needed';
const SUPPORTED_LOCALES = ['cs', 'da', 'de', 'en', 'es', 'fr', 'hu', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'] as const;
const SUPPORTED_LOCALES = ['cs', 'da', 'de', 'en', 'es', 'fa', 'fr', 'hu', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ro', 'ru', 'tr', 'uk', 'zh'] as const;
// Fallback locale used when the visitor's Accept-Language header does not
// match any supported locale (and no NEXT_LOCALE cookie is set yet). Admins
+128
View File
@@ -0,0 +1,128 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
snapshotAccount,
restoreAccount,
clearAllStores,
evictAccount,
evictAll,
} from '@/lib/account-state-manager';
import { useEmailStore } from '@/stores/email-store';
import { useContactStore } from '@/stores/contact-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useFilterStore } from '@/stores/filter-store';
import { useIdentityStore } from '@/stores/identity-store';
import { useVacationStore } from '@/stores/vacation-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
import { makeEmail, makeMailbox } from './helpers/factories';
// The store singletons and the module-level snapshot cache persist across
// tests; reset both around every test.
beforeEach(() => evictAll());
afterEach(() => evictAll());
describe('snapshotAccount / restoreAccount', () => {
it('round-trips the captured fields across all six stores', () => {
useEmailStore.setState({
emails: [makeEmail({ id: 'a1' })],
mailboxes: [makeMailbox({ id: 'a-in' })],
selectedMailbox: 'a-in',
searchQuery: 'queryA',
quota: { used: 1, total: 2 },
});
useContactStore.setState({ supportsSync: true });
useCalendarStore.setState({ viewMode: 'week', supportsCalendar: true });
useFilterStore.setState({ isSupported: true });
useIdentityStore.setState({ preferredPrimaryId: 'idA' });
useVacationStore.setState({ isEnabled: true });
snapshotAccount('A');
// Mutate everything to "account B" values.
useEmailStore.setState({ emails: [], selectedMailbox: 'b-in', searchQuery: 'queryB', quota: null });
useContactStore.setState({ supportsSync: false });
useCalendarStore.setState({ viewMode: 'month', supportsCalendar: false });
useFilterStore.setState({ isSupported: false });
useIdentityStore.setState({ preferredPrimaryId: 'idB' });
useVacationStore.setState({ isEnabled: false });
expect(restoreAccount('A')).toBe(true);
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['a1']);
expect(useEmailStore.getState().selectedMailbox).toBe('a-in');
expect(useEmailStore.getState().searchQuery).toBe('queryA');
expect(useEmailStore.getState().quota).toEqual({ used: 1, total: 2 });
expect(useContactStore.getState().supportsSync).toBe(true);
expect(useCalendarStore.getState().viewMode).toBe('week');
expect(useFilterStore.getState().isSupported).toBe(true);
expect(useIdentityStore.getState().preferredPrimaryId).toBe('idA');
expect(useVacationStore.getState().isEnabled).toBe(true);
});
it('resets fields outside the snapshot subset to their defaults (no cross-account leak)', () => {
// isLoading is NOT part of the email snapshot subset.
useEmailStore.setState({ selectedMailbox: 'a-in', isLoading: false });
snapshotAccount('A');
useEmailStore.setState({ selectedMailbox: 'b-in', isLoading: true });
restoreAccount('A');
expect(useEmailStore.getState().selectedMailbox).toBe('a-in'); // captured → restored
expect(useEmailStore.getState().isLoading).toBe(false); // uncaptured → reset, not leaked
});
it('decouples the snapshot from later in-place mutation of the source array', () => {
const arr = [makeEmail({ id: '1' })];
useEmailStore.setState({ emails: arr });
snapshotAccount('A');
arr.push(makeEmail({ id: '2' })); // mutate the same array after snapshot
useEmailStore.setState({ emails: [] });
restoreAccount('A');
// The post-snapshot mutation did NOT leak into the snapshot.
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['1']);
});
it('returns false and leaves stores untouched for an unknown account', () => {
useEmailStore.setState({ searchQuery: 'keep' });
expect(restoreAccount('nope')).toBe(false);
expect(useEmailStore.getState().searchQuery).toBe('keep');
});
});
describe('clearAllStores', () => {
it('resets the email store to fresh empty collections', () => {
useEmailStore.setState({
emails: [makeEmail({ id: 'x' })],
selectedEmailIds: new Set(['x']),
searchQuery: 'q',
tagCounts: { a: { total: 1, unread: 0 } },
threadEmailsCache: new Map([['t', []]]),
});
clearAllStores();
const s = useEmailStore.getState();
expect(s.emails).toEqual([]);
expect(s.searchQuery).toBe('');
expect(s.selectedEmailIds.size).toBe(0);
expect(s.threadEmailsCache.size).toBe(0);
expect(s.tagCounts).toEqual({});
expect(s.searchFilters).toEqual(DEFAULT_SEARCH_FILTERS);
});
});
describe('evictAccount / evictAll', () => {
it('evictAccount drops a single snapshot', () => {
snapshotAccount('A');
evictAccount('A');
expect(restoreAccount('A')).toBe(false);
});
it('evictAll drops every snapshot', () => {
snapshotAccount('B');
snapshotAccount('C');
evictAll();
expect(restoreAccount('B')).toBe(false);
expect(restoreAccount('C')).toBe(false);
});
});
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
encryptSession,
decryptSession,
encryptPayload,
decryptPayload,
} from '@/lib/auth/crypto';
// crypto.ts derives its key solely from getSessionSecret(); mock that one seam
// so we control the secret without touching configManager / env-file lookups.
const { secretRef } = vi.hoisted(() => ({ secretRef: { value: 'x'.repeat(32) } }));
vi.mock('@/lib/auth/session-secret', () => ({
getSessionSecret: () => secretRef.value,
hasSessionSecret: () => secretRef.value.length > 0,
}));
vi.mock('@/lib/logger', () => ({
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
}));
const SECRET = 'x'.repeat(32);
beforeEach(() => {
secretRef.value = SECRET;
});
describe('encryptSession / decryptSession', () => {
it('round-trips a session', () => {
const token = encryptSession('https://mail.example.com', 'alice', 's3cret');
expect(decryptSession(token)).toEqual({
serverUrl: 'https://mail.example.com',
username: 'alice',
password: 's3cret',
});
});
it('produces a base64 token with a random IV (two encrypts differ, both decrypt equal)', () => {
const a = encryptSession('https://x', 'u', 'p');
const b = encryptSession('https://x', 'u', 'p');
expect(a).not.toBe(b);
expect(Buffer.from(a, 'base64').toString('base64')).toBe(a); // valid base64
expect(decryptSession(a)).toEqual(decryptSession(b));
});
it('returns null (not throw) on a tampered auth tag', () => {
const token = encryptSession('https://x', 'u', 'p');
const buf = Buffer.from(token, 'base64');
buf[13] ^= 0xff; // flip a byte inside the GCM tag region (bytes 12..28)
expect(decryptSession(buf.toString('base64'))).toBeNull();
});
it('returns null on a token shorter than IV+TAG', () => {
expect(decryptSession(Buffer.alloc(10).toString('base64'))).toBeNull();
});
it('returns null when the version is not 1', () => {
const token = encryptPayload({ v: 2, serverUrl: 'https://x', username: 'u', password: 'p' });
expect(decryptSession(token)).toBeNull();
});
it('returns null when a required field is missing', () => {
const token = encryptPayload({ v: 1, serverUrl: 'https://x', username: 'u' });
expect(decryptSession(token)).toBeNull();
});
it('throws when no secret is configured', () => {
secretRef.value = '';
expect(() => encryptSession('https://x', 'u', 'p')).toThrow('SESSION_SECRET not configured');
});
it('throws when the secret is shorter than 32 characters', () => {
secretRef.value = 'tooshort';
expect(() => encryptSession('https://x', 'u', 'p')).toThrow(/at least 32 characters/);
});
});
describe('encryptPayload / decryptPayload', () => {
it('round-trips an arbitrary object', () => {
const token = encryptPayload({ a: 1, b: 'two', c: { nested: true } });
expect(decryptPayload(token)).toEqual({ a: 1, b: 'two', c: { nested: true } });
});
it('does NOT enforce the version/field guard that decryptSession applies', () => {
// CHARACTERISATION: decryptPayload returns whatever JSON parsed, with no
// v===1 / required-field validation (unlike decryptSession).
const token = encryptPayload({ v: 2, anything: 'goes' });
expect(decryptPayload(token)).toEqual({ v: 2, anything: 'goes' });
});
it('returns null on a tampered token', () => {
const token = encryptPayload({ a: 1 });
const buf = Buffer.from(token, 'base64');
buf[20] ^= 0xff;
expect(decryptPayload(buf.toString('base64'))).toBeNull();
});
});
+30
View File
@@ -72,3 +72,33 @@ describe('withBasePath — asset-URL fallbacks under a subpath', () => {
expect(getPathPrefix()).toBe('/webmail');
});
});
describe('toRouterPath — router.push paths under a subpath', () => {
// With a build-time basePath, Next's router prepends the prefix itself, so
// browser-derived paths (redirect_after_login stores
// window.location.pathname) must be stripped or the redirect lands on
// /webmail/webmail/en. See #390.
it('strips the static base path from a stored browser path', async () => {
const { toRouterPath } = await loadNav('/webmail');
expect(toRouterPath('/webmail/en')).toBe('/en');
expect(toRouterPath('/webmail/en/calendar?view=day')).toBe('/en/calendar?view=day');
expect(toRouterPath('/webmail')).toBe('/');
expect(toRouterPath('/webmail?compose=1')).toBe('/?compose=1');
});
it('leaves already-stripped and unrelated paths alone', async () => {
const { toRouterPath } = await loadNav('/webmail');
expect(toRouterPath('/en')).toBe('/en');
expect(toRouterPath('/')).toBe('/');
// Shares the prefix text but is a different first segment.
expect(toRouterPath('/webmail2/en')).toBe('/webmail2/en');
});
it('passes paths through unchanged when no base path is built in', async () => {
// Legacy runtime-detected proxy mounts: Next knows nothing about the
// prefix, so router.push needs the full prefixed path.
const { toRouterPath } = await loadNav(undefined);
expect(toRouterPath('/webmail/en')).toBe('/webmail/en');
expect(toRouterPath('/en')).toBe('/en');
});
});
+90
View File
@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import type { ContactCard } from '@/lib/jmap/types';
import {
createBirthdayCalendar,
generateBirthdayEvents,
BIRTHDAY_CALENDAR_ID,
BIRTHDAY_CALENDAR_COLOR,
} from '@/lib/birthday-calendar';
const contact = (over: Record<string, unknown> = {}): ContactCard =>
({
id: 'c1',
'@type': 'Card',
name: { full: 'Alice Smith' },
anniversaries: { b1: { '@type': 'Anniversary', kind: 'birth', date: '1990-05-15' } },
...over,
} as unknown as ContactCard);
describe('createBirthdayCalendar', () => {
it('returns the virtual calendar with defaults', () => {
const cal = createBirthdayCalendar();
expect(cal).toMatchObject({
id: BIRTHDAY_CALENDAR_ID,
name: 'Birthdays',
color: BIRTHDAY_CALENDAR_COLOR,
isSubscribed: true,
myRights: { mayReadItems: true, mayWriteAll: false, mayDelete: false },
});
});
it('honours name/color overrides', () => {
expect(createBirthdayCalendar('My BDays', '#fff')).toMatchObject({ name: 'My BDays', color: '#fff' });
});
});
describe('generateBirthdayEvents', () => {
it('emits one event per year in range, with age and stable ids', () => {
const events = generateBirthdayEvents([contact()], '2020-01-01', '2022-12-31');
expect(events.map((e) => e.id)).toEqual([
'birthday-c1-b1-2020',
'birthday-c1-b1-2021',
'birthday-c1-b1-2022',
]);
expect(events[0]).toMatchObject({
uid: 'birthday-c1-b1',
title: '🎂 Alice Smith (30)',
start: '2020-05-15T00:00:00',
calendarIds: { [BIRTHDAY_CALENDAR_ID]: true },
showWithoutTime: true,
});
});
it('omits the age when the birthday has no year (partial date)', () => {
const c = contact({ anniversaries: { b1: { '@type': 'Anniversary', kind: 'birth', date: '--05-15' } } });
const events = generateBirthdayEvents([c], '2021-01-01', '2021-12-31');
expect(events).toHaveLength(1);
expect(events[0].title).toBe('🎂 Alice Smith');
});
it('parses a Timestamp anniversary date', () => {
const c = contact({ anniversaries: { b1: { '@type': 'Anniversary', kind: 'birth', date: { '@type': 'Timestamp', utc: '1985-03-10T00:00:00Z' } } } });
const events = generateBirthdayEvents([c], '2021-01-01', '2021-12-31');
expect(events[0].start).toBe('2021-03-10T00:00:00');
});
it('parses a PartialDate anniversary date', () => {
const c = contact({ anniversaries: { b1: { '@type': 'Anniversary', kind: 'birth', date: { month: 7, day: 4 } } } });
const events = generateBirthdayEvents([c], '2021-01-01', '2021-12-31');
expect(events[0].start).toBe('2021-07-04T00:00:00');
});
it('clamps Feb 29 to Feb 28 in a non-leap year', () => {
const c = contact({ anniversaries: { b1: { '@type': 'Anniversary', kind: 'birth', date: '2000-02-29' } } });
const events = generateBirthdayEvents([c], '2021-01-01', '2021-12-31');
expect(events[0].start).toBe('2021-02-28T00:00:00');
});
it('excludes occurrences outside the range', () => {
expect(generateBirthdayEvents([contact()], '2021-01-01', '2021-02-28')).toEqual([]); // May birthday
});
it('returns [] for an invalid range', () => {
expect(generateBirthdayEvents([contact()], 'not-a-date', '2021-12-31')).toEqual([]);
});
it('skips non-birth anniversaries', () => {
const c = contact({ anniversaries: { w1: { '@type': 'Anniversary', kind: 'wedding', date: '2010-06-01' } } });
expect(generateBirthdayEvents([c], '2021-01-01', '2021-12-31')).toEqual([]);
});
});
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
vi.mock('next/server', () => ({
NextResponse: {
json: (data: unknown, init?: { status?: number }) => ({ status: init?.status ?? 200, json: async () => data }),
},
NextRequest: class {},
}));
vi.mock('@/lib/logger', () => ({ logger: { warn: () => {}, error: () => {} } }));
vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: vi.fn() }));
import { POST } from '@/app/api/caldav/discover/route';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
const mockCreds = getStalwartCredentials as unknown as Mock;
const CREDS = { serverUrl: 'https://mail.example.com', username: 'u', authHeader: 'Basic abc' };
let fetchSpy: Mock;
function makeReq(body: unknown): Parameters<typeof POST>[0] {
return { headers: { get: () => null }, json: async () => body } as unknown as Parameters<typeof POST>[0];
}
function read(res: unknown) {
return res as { status: number; json: () => Promise<{ wellKnownUrl: string; accounts: Record<string, { url: string | null; resolvedAccount: string | null }> }> };
}
const target = (c: string) => `https://mail.example.com/dav/cal/${c}`;
beforeEach(() => {
mockCreds.mockResolvedValue(CREDS);
fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('POST /api/caldav/discover', () => {
it('401 without credentials', async () => {
mockCreds.mockResolvedValue(null);
const res = read(await POST(makeReq({ accounts: [] })));
expect(res.status).toBe(401);
});
it('returns the .well-known url and resolves the first 207 candidate, skipping the rest', async () => {
fetchSpy.mockResolvedValue({ status: 207, headers: new Headers() });
const res = read(await POST(makeReq({ accounts: [{ key: 'A', candidates: ['c1', 'c2'] }] })));
const data = await res.json();
expect(data.wellKnownUrl).toBe('https://mail.example.com/.well-known/caldav');
expect(data.accounts.A).toEqual({ url: target('c1'), resolvedAccount: 'c1' });
expect(fetchSpy).toHaveBeenCalledTimes(1); // c2 never probed
expect(fetchSpy).toHaveBeenCalledWith(target('c1'), expect.objectContaining({ method: 'PROPFIND' }));
});
it('resolves a redirect Location relative to the probe URL', async () => {
fetchSpy.mockResolvedValue({ status: 302, headers: new Headers({ Location: '/dav/cal/real-home' }) });
const res = read(await POST(makeReq({ accounts: [{ key: 'A', candidates: ['c1'] }] })));
const data = await res.json();
expect(data.accounts.A).toEqual({ url: 'https://mail.example.com/dav/cal/real-home', resolvedAccount: 'c1' });
});
it('returns null url when every candidate fails, but still 200', async () => {
fetchSpy.mockResolvedValue({ status: 404, headers: new Headers() });
const res = read(await POST(makeReq({ accounts: [{ key: 'A', candidates: ['c1', 'c2'] }] })));
expect(res.status).toBe(200);
expect((await res.json()).accounts.A).toEqual({ url: null, resolvedAccount: null });
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it('catches a probe error and continues to the next candidate', async () => {
fetchSpy
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce({ status: 207, headers: new Headers() });
const res = read(await POST(makeReq({ accounts: [{ key: 'A', candidates: ['c1', 'c2'] }] })));
expect((await res.json()).accounts.A).toEqual({ url: target('c2'), resolvedAccount: 'c2' });
});
it('de-duplicates and trims candidates before probing', async () => {
fetchSpy.mockResolvedValue({ status: 404, headers: new Headers() });
await POST(makeReq({ accounts: [{ key: 'A', candidates: [' c1 ', 'c1', '', 'c1'] }] }));
expect(fetchSpy).toHaveBeenCalledTimes(1); // collapsed to a single "c1"
expect(fetchSpy).toHaveBeenCalledWith(target('c1'), expect.anything());
});
});
+26 -19
View File
@@ -274,26 +274,31 @@ describe('buildParticipantMap', () => {
expect(Object.keys(map)).toHaveLength(3);
const org = map['organizer'];
expect(org.name).toBe('Alice');
expect(org.email).toBe('alice@example.com');
expect(org.roles).toEqual({ owner: true, attendee: true });
expect(org.participationStatus).toBe('accepted');
expect(org.scheduleAgent).toBe('server');
expect(org.sendTo).toEqual({ imip: 'mailto:alice@example.com' });
expect(org.expectReply).toBe(false);
// Entries are keyed by generated UUIDs (RFC 8984 participant ids), so
// look them up by identity rather than by a fixed key.
const entries = Object.values(map);
const att0 = map['attendee-0'];
expect(att0.name).toBe('Bob');
expect(att0.email).toBe('bob@example.com');
expect(att0.roles).toEqual({ attendee: true });
expect(att0.participationStatus).toBe('needs-action');
expect(att0.scheduleAgent).toBe('server');
expect(att0.expectReply).toBe(true);
const org = entries.find(p => p.roles?.owner);
expect(org).toBeDefined();
expect(org!.name).toBe('Alice');
expect(org!.email).toBe('alice@example.com');
expect(org!.roles).toEqual({ owner: true, attendee: true });
expect(org!.participationStatus).toBe('accepted');
expect(org!.scheduleAgent).toBe('server');
expect(org!.sendTo).toEqual({ imip: 'mailto:alice@example.com' });
expect(org!.expectReply).toBe(false);
const att1 = map['attendee-1'];
expect(att1.name).toBe('Carol');
expect(att1.email).toBe('carol@example.com');
const att0 = entries.find(p => p.email === 'bob@example.com');
expect(att0).toBeDefined();
expect(att0!.name).toBe('Bob');
expect(att0!.roles).toEqual({ attendee: true });
expect(att0!.participationStatus).toBe('needs-action');
expect(att0!.scheduleAgent).toBe('server');
expect(att0!.expectReply).toBe(true);
const att1 = entries.find(p => p.email === 'carol@example.com');
expect(att1).toBeDefined();
expect(att1!.name).toBe('Carol');
});
it('creates only organizer when no attendees', () => {
@@ -302,7 +307,9 @@ describe('buildParticipantMap', () => {
[]
);
expect(Object.keys(map)).toHaveLength(1);
expect(map['organizer']).toBeDefined();
const org = Object.values(map)[0];
expect(org).toBeDefined();
expect(org.roles).toEqual({ owner: true, attendee: true });
});
it('sets @type to Participant for all entries', () => {
+16 -4
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
import type { CalendarEvent } from '@/lib/jmap/types';
import {
buildTimedFullDayWeekSegments,
@@ -14,6 +14,18 @@ import {
normalizeAllDayDuration,
} from '../calendar-utils';
// Several suites assert wall-clock minutes/dates derived in local time. Pin the
// timezone to UTC so results don't depend on the host's zone (CI here runs at
// UTC+2); the expected values below are all UTC.
let originalTZ: string | undefined;
beforeAll(() => {
originalTZ = process.env.TZ;
process.env.TZ = 'UTC';
});
afterAll(() => {
process.env.TZ = originalTZ;
});
function expectLocalDateParts(date: Date, year: number, month: number, day: number, hour: number, minute = 0, second = 0, millisecond = 0) {
expect(date.getFullYear()).toBe(year);
expect(date.getMonth()).toBe(month - 1);
@@ -124,7 +136,7 @@ describe('calendar-utils all-day handling', () => {
});
expect(getTimedEventBoundsForDay(event, new Date('2026-03-14T00:00:00Z'))).toMatchObject({
startMinutes: 1380,
startMinutes: 1320,
endMinutes: 1440,
continuesBefore: false,
continuesAfter: true,
@@ -132,7 +144,7 @@ describe('calendar-utils all-day handling', () => {
expect(getTimedEventBoundsForDay(event, new Date('2026-03-15T00:00:00Z'))).toMatchObject({
startMinutes: 0,
endMinutes: 180,
endMinutes: 120,
continuesBefore: true,
continuesAfter: false,
});
@@ -152,7 +164,7 @@ describe('calendar-utils all-day handling', () => {
expect(layout).toHaveLength(1);
expect(layout[0]).toMatchObject({
startMinutes: 0,
endMinutes: 180,
endMinutes: 120,
column: 0,
totalColumns: 1,
continuesBefore: true,
+146
View File
@@ -0,0 +1,146 @@
// Pin TZ so the local-time date rendering in dateParts is deterministic.
process.env.TZ = 'UTC';
import { describe, it, expect } from 'vitest';
import type { Email } from '@/lib/jmap/types';
import {
emailExportFilename,
attachmentDownloadFilename,
attachmentsBundleFilename,
bundleExportFilename,
emailVars,
attachmentVars,
buildSampleEmail,
} from '@/lib/download-filename';
const makeEmail = (over: Partial<Email>): Email =>
({ id: 'e', receivedAt: '2026-05-22T14:05:33Z', from: [], to: [], subject: '', ...over } as unknown as Email);
describe('emailExportFilename', () => {
it('renders the default template from the sample email (UTC)', () => {
expect(emailExportFilename(buildSampleEmail())).toBe(
'2026-05-22 14.05.33 (Alice Sender-Bob Recipient) Benachrichtigung von Ihrem Gerät.eml',
);
});
it('applies lowercase + stripDiacritics + underscore-spaces transforms', () => {
expect(
emailExportFilename(buildSampleEmail(), {
template: '{from_name}-{subject}',
lowercase: true,
stripDiacritics: true,
spaceReplacement: 'underscore',
}),
).toBe('alice_sender-benachrichtigung_von_ihrem_gerat.eml');
});
it('falls back to "no subject" for an empty subject', () => {
expect(emailExportFilename(makeEmail({ subject: '' }), '{subject}')).toBe('no subject.eml');
});
it('falls back to "email" when the template renders empty', () => {
expect(emailExportFilename(makeEmail({}), '{unknown_token}')).toBe('email.eml');
});
it('lets a single long token reach the 200-char filename cap', () => {
// Each {token} is now capped at FILENAME_MAX_LEN (200), so the overall
// filename limit governs instead of an earlier 80-char per-token cap.
const out = emailExportFilename(makeEmail({ subject: 'a'.repeat(300) }), '{subject}');
expect(out).toBe('a'.repeat(200) + '.eml');
});
});
describe('attachmentDownloadFilename', () => {
it('email===null: sanitises the raw attachment name, applying transforms', () => {
expect(attachmentDownloadFilename(null, { name: 'Report.PDF' })).toBe('Report.PDF');
expect(attachmentDownloadFilename(null, { name: 'Report.PDF' }, { lowercase: true })).toBe('report.pdf');
});
it('{filename} token preserves the original extension', () => {
expect(
attachmentDownloadFilename(buildSampleEmail(), { name: 'My Report.pdf' }, '{filename}'),
).toBe('My Report.pdf');
});
it('template without {ext}/{filename} appends the attachment extension', () => {
expect(
attachmentDownloadFilename(buildSampleEmail(), { name: 'My Report.PDF' }, '{name}'),
).toBe('My Report.PDF');
expect(
attachmentDownloadFilename(buildSampleEmail(), { name: 'My Report.PDF' }, { template: '{name}', lowercase: true }),
).toBe('my report.pdf');
});
it('attachment with no extension yields no trailing dot', () => {
expect(attachmentDownloadFilename(buildSampleEmail(), { name: 'noext' }, '{name}')).toBe('noext');
});
it('sanitises path-traversal characters out of the name', () => {
const out = attachmentDownloadFilename(null, { name: '../../etc/passwd' });
expect(out).not.toContain('/');
// CHARACTERISATION: slashes → "_", then the leading "._-" run is stripped,
// so "../../etc/passwd" collapses to "etc_passwd".
expect(out).toBe('etc_passwd');
});
});
describe('bundleExportFilename', () => {
it('substitutes {count} and appends .zip', () => {
expect(bundleExportFilename(3, '{count}-emails', '2026-05-22T14:05:33Z')).toBe('3-emails.zip');
});
it('uses the default template', () => {
expect(bundleExportFilename(5, {}, '2026-05-22T14:05:33Z')).toBe('emails-5.zip');
});
});
describe('attachmentsBundleFilename', () => {
it('embeds the sanitised subject', () => {
expect(attachmentsBundleFilename(makeEmail({ subject: 'Invoice March' }))).toBe('attachments_Invoice March.zip');
});
it('sanitises filesystem-reserved characters', () => {
expect(attachmentsBundleFilename(makeEmail({ subject: 'Q1/Q2: report' }))).toBe('attachments_Q1_Q2_ report.zip');
});
it('falls back when the subject is empty', () => {
expect(attachmentsBundleFilename(makeEmail({ subject: '' }))).toBe('attachments.zip');
});
it('falls back when there is no email', () => {
expect(attachmentsBundleFilename(null)).toBe('attachments.zip');
});
});
describe('emailVars (date + address labels)', () => {
it('returns the invalid-date sentinel for an unparseable date', () => {
const v = emailVars(makeEmail({ receivedAt: 'not-a-date', sentAt: undefined }));
expect(v.date).toBe('0000-00-00 00.00.00');
expect(v.date_short).toBe('0000-00-00');
expect(v.time).toBe('00.00.00');
expect(v.year).toBe('0000');
});
it('addrLabel falls back name → email user-part → "unknown"', () => {
expect(emailVars(makeEmail({ from: [{ email: 'alice@example.com' }] }) ).from).toBe('alice');
expect(emailVars(makeEmail({ from: [] })).from).toBe('unknown');
expect(emailVars(makeEmail({ from: [{ name: ' ', email: 'x@y.com' }] })).from).toBe('x');
});
});
describe('attachmentVars (extension split)', () => {
it('splits name and ext on the last dot', () => {
const v = attachmentVars(buildSampleEmail(), { name: 'doc.tar.gz' });
expect(v).toMatchObject({ filename: 'doc.tar.gz', name: 'doc.tar', ext: 'gz' });
});
it('treats a dotless name as having no extension', () => {
const v = attachmentVars(buildSampleEmail(), { name: 'noext' });
expect(v).toMatchObject({ name: 'noext', ext: '' });
});
it('defaults a missing name to "attachment"', () => {
const v = attachmentVars(buildSampleEmail(), {});
expect(v).toMatchObject({ filename: 'attachment', ext: '' });
});
});
+162
View File
@@ -4,6 +4,12 @@ import {
rewriteCidImagesForEditor,
replaceInlineImagePlaceholders,
INLINE_IMAGE_PLACEHOLDER,
splitRecipients,
formatRecipient,
parseRecipient,
parseRecipientList,
formatRecipientList,
splitPastedRecipients,
} from "../email-composer-utils";
describe("plainTextToComposerBody", () => {
@@ -112,3 +118,159 @@ describe("replaceInlineImagePlaceholders", () => {
expect(out).toBe(html);
});
});
describe("splitRecipients", () => {
it("splits a plain comma-separated list", () => {
expect(splitRecipients("alice@x.com, bob@x.com")).toEqual([
"alice@x.com",
"bob@x.com",
]);
});
it("trims whitespace and drops empty segments", () => {
expect(splitRecipients(" alice@x.com ,, bob@x.com ,")).toEqual([
"alice@x.com",
"bob@x.com",
]);
});
it("keeps a quoted display name containing a comma intact", () => {
expect(splitRecipients('"Doo, John" <john@doo.org>, alice@x.com')).toEqual([
'"Doo, John" <john@doo.org>',
"alice@x.com",
]);
});
it("does not split on a comma inside angle brackets", () => {
expect(splitRecipients("Group <a,b@x.com>, c@x.com")).toEqual([
"Group <a,b@x.com>",
"c@x.com",
]);
});
it("returns an empty array for an empty string", () => {
expect(splitRecipients("")).toEqual([]);
});
it("only splits on the given separators (default comma keeps semicolons/newlines literal)", () => {
expect(splitRecipients("a@x.com; b@y.com")).toEqual(["a@x.com; b@y.com"]);
});
it("splits on a wider separator set while keeping quotes/angles literal", () => {
expect(
splitRecipients('"Doo, John" <john@doo.org>; a@x.com\nb@y.com', ',;\n\r'),
).toEqual(['"Doo, John" <john@doo.org>', "a@x.com", "b@y.com"]);
});
});
describe("formatRecipient / parseRecipient", () => {
it("returns a bare email when there is no name", () => {
expect(formatRecipient(undefined, "a@x.com")).toBe("a@x.com");
});
it("returns a bare email when the name equals the email", () => {
expect(formatRecipient("a@x.com", "a@x.com")).toBe("a@x.com");
});
it("formats a simple name without quoting", () => {
expect(formatRecipient("Alice", "a@x.com")).toBe("Alice <a@x.com>");
});
it("quotes a name containing a comma", () => {
expect(formatRecipient("Doo, John", "john@doo.org")).toBe(
'"Doo, John" <john@doo.org>'
);
});
it("parses a bare email", () => {
expect(parseRecipient("a@x.com")).toEqual({ email: "a@x.com" });
});
it("parses and unquotes a quoted comma name", () => {
expect(parseRecipient('"Doo, John" <john@doo.org>')).toEqual({
name: "Doo, John",
email: "john@doo.org",
});
});
});
describe("parseRecipientList / formatRecipientList", () => {
it("round-trips a comma-name recipient through serialize + parse", () => {
const list = [
{ name: "Doo, John", email: "john@doo.org" },
{ email: "alice@x.com" },
];
const serialized = formatRecipientList(list);
expect(serialized).toBe('"Doo, John" <john@doo.org>, alice@x.com');
expect(parseRecipientList(serialized)).toEqual(list);
});
it("parses an empty string to an empty array", () => {
expect(parseRecipientList("")).toEqual([]);
});
});
describe("splitPastedRecipients", () => {
it("splits on commas, semicolons and whitespace (incl. newline/tab)", () => {
const { valid, invalid } = splitPastedRecipients(
"a@x.com, b@y.com; c@z.com\nd@w.com\te@v.com f@u.com",
);
expect(valid.map((r) => r.email)).toEqual([
"a@x.com", "b@y.com", "c@z.com", "d@w.com", "e@v.com", "f@u.com",
]);
expect(invalid).toEqual([]);
});
it("collapses runs of mixed separators and drops empties", () => {
const { valid } = splitPastedRecipients(" a@x.com ,; , b@y.com ");
expect(valid.map((r) => r.email)).toEqual(["a@x.com", "b@y.com"]);
});
it("partitions invalid tokens into `invalid`, keeping valid as chips", () => {
const { valid, invalid } = splitPastedRecipients("a@x.com not-an-email b@y.com");
expect(valid.map((r) => r.email)).toEqual(["a@x.com", "b@y.com"]);
expect(invalid).toEqual(["not-an-email"]);
});
it("unwraps an angle-bracketed token before validating", () => {
const { valid } = splitPastedRecipients("<a@x.com>");
expect(valid).toEqual([{ email: "a@x.com" }]);
});
it("keeps a `Name <email>` pair as a single chip with its display name", () => {
const { valid, invalid } = splitPastedRecipients("John Doe <j@x.com>");
expect(valid).toEqual([{ name: "John Doe", email: "j@x.com" }]);
expect(invalid).toEqual([]);
});
it("keeps a fully-quoted `\"Name <email>\"` entry with its display name", () => {
const { valid, invalid } = splitPastedRecipients(
'"Alice Smith <alice@x.com>", "Alex Smith <alex@x.com>"',
);
expect(valid).toEqual([
{ name: "Alice Smith", email: "alice@x.com" },
{ name: "Alex Smith", email: "alex@x.com" },
]);
expect(invalid).toEqual([]);
});
it("keeps a comma inside a quoted display name intact", () => {
const { valid } = splitPastedRecipients('"Doe, John" <j@x.com>; bob@z.com');
expect(valid).toEqual([
{ name: "Doe, John", email: "j@x.com" },
{ email: "bob@z.com" },
]);
});
it("dedupes case-insensitively within the paste and against existing emails", () => {
const { valid } = splitPastedRecipients(
"a@x.com A@X.com b@y.com c@z.com",
["B@Y.com"],
);
expect(valid.map((r) => r.email)).toEqual(["a@x.com", "c@z.com"]);
});
it("returns empty arrays for blank input", () => {
expect(splitPastedRecipients(" ")).toEqual({ valid: [], invalid: [] });
});
});
+2 -2
View File
@@ -207,10 +207,10 @@ describe('getSecurityStatus', () => {
expect(status.color).toContain('red');
});
it('returns amber for softfail', () => {
it('returns a warning color for softfail', () => {
const status = getSecurityStatus('softfail');
expect(status.icon).toBe('alert');
expect(status.color).toContain('amber');
expect(status.color).toContain('warning');
});
it('returns amber for neutral and temperror', () => {
+206
View File
@@ -7,6 +7,13 @@ import {
hasRichFormatting,
plainTextToSafeHtml,
EMAIL_SANITIZE_CONFIG,
EMAIL_IFRAME_SANITIZE_CONFIG,
isExternalResourceUrl,
decodeCssEscapes,
styleHasExternalUrl,
stripExternalCssUrls,
blockExternalResourcesOnNode,
TRANSPARENT_BLOCKED_PIXEL,
} from '../email-sanitization';
describe('email-sanitization', () => {
@@ -324,6 +331,205 @@ describe('email-sanitization', () => {
});
});
describe('isExternalResourceUrl', () => {
it('detects http(s) and protocol-relative URLs', () => {
expect(isExternalResourceUrl('https://tracker.example/p.png')).toBe(true);
expect(isExternalResourceUrl('http://tracker.example/p.png')).toBe(true);
expect(isExternalResourceUrl('//tracker.example/p.png')).toBe(true);
});
it('sees through leading whitespace/newlines (imgNewlineSrc bypass)', () => {
expect(isExternalResourceUrl('\n\nhttps://tracker.example/p.png')).toBe(true);
expect(isExternalResourceUrl(' \t https://tracker.example/p.png')).toBe(true);
// Tab/newline removed anywhere in the URL by the parser.
expect(isExternalResourceUrl('h\nttps://tracker.example/p.png')).toBe(true);
expect(isExternalResourceUrl('ht\ttps://tracker.example/p.png')).toBe(true);
});
it('treats inline/local schemes as not external', () => {
expect(isExternalResourceUrl('data:image/png;base64,AAAA')).toBe(false);
expect(isExternalResourceUrl('blob:http://localhost/abc')).toBe(false);
expect(isExternalResourceUrl('cid:image001@example.com')).toBe(false);
expect(isExternalResourceUrl('/relative/path.png')).toBe(false);
expect(isExternalResourceUrl('')).toBe(false);
expect(isExternalResourceUrl(null)).toBe(false);
expect(isExternalResourceUrl(undefined)).toBe(false);
});
});
describe('decodeCssEscapes', () => {
it('decodes hex escapes (cssEscape bypass)', () => {
expect(decodeCssEscapes('\\68ttp://x')).toBe('http://x');
expect(decodeCssEscapes('\\000068ttps://x')).toBe('https://x');
// Hex escape consumes one trailing whitespace separator.
expect(decodeCssEscapes('\\68 ttp')).toBe('http');
});
it('decodes single-character escapes', () => {
expect(decodeCssEscapes('\\h\\t\\t\\p')).toBe('http');
});
});
describe('styleHasExternalUrl / stripExternalCssUrls', () => {
it('detects and strips plain external url()', () => {
const style = 'background:url(https://tracker.example/p.png)';
expect(styleHasExternalUrl(style)).toBe(true);
expect(stripExternalCssUrls(style)).toBe('background:url()');
});
it('detects and strips CSS-escaped external url()', () => {
const style = 'background:url(\\68ttps://tracker.example/p.png)';
expect(styleHasExternalUrl(style)).toBe(true);
expect(stripExternalCssUrls(style)).toBe('background:url()');
});
it('detects url() with whitespace/quotes', () => {
expect(styleHasExternalUrl("background: url( '\n https://t/p.png' )")).toBe(true);
});
it('leaves data: and relative url() untouched', () => {
const style = "background:url('data:image/png;base64,AAAA')";
expect(styleHasExternalUrl(style)).toBe(false);
expect(stripExternalCssUrls(style)).toBe(style);
});
});
describe('blockExternalResourcesOnNode (anti-tracking vectors)', () => {
function el(html: string): Element {
return parseHtmlSafely(`<body>${html}</body>`).body.firstElementChild!;
}
it('blocks an img whose src is hidden behind a leading newline', () => {
const img = el('<img src="">');
img.setAttribute('src', '\n\nhttps://tracker.example/pixel.png');
expect(blockExternalResourcesOnNode(img)).toBe(true);
expect(img.getAttribute('data-blocked-src')).toBe('https://tracker.example/pixel.png');
expect(img.getAttribute('src')).toBe(TRANSPARENT_BLOCKED_PIXEL);
});
it('blocks img srcset', () => {
const img = el('<img srcset="https://tracker.example/1x.png 1x, https://tracker.example/2x.png 2x">');
expect(blockExternalResourcesOnNode(img)).toBe(true);
expect(img.hasAttribute('srcset')).toBe(false);
expect(img.getAttribute('data-blocked-srcset')).toContain('tracker.example');
});
it('blocks <picture><source srcset> (pictureSource)', () => {
const source = el('<source srcset="https://tracker.example/pic.webp" type="image/webp">');
expect(blockExternalResourcesOnNode(source)).toBe(true);
expect(source.hasAttribute('srcset')).toBe(false);
});
it('blocks <source src> for media', () => {
const source = el('<source src="https://tracker.example/v.mp4">');
expect(blockExternalResourcesOnNode(source)).toBe(true);
expect(source.hasAttribute('src')).toBe(false);
expect(source.getAttribute('data-blocked-src')).toContain('tracker.example');
});
it('blocks <video poster> (videoPoster)', () => {
const video = el('<video poster="https://tracker.example/poster.jpg"></video>');
expect(blockExternalResourcesOnNode(video)).toBe(true);
expect(video.hasAttribute('poster')).toBe(false);
expect(video.getAttribute('data-blocked-poster')).toContain('tracker.example');
});
it('blocks video src', () => {
const video = el('<video src="https://tracker.example/v.mp4"></video>');
expect(blockExternalResourcesOnNode(video)).toBe(true);
expect(video.hasAttribute('src')).toBe(false);
});
it('blocks the legacy background attribute', () => {
// <td> is foster-parented out of <body>, so build it directly.
const td = document.createElement('td');
td.setAttribute('background', 'https://tracker.example/bg.png');
expect(blockExternalResourcesOnNode(td)).toBe(true);
expect(td.hasAttribute('background')).toBe(false);
expect(td.getAttribute('data-blocked-background')).toContain('tracker.example');
});
it('strips external inline style url() including CSS escapes (cssEscape)', () => {
const div = el('<div style="background:url(\\68ttps://tracker.example/p.png)">x</div>');
expect(blockExternalResourcesOnNode(div)).toBe(true);
expect(div.getAttribute('style')).not.toContain('tracker.example');
expect(div.getAttribute('data-blocked-style')).toContain('tracker.example');
});
it('does not block inline/local resources', () => {
const img = el('<img src="blob:http://localhost/inline">');
expect(blockExternalResourcesOnNode(img)).toBe(false);
expect(img.getAttribute('src')).toBe('blob:http://localhost/inline');
const dataImg = el('<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7">');
expect(blockExternalResourcesOnNode(dataImg)).toBe(false);
const cidImg = el('<img src="cid:logo@example.com">');
expect(blockExternalResourcesOnNode(cidImg)).toBe(false);
});
it('works as a DOMPurify afterSanitizeAttributes hook across all vectors', () => {
const html = `
<img src="&#10;&#10;https://tracker.example/a.png">
<picture><source srcset="https://tracker.example/b.webp"><img src="https://tracker.example/c.png"></picture>
<div style="background:url(\\68ttps://tracker.example/d.png)">bg</div>
`;
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
blockExternalResourcesOnNode(node as Element);
});
const clean = DOMPurify.sanitize(html, EMAIL_SANITIZE_CONFIG);
DOMPurify.removeAllHooks();
const doc = parseHtmlSafely(clean);
// No live src/srcset/style references the tracker anymore.
doc.querySelectorAll('img, source').forEach((node) => {
expect(node.getAttribute('src') ?? '').not.toContain('tracker.example');
expect(node.getAttribute('srcset') ?? '').not.toContain('tracker.example');
});
expect(doc.querySelector('div')?.getAttribute('style') ?? '').not.toContain('tracker.example');
// The originals are stashed for the banner/affordance.
expect(clean).toContain('data-blocked-src');
expect(clean).toContain('data-blocked-srcset');
expect(clean).toContain('data-blocked-style');
});
});
describe('Email Privacy Tester exact payloads (iframe render path)', () => {
function render(html: string): string {
DOMPurify.addHook('afterSanitizeAttributes', (node) =>
blockExternalResourcesOnNode(node as Element)
);
const out = DOMPurify.sanitize(html, EMAIL_IFRAME_SANITIZE_CONFIG);
DOMPurify.removeAllHooks();
return out;
}
it('pictureSource: <picture><source srcset> does not keep a live external ref', () => {
const source = parseHtmlSafely(render('<picture><source srcset="http://TRACK/"><img src="#"></picture>')).querySelector('source')!;
expect(source.hasAttribute('srcset')).toBe(false);
expect(source.getAttribute('data-blocked-srcset')).toContain('TRACK');
});
it('imgNewlineSrc: newline after the first slash (protocol-relative) is blocked', () => {
const img = parseHtmlSafely(render('<img src="/\n/TRACK_HOST/PATH">')).querySelector('img')!;
expect(img.getAttribute('src')).toBe(TRANSPARENT_BLOCKED_PIXEL);
expect(img.getAttribute('data-blocked-src')).toContain('TRACK_HOST');
});
it('videoPoster: poster and src are both stripped', () => {
const video = parseHtmlSafely(render('<video poster="http://TRACK/" autoplay="true" src="http://OTHER/"></video>')).querySelector('video')!;
expect(video.hasAttribute('poster')).toBe(false);
expect(video.hasAttribute('src')).toBe(false);
expect(video.getAttribute('data-blocked-poster')).toContain('TRACK');
expect(video.getAttribute('data-blocked-src')).toContain('OTHER');
});
it('anchor href is preserved (links stay clickable; DNS prefetch is disabled via iframe meta)', () => {
const out = render('<a href="http://TRACK/">link</a>');
expect(out).toContain('href="http://TRACK/"');
});
});
describe('plainTextToSafeHtml', () => {
it('escapes HTML-special characters in surrounding text', () => {
const result = plainTextToSafeHtml('<script>alert(1)</script> & "q" \'q\'');
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import JSZip from 'jszip';
import { expandImportableEmails, EML_IMPORT_ACCEPT } from '@/lib/eml-import';
const emlFile = (name: string, content = 'raw email', type = 'message/rfc822') =>
new File([content], name, { type });
describe('expandImportableEmails', () => {
it('wraps a .eml file as a message/rfc822 blob, keeping its name', async () => {
const out = await expandImportableEmails([emlFile('msg.eml')]);
expect(out).toHaveLength(1);
expect(out[0].name).toBe('msg.eml');
expect(out[0].blob.type).toBe('message/rfc822');
await expect(out[0].blob.text()).resolves.toBe('raw email');
});
it('CHARACTERISATION: wraps a non-.eml, non-zip file as rfc822 too', async () => {
const out = await expandImportableEmails([emlFile('note.txt', 'hi', 'text/plain')]);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ name: 'note.txt' });
expect(out[0].blob.type).toBe('message/rfc822');
});
it('extracts only .eml entries from a .zip, stripping path prefixes', async () => {
const zip = new JSZip();
zip.file('a.eml', 'A');
zip.file('sub/b.eml', 'B');
zip.file('c.txt', 'C'); // skipped (not .eml)
zip.folder('emptydir'); // skipped (directory)
const blob = await zip.generateAsync({ type: 'blob' });
const file = new File([blob], 'archive.zip', { type: 'application/zip' });
const out = await expandImportableEmails([file]);
expect(out.map((e) => e.name).sort()).toEqual(['a.eml', 'b.eml']);
expect(out.every((e) => e.blob.type === 'message/rfc822')).toBe(true);
});
it('uses the zip path when the MIME type is application/zip even without a .zip name', async () => {
const zip = new JSZip();
zip.file('only.eml', 'X');
const blob = await zip.generateAsync({ type: 'blob' });
const file = new File([blob], 'archive-no-ext', { type: 'application/zip' });
const out = await expandImportableEmails([file]);
expect(out.map((e) => e.name)).toEqual(['only.eml']);
});
it('exposes the accept string for the file picker', () => {
expect(EML_IMPORT_ACCEPT).toBe('.eml,.zip,message/rfc822,application/zip');
});
});
+34
View File
@@ -0,0 +1,34 @@
// Shared test factories. Additive only — existing tests keep their inline
// factories; new tests can import these to avoid re-declaring the large
// Email/Mailbox literals. Keep minimal and cast through `unknown` so callers
// only specify the fields they assert on.
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
export const makeEmail = (over: Partial<Email> = {}): Email =>
({
id: 'e1',
threadId: 't1',
receivedAt: '2026-01-01T00:00:00Z',
subject: '',
from: [],
to: [],
cc: [],
keywords: {},
mailboxIds: {},
...over,
} as unknown as Email);
export const makeMailbox = (over: Partial<Mailbox> = {}): Mailbox =>
({
id: 'mb1',
name: 'Inbox',
role: 'inbox',
unreadEmails: 0,
totalEmails: 0,
...over,
} as unknown as Mailbox);
/** A bare fake JMAP client: only the methods you pass exist. */
export const makeFakeJmapClient = (over: Partial<IJMAPClient> = {}): IJMAPClient =>
over as unknown as IJMAPClient;
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { htmlToPlainText } from '../html-to-text';
describe('htmlToPlainText', () => {
it('preserves line breaks from <br> within a paragraph', () => {
expect(htmlToPlainText('<p>Line 1.<br>Line 2.</p>')).toBe('Line 1.\nLine 2.');
});
it('separates block elements with newlines instead of running them together (#421)', () => {
// The previous textContent-based implementation produced "Line 1.Line 2.Paragraph 2."
expect(htmlToPlainText('<div>Line 1.</div><div>Line 2.</div><div>Paragraph 2.</div>')).toBe(
'Line 1.\nLine 2.\nParagraph 2.'
);
});
it('with paragraphSpacing, separates paragraphs with a blank line', () => {
expect(
htmlToPlainText('<p>Line 1.<br>Line 2.</p><p>Paragraph 2.</p>', { paragraphSpacing: true })
).toBe('Line 1.\nLine 2.\n\nParagraph 2.');
});
it('without paragraphSpacing, separates paragraphs with a single newline', () => {
expect(htmlToPlainText('<p>A</p><p>B</p>')).toBe('A\nB');
});
it('renders links as text when the text equals the href', () => {
expect(
htmlToPlainText('<p><a href="mailto:alice@example.com">alice@example.com</a></p>')
).toBe('alice@example.com');
});
it('renders links as "text <href>" when they differ', () => {
expect(htmlToPlainText('<p>Visit <a href="https://example.com">our site</a></p>')).toBe(
'Visit our site <https://example.com>'
);
});
it('collapses excess whitespace and trims the result', () => {
expect(htmlToPlainText(' <p> Hello world </p> ')).toBe('Hello world');
});
it('caps consecutive blank lines at one', () => {
expect(htmlToPlainText('<p>A</p><br><br><br><p>B</p>', { paragraphSpacing: true })).toBe(
'A\n\nB'
);
});
it('returns an empty string for empty or whitespace-only HTML', () => {
expect(htmlToPlainText('')).toBe('');
expect(htmlToPlainText(' <p> </p> ')).toBe('');
});
it('handles nested lists as separate lines', () => {
expect(htmlToPlainText('<ul><li>One</li><li>Two</li></ul>')).toBe('One\nTwo');
});
});
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
import type { FileNodeRights } from '../jmap/types';
function createClient(): JMAPClient {
const client = new JMAPClient('https://jmap.example.com', 'user', 'pass');
Object.assign(client, {
apiUrl: 'https://jmap.example.com/api',
accountId: 'account-1',
capabilities: { 'urn:ietf:params:jmap:filenode': {}, 'urn:ietf:params:jmap:principals': {} },
});
return client;
}
function mockFetch(response: object, ok = true, status = 200) {
return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok,
status,
text: () => Promise.resolve(JSON.stringify(response)),
json: () => Promise.resolve(response),
} as Response);
}
const READ: FileNodeRights = {
mayRead: true, mayAddChildren: false, mayRename: false,
mayDelete: false, mayModifyContent: false, mayShare: false,
};
function lastRequestBody(spy: ReturnType<typeof vi.spyOn>): { using: string[]; methodCalls: unknown[][] } {
const call = spy.mock.calls[spy.mock.calls.length - 1];
return JSON.parse((call[1] as RequestInit).body as string);
}
describe('JMAPClient.setFileNodeShare', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('sends a FileNode/set shareWith patch and resolves on success', async () => {
const spy = mockFetch({
methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']],
});
const client = createClient();
await client.setFileNodeShare('node-1', 'principal-9', READ);
const body = lastRequestBody(spy);
expect(body.using).toContain('urn:ietf:params:jmap:filenode');
expect(body.using).toContain('urn:ietf:params:jmap:principals:owner');
const [method, args] = body.methodCalls[0] as [string, Record<string, unknown>];
expect(method).toBe('FileNode/set');
expect(args.accountId).toBe('account-1');
expect(args.update).toEqual({
'node-1': { 'shareWith/principal-9': READ },
});
});
it('sends null to revoke a principal\'s access', async () => {
const spy = mockFetch({
methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']],
});
const client = createClient();
await client.setFileNodeShare('node-1', 'principal-9', null);
const body = lastRequestBody(spy);
const [, args] = body.methodCalls[0] as [string, Record<string, unknown>];
expect(args.update).toEqual({ 'node-1': { 'shareWith/principal-9': null } });
});
it('throws with the server description when the update is rejected', async () => {
mockFetch({
methodResponses: [['FileNode/set', {
notUpdated: { 'node-1': { type: 'forbidden', description: 'Not allowed' } },
}, '0']],
});
const client = createClient();
await expect(client.setFileNodeShare('node-1', 'principal-9', READ))
.rejects.toThrow('Not allowed');
});
it('throws when the server does not confirm the update', async () => {
mockFetch({
methodResponses: [['FileNode/set', { updated: {} }, '0']],
});
const client = createClient();
await expect(client.setFileNodeShare('node-1', 'principal-9', READ))
.rejects.toThrow('did not confirm');
});
});
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { localizeMailboxName } from "@/lib/mailbox-label";
// Stand-in translator mirroring the `sidebar.mailboxes` namespace.
const RU: Record<string, string> = {
inbox: "Входящие",
sent: "Отправленные",
drafts: "Черновики",
trash: "Корзина",
archive: "Архив",
spam: "Спам",
important: "Важные",
starred: "Помечённые",
all_mail: "Вся почта",
};
const translate = (key: string) => RU[key] ?? `MISSING:${key}`;
describe("localizeMailboxName", () => {
it("localizes special-use folders by JMAP role, ignoring the server name", () => {
expect(localizeMailboxName("inbox", "Inbox", translate)).toBe("Входящие");
expect(localizeMailboxName("sent", "Sent", translate)).toBe("Отправленные");
expect(localizeMailboxName("drafts", "Drafts", translate)).toBe("Черновики");
expect(localizeMailboxName("trash", "Deleted Items", translate)).toBe("Корзина");
expect(localizeMailboxName("archive", "Archive", translate)).toBe("Архив");
expect(localizeMailboxName("important", "Important", translate)).toBe("Важные");
});
it("maps junk/flagged/all onto reused translation keys", () => {
expect(localizeMailboxName("junk", "Junk", translate)).toBe("Спам");
expect(localizeMailboxName("flagged", "Flagged", translate)).toBe("Помечённые");
expect(localizeMailboxName("all", "All Mail", translate)).toBe("Вся почта");
});
it("leaves user-created folders (no role) untouched", () => {
expect(localizeMailboxName(undefined, "Projects", translate)).toBe("Projects");
expect(localizeMailboxName(null, "Работа", translate)).toBe("Работа");
expect(localizeMailboxName("", "Receipts", translate)).toBe("Receipts");
});
it("falls back to the server name for unknown roles", () => {
expect(localizeMailboxName("subscribed", "Subscribed", translate)).toBe("Subscribed");
});
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { parseMailto } from "@/lib/protocol-handlers/mailto";
import { formatRecipient, parseRecipientList } from "@/lib/email-composer-utils";
// Build a mailto: URL the way a recipient picker encodes one: each recipient
// percent-encoded and comma-joined, To in the path, Cc/Bcc in a query param.
// Used only to exercise parseMailto's quote-aware recipient splitting.
function encodeMailto(recipients: string[], field: "to" | "cc" | "bcc"): string {
const encoded = recipients.map((r) => encodeURIComponent(r)).join(",");
return field === "to" ? `mailto:${encoded}` : `mailto:?${field}=${encoded}`;
}
describe("parseMailto display-name handling", () => {
it("preserves display names through the mailto round-trip", () => {
const recipients = [
formatRecipient("Alice Smith", "alice@x.com"),
formatRecipient("Bob", "bob@y.com"),
];
const parsed = parseMailto(encodeMailto(recipients, "to"));
expect(parseRecipientList(parsed!.to.join(", "))).toEqual([
{ name: "Alice Smith", email: "alice@x.com" },
{ name: "Bob", email: "bob@y.com" },
]);
});
it("keeps a display name containing a comma intact (quote-aware split)", () => {
const recipients = [
formatRecipient("Doe, John", "john@doe.org"), // -> "Doe, John" <john@doe.org>
"alice@x.com",
];
const parsed = parseMailto(encodeMailto(recipients, "cc"));
expect(parsed!.cc).toEqual(['"Doe, John" <john@doe.org>', "alice@x.com"]);
expect(parseRecipientList(parsed!.cc.join(", "))).toEqual([
{ name: "Doe, John", email: "john@doe.org" },
{ email: "alice@x.com" },
]);
});
});
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { buildMdnMessage } from '@/lib/mdn';
// buildMdnMessage pulls in Date / Date.now / Math.random for the Date header,
// Message-ID and MIME boundary. Pin all three so the output is reproducible.
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-28T14:23:00Z'));
vi.spyOn(Math, 'random').mockReturnValue(0.5); // (0.5).toString(36).slice(2) === 'i'
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
const base = {
to: 'sender@other.com',
fromEmail: 'me@example.com',
originalSubject: 'Hello',
originalMessageId: 'orig@other.com',
};
describe('buildMdnMessage — structure', () => {
it('emits the expected headers and a manual disposition by default', () => {
const msg = buildMdnMessage(base);
expect(msg).toContain('Date: Thu, 28 May 2026 14:23:00 +0000'); // rfc5322 UTC
expect(msg).toMatch(/^From: me@example\.com$/m);
expect(msg).toMatch(/^To: sender@other\.com$/m);
expect(msg).toMatch(/^Subject: Read: Hello$/m); // default subject
expect(msg).toMatch(/^Message-ID: <mdn\.[0-9a-z]+\.i@example\.com>$/m); // random token = 'i'
expect(msg).toMatch(/^In-Reply-To: <orig@other\.com>$/m);
expect(msg).toContain('Original-Message-ID: <orig@other.com>');
expect(msg).toContain('Disposition: manual-action/MDN-sent-manually; displayed');
expect(msg).toContain('Final-Recipient: rfc822;me@example.com');
expect(msg).not.toContain('Original-Recipient:');
expect(msg).toContain('Reporting-UA: example.com; Bulwark Webmail');
});
it('uses CRLF line endings everywhere', () => {
const msg = buildMdnMessage(base);
expect(msg).toContain('\r\n');
expect(msg).not.toMatch(/[^\r]\n/); // no bare LF
});
it('marks an automatic action when automatic:true', () => {
expect(buildMdnMessage({ ...base, automatic: true })).toContain(
'Disposition: automatic-action/MDN-sent-automatically; displayed',
);
});
});
describe('buildMdnMessage — header encoding & normalisation', () => {
it('RFC2047-encodes non-ASCII From name and Subject', () => {
const msg = buildMdnMessage({ ...base, fromName: 'Müller', subject: 'Übersicht' });
expect(msg).toMatch(/^From: =\?UTF-8\?B\?[A-Za-z0-9+/=]+\?= <me@example\.com>$/m);
expect(msg).toMatch(/^Subject: =\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/m);
});
it('normalises Message-ID from a string[] and adds missing angle brackets', () => {
expect(buildMdnMessage({ ...base, originalMessageId: ['arr@x.com'] })).toMatch(
/^In-Reply-To: <arr@x\.com>$/m,
);
expect(buildMdnMessage({ ...base, originalMessageId: 'bare@x.com' })).toContain(
'Original-Message-ID: <bare@x.com>',
);
});
it('omits In-Reply-To / Original-Message-ID when no original id is given', () => {
const msg = buildMdnMessage({ to: base.to, fromEmail: base.fromEmail });
expect(msg).not.toContain('In-Reply-To:');
expect(msg).not.toContain('Original-Message-ID:');
});
it('adds Original-Recipient and uses it as Final-Recipient when supplied', () => {
const msg = buildMdnMessage({ ...base, originalRecipient: 'alias@example.com' });
expect(msg).toContain('Original-Recipient: rfc822;alias@example.com');
expect(msg).toContain('Final-Recipient: rfc822;alias@example.com');
});
it('falls back to the localhost domain when fromEmail has no @', () => {
const msg = buildMdnMessage({ to: base.to, fromEmail: 'invalid' });
expect(msg).toMatch(/^Message-ID: <mdn\.[0-9a-z]+\.i@localhost>$/m);
expect(msg).toContain('Reporting-UA: localhost; Bulwark Webmail');
});
});
describe('buildMdnMessage — body', () => {
it('base64-encodes the human-readable part wrapped at 76 columns', () => {
const msg = buildMdnMessage({ ...base, humanText: 'A'.repeat(100) });
const lines = msg.split('\r\n');
// A 100-char ASCII body → 136 base64 chars → a 76-char line + a 60-char line.
expect(lines.some((l) => l.length === 76 && /^[A-Za-z0-9+/]+$/.test(l))).toBe(true);
expect(lines.every((l) => !/^[A-Za-z0-9+/]+={0,2}$/.test(l) || l.length <= 76)).toBe(true);
});
});
+4 -6
View File
@@ -15,14 +15,12 @@ beforeEach(() => {
});
describe('exposePluginExternals', () => {
it('sets window.__PLUGIN_EXTERNALS__ with React, ReactDOM, ReactJSX', () => {
it('is a no-op that does not publish globals (sandbox injects React per-iframe)', () => {
exposePluginExternals();
// The blob-import loader that needed window.__PLUGIN_EXTERNALS__ is gone;
// exposePluginExternals is kept only as a no-op for legacy callers.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const externals = (globalThis as any).__PLUGIN_EXTERNALS__;
expect(externals).toBeDefined();
expect(externals.React).toBeDefined();
expect(externals.ReactDOM).toBeDefined();
expect(externals.ReactJSX).toBeDefined();
expect((globalThis as any).__PLUGIN_EXTERNALS__).toBeUndefined();
});
});
+23 -21
View File
@@ -1,14 +1,22 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { render } from '@testing-library/react';
import type { SlotRegistration } from '@/lib/plugin-types';
// PluginSlot reads active plugins from the sandbox registry and renders each
// inside a sandboxed iframe. Mock both so we can drive the offers and assert
// what PluginSlot renders without a real iframe + postMessage bridge.
const mockOffers: Record<string, Array<{ pluginId: string }>> = {};
// useSyncExternalStore requires a referentially stable snapshot; hand back a
// single shared empty array for unregistered slots instead of a fresh [].
const EMPTY_OFFERS: Array<{ pluginId: string }> = [];
// Mock the plugin store
const mockSlots: Record<string, SlotRegistration[]> = {};
vi.mock('@/lib/plugin-sandbox/registry', () => ({
offersForSlot: (slot: string) => mockOffers[slot] ?? EMPTY_OFFERS,
subscribe: () => () => {},
}));
vi.mock('@/stores/plugin-store', () => ({
usePluginStore: (selector: (s: { slots: typeof mockSlots }) => unknown) =>
selector({ slots: mockSlots }),
vi.mock('@/components/plugins/plugin-iframe-slot', () => ({
PluginIframeSlot: ({ pluginId, slot }: { pluginId: string; slot: string }) =>
React.createElement('span', { 'data-iframe-plugin': pluginId }, `iframe:${slot}:${pluginId}`),
}));
// Import after mocks
@@ -16,42 +24,36 @@ import { PluginSlot } from '@/components/plugins/plugin-slot';
import { PluginErrorBoundary } from '@/components/plugins/plugin-error-boundary';
beforeEach(() => {
Object.keys(mockSlots).forEach(k => delete mockSlots[k]);
Object.keys(mockOffers).forEach(k => delete mockOffers[k]);
});
describe('PluginSlot', () => {
it('renders null when no registrations', () => {
mockSlots['toolbar-actions'] = [];
it('renders null when the slot has an empty offer list', () => {
mockOffers['toolbar-actions'] = [];
const { container } = render(
React.createElement(PluginSlot, { name: 'toolbar-actions' })
);
expect(container.innerHTML).toBe('');
});
it('renders null when slot has undefined registrations', () => {
// slot entry doesn't exist at all
it('renders null when the slot has no offers at all', () => {
// slot entry doesn't exist in the registry
const { container } = render(
React.createElement(PluginSlot, { name: 'toolbar-actions' })
);
expect(container.innerHTML).toBe('');
});
it('renders registered components', () => {
const TestComponent = () => React.createElement('span', null, 'Hello Plugin');
mockSlots['email-footer'] = [
{ pluginId: 'test', component: TestComponent, order: 100 },
];
it('renders an iframe slot per offer', () => {
mockOffers['email-footer'] = [{ pluginId: 'test' }];
const { getByText } = render(
React.createElement(PluginSlot, { name: 'email-footer' })
);
expect(getByText('Hello Plugin')).toBeTruthy();
expect(getByText('iframe:email-footer:test')).toBeTruthy();
});
it('sets data-plugin-slot attribute', () => {
const TestComponent = () => React.createElement('span', null, 'x');
mockSlots['sidebar-widget'] = [
{ pluginId: 'sw', component: TestComponent, order: 100 },
];
mockOffers['sidebar-widget'] = [{ pluginId: 'sw' }];
const { container } = render(
React.createElement(PluginSlot, { name: 'sidebar-widget' })
);
+2 -2
View File
@@ -69,8 +69,8 @@ describe('plugin-types constants', () => {
expect(MAX_PLUGIN_SIZE).toBe(5 * 1024 * 1024);
});
it('MAX_THEME_SIZE is 1 MB', () => {
expect(MAX_THEME_SIZE).toBe(1 * 1024 * 1024);
it('MAX_THEME_SIZE is 2 MB', () => {
expect(MAX_THEME_SIZE).toBe(2 * 1024 * 1024);
});
});
+133 -41
View File
@@ -1,6 +1,15 @@
import { describe, it, expect } from 'vitest';
import { expandRecurringEvents } from '../recurrence-expansion';
import type { CalendarEvent } from '@/lib/jmap/types';
import type { CalendarEvent, CalendarRecurrenceRule } from '@/lib/jmap/types';
/**
* Helper: builds a recurrence-rule fixture from only the fields a test cares
* about. Cast-only (no defaults injected) so the expansion logic sees exactly
* the same partial rule the tests previously passed via `as any`.
*/
function rule(partial: Partial<CalendarRecurrenceRule>): CalendarRecurrenceRule {
return partial as CalendarRecurrenceRule;
}
/** Helper: create a minimal CalendarEvent for testing recurrence */
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
@@ -41,7 +50,7 @@ describe('expandRecurringEvents', () => {
describe('daily frequency', () => {
it('expands daily events within range', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-09T00:00:00');
expect(starts(result)).toEqual([
@@ -53,7 +62,7 @@ describe('expandRecurringEvents', () => {
it('respects interval', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', interval: 2 } as any],
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily', interval: 2 })],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-12T00:00:00');
expect(starts(result)).toEqual([
@@ -65,7 +74,7 @@ describe('expandRecurringEvents', () => {
it('respects count', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 3 } as any],
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily', count: 3 })],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-12-31T00:00:00');
expect(result).toHaveLength(3);
@@ -73,7 +82,7 @@ describe('expandRecurringEvents', () => {
it('respects until', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', until: '2025-01-08T09:00:00' } as any],
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily', until: '2025-01-08T09:00:00' })],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-12-31T00:00:00');
expect(result).toHaveLength(3);
@@ -86,7 +95,7 @@ describe('expandRecurringEvents', () => {
describe('weekly frequency', () => {
it('expands weekly with implicit byDay (same weekday as start)', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
});
// Jan 6 is Monday, so every Monday
const result = expand(event, '2025-01-06T00:00:00', '2025-01-28T00:00:00');
@@ -100,11 +109,11 @@ describe('expandRecurringEvents', () => {
it('expands weekly with explicit byDay (MWF)', () => {
const event = makeEvent({
recurrenceRules: [{
recurrenceRules: [rule({
'@type': 'RecurrenceRule',
frequency: 'weekly',
byDay: [{ day: 'mo' }, { day: 'we' }, { day: 'fr' }],
} as any],
})],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-13T00:00:00');
expect(starts(result)).toEqual([
@@ -116,12 +125,12 @@ describe('expandRecurringEvents', () => {
it('expands weekly with interval=2', () => {
const event = makeEvent({
recurrenceRules: [{
recurrenceRules: [rule({
'@type': 'RecurrenceRule',
frequency: 'weekly',
interval: 2,
byDay: [{ day: 'mo' }],
} as any],
})],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-02-03T00:00:00');
expect(starts(result)).toEqual([
@@ -138,7 +147,7 @@ describe('expandRecurringEvents', () => {
it('expands monthly with implicit byMonthDay', () => {
const event = makeEvent({
start: '2025-01-15T10:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'monthly' } as any],
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'monthly' })],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
expect(starts(result)).toEqual([
@@ -151,11 +160,11 @@ describe('expandRecurringEvents', () => {
it('expands monthly with byMonthDay', () => {
const event = makeEvent({
start: '2025-01-01T08:00:00',
recurrenceRules: [{
recurrenceRules: [rule({
'@type': 'RecurrenceRule',
frequency: 'monthly',
byMonthDay: [1, 15],
} as any],
})],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-02-28T00:00:00');
expect(starts(result)).toEqual([
@@ -169,11 +178,11 @@ describe('expandRecurringEvents', () => {
it('expands monthly with negative byMonthDay (-1 = last day)', () => {
const event = makeEvent({
start: '2025-01-31T08:00:00',
recurrenceRules: [{
recurrenceRules: [rule({
'@type': 'RecurrenceRule',
frequency: 'monthly',
byMonthDay: [-1],
} as any],
})],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
@@ -183,11 +192,11 @@ describe('expandRecurringEvents', () => {
it('expands monthly with byDay + nthOfPeriod (2nd Tuesday)', () => {
const event = makeEvent({
start: '2025-01-14T09:00:00', // 2nd Tuesday
recurrenceRules: [{
recurrenceRules: [rule({
'@type': 'RecurrenceRule',
frequency: 'monthly',
byDay: [{ day: 'tu', nthOfPeriod: 2 }],
} as any],
})],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
@@ -197,11 +206,11 @@ describe('expandRecurringEvents', () => {
it('expands monthly with byDay nthOfPeriod=-1 (last Friday)', () => {
const event = makeEvent({
start: '2025-01-31T09:00:00', // last Friday of Jan
recurrenceRules: [{
recurrenceRules: [rule({
'@type': 'RecurrenceRule',
frequency: 'monthly',
byDay: [{ day: 'fr', nthOfPeriod: -1 }],
} as any],
})],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
@@ -216,7 +225,7 @@ describe('expandRecurringEvents', () => {
it('expands yearly on the same date', () => {
const event = makeEvent({
start: '2023-03-15T12:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'yearly' } as any],
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'yearly' })],
});
const result = expand(event, '2023-01-01T00:00:00', '2026-01-01T00:00:00');
expect(starts(result)).toEqual([
@@ -229,12 +238,12 @@ describe('expandRecurringEvents', () => {
it('expands yearly with byMonth and byDay (last Friday of November = Thanksgiving-ish)', () => {
const event = makeEvent({
start: '2025-11-28T09:00:00', // last Friday of Nov 2025
recurrenceRules: [{
recurrenceRules: [rule({
'@type': 'RecurrenceRule',
frequency: 'yearly',
byMonth: ['11'],
byDay: [{ day: 'fr', nthOfPeriod: -1 }],
} as any],
})],
});
const result = expand(event, '2025-01-01T00:00:00', '2028-01-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
@@ -246,12 +255,12 @@ describe('expandRecurringEvents', () => {
const event = makeEvent({
start: '2025-07-04T00:00:00',
showWithoutTime: true,
recurrenceRules: [{
recurrenceRules: [rule({
'@type': 'RecurrenceRule',
frequency: 'yearly',
byMonth: ['7'],
byMonthDay: [4],
} as any],
})],
});
const result = expand(event, '2025-01-01T00:00:00', '2028-01-01T00:00:00');
expect(result).toHaveLength(3);
@@ -265,12 +274,12 @@ describe('expandRecurringEvents', () => {
it('selects first and last from monthly byDay expansion', () => {
const event = makeEvent({
start: '2025-01-06T10:00:00',
recurrenceRules: [{
recurrenceRules: [rule({
'@type': 'RecurrenceRule',
frequency: 'monthly',
byDay: [{ day: 'mo' }, { day: 'tu' }, { day: 'we' }, { day: 'th' }, { day: 'fr' }],
bySetPosition: [1, -1], // first and last weekday of month
} as any],
})],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-03-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
@@ -289,7 +298,7 @@ describe('expandRecurringEvents', () => {
describe('recurrenceOverrides', () => {
it('applies overrides to matching occurrences', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
recurrenceOverrides: {
'2025-01-07T09:00:00': { title: 'Modified' },
},
@@ -301,9 +310,9 @@ describe('expandRecurringEvents', () => {
it('excludes occurrences marked as excluded', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
recurrenceOverrides: {
'2025-01-07T09:00:00': { excluded: true } as any,
'2025-01-07T09:00:00': { excluded: true } as Partial<CalendarEvent> & { excluded?: boolean },
},
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-09T00:00:00');
@@ -313,7 +322,7 @@ describe('expandRecurringEvents', () => {
it('adds RDATE-style overrides not generated by rules', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
recurrenceOverrides: {
'2025-01-08T09:00:00': { title: 'Extra Wednesday' }, // Not a Monday
},
@@ -323,6 +332,89 @@ describe('expandRecurringEvents', () => {
});
});
// -----------------------------------------------------------------------
// excludedRecurrenceRules (EXRULE)
// -----------------------------------------------------------------------
describe('excludedRecurrenceRules', () => {
it('removes occurrences generated by excluded rules', () => {
const event = makeEvent({
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
excludedRecurrenceRules: [rule({
'@type': 'RecurrenceRule',
frequency: 'weekly',
byDay: [{ day: 'tu' }],
})],
});
// Jan 6-12 2025: Mon-Sun, Tuesday Jan 7 excluded
const result = expand(event, '2025-01-06T00:00:00', '2025-01-13T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
expect(days).not.toContain('2025-01-07');
expect(days).toContain('2025-01-06');
expect(days).toContain('2025-01-08');
expect(result).toHaveLength(6);
});
});
// -----------------------------------------------------------------------
// Long-running series
// -----------------------------------------------------------------------
describe('long-running series', () => {
it('expands a daily series started years before the visible range', () => {
const event = makeEvent({
start: '2022-01-03T09:00:00',
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
});
const result = expand(event, '2025-06-02T00:00:00', '2025-06-05T00:00:00');
expect(starts(result)).toEqual([
'2025-06-02T09:00:00',
'2025-06-03T09:00:00',
'2025-06-04T09:00:00',
]);
});
it('expands a weekly series started years before the visible range', () => {
const event = makeEvent({
start: '2020-01-06T09:00:00', // Monday
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
});
const result = expand(event, '2025-06-01T00:00:00', '2025-06-30T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
expect(days).toEqual(['2025-06-02', '2025-06-09', '2025-06-16', '2025-06-23']);
});
it('still respects count for old series (no fast-forward shortcut)', () => {
const event = makeEvent({
start: '2025-01-06T09:00:00',
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 })],
});
// Range far after the 5 occurrences ran out
const result = expand(event, '2025-06-01T00:00:00', '2025-06-30T00:00:00');
expect(result).toHaveLength(0);
});
});
// -----------------------------------------------------------------------
// Event-timezone DST handling
// -----------------------------------------------------------------------
describe('event timezone DST handling', () => {
it('keeps wall time across a DST transition in the event timezone', () => {
// America/New_York: EST (UTC-5) until 2025-03-09, EDT (UTC-4) after
const event = makeEvent({
start: '2025-03-03T10:00:00',
timeZone: 'America/New_York',
utcStart: '2025-03-03T15:00:00Z',
utcEnd: '2025-03-03T16:00:00Z',
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
} as Partial<CalendarEvent>);
const result = expand(event, '2025-03-03T00:00:00', '2025-03-17T00:00:00');
const utcStarts = result.map(e => e.utcStart);
expect(utcStarts[0]).toBe('2025-03-03T15:00:00.000Z'); // EST: 10:00 -5
expect(utcStarts[1]).toBe('2025-03-10T14:00:00.000Z'); // EDT: 10:00 -4
const utcEnds = result.map(e => e.utcEnd);
expect(utcEnds[1]).toBe('2025-03-10T15:00:00.000Z');
});
});
// -----------------------------------------------------------------------
// All-day events
// -----------------------------------------------------------------------
@@ -331,7 +423,7 @@ describe('expandRecurringEvents', () => {
const event = makeEvent({
start: '2025-01-06T00:00:00',
showWithoutTime: true,
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-28T00:00:00');
expect(result).toHaveLength(4); // 4 Mondays: 6, 13, 20, 27
@@ -344,7 +436,7 @@ describe('expandRecurringEvents', () => {
describe('edge cases', () => {
it('does not exceed 500 occurrences', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
});
const result = expand(event, '2025-01-01T00:00:00', '2030-01-01T00:00:00');
expect(result.length).toBeLessThanOrEqual(500);
@@ -353,7 +445,7 @@ describe('expandRecurringEvents', () => {
it('handles invalid start date gracefully', () => {
const event = makeEvent({
start: 'invalid',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-02-01T00:00:00');
expect(result).toHaveLength(0);
@@ -361,7 +453,7 @@ describe('expandRecurringEvents', () => {
it('generates synthetic IDs for occurrences', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
expect(result[0].id).toBe('evt1:2025-01-06T09:00:00');
@@ -370,7 +462,7 @@ describe('expandRecurringEvents', () => {
it('preserves originalId pointing to master', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
expect(result[0].originalId).toBe('evt1');
@@ -382,7 +474,7 @@ describe('expandRecurringEvents', () => {
id: 'master1',
uid: 'shared-uid',
start: '2025-01-06T09:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
});
const override1 = makeEvent({
id: 'override1',
@@ -444,10 +536,10 @@ describe('expandRecurringEvents', () => {
const event = makeEvent({
start: '2026-09-01T12:00:00',
utcStart: '2026-09-01T10:00:00Z',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
});
const result = expand(event, '2026-09-01T00:00:00', '2026-10-01T00:00:00');
const utcStarts = result.map(e => (e as any).utcStart);
const utcStarts = result.map(e => e.utcStart);
// Each occurrence should have a unique utcStart
expect(new Set(utcStarts).size).toBe(result.length);
// First occurrence keeps master's UTC offset relationship
@@ -462,10 +554,10 @@ describe('expandRecurringEvents', () => {
duration: 'PT1H',
utcStart: '2026-09-01T10:00:00Z',
utcEnd: '2026-09-01T11:00:00Z',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
});
const result = expand(event, '2026-09-01T00:00:00', '2026-10-01T00:00:00');
const utcEnds = result.map(e => (e as any).utcEnd);
const utcEnds = result.map(e => e.utcEnd);
// Each occurrence should have a unique utcEnd
expect(new Set(utcEnds).size).toBe(result.length);
expect(utcEnds[0]).toContain('2026-09-01');
@@ -476,7 +568,7 @@ describe('expandRecurringEvents', () => {
it('does not set utcStart when master has none', () => {
const event = makeEvent({
start: '2025-01-06T09:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
expect(result[0].utcStart).toBeUndefined();
+22
View File
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import {
SESSION_COOKIE,
SESSION_COOKIE_MAX_AGE,
sessionCookieName,
} from '@/lib/auth/session-cookie';
describe('session-cookie', () => {
it('exposes the legacy cookie name and 30-day max-age', () => {
expect(SESSION_COOKIE).toBe('jmap_session');
expect(SESSION_COOKIE_MAX_AGE).toBe(2592000); // 30 * 24 * 60 * 60
});
it('uses the bare legacy name for slot 0 (no suffix)', () => {
expect(sessionCookieName(0)).toBe('jmap_session');
});
it('suffixes the slot number for slots > 0', () => {
expect(sessionCookieName(1)).toBe('jmap_session_1');
expect(sessionCookieName(49)).toBe('jmap_session_49');
});
});

Some files were not shown because too many files have changed in this diff Show More