Compare commits

...
64 Commits
Author SHA1 Message Date
Linus Rath 2f5b133000 chore: update version to 1.7.6 2026-06-28 20:47:39 +02:00
Linus Rath 1f21a5213f fix: hide server scheduled folder when virtual one is shown #495 2026-06-28 20:36:32 +02:00
Linus Rath d4c066622b i18n: add missing translation keys across 19 locales 2026-06-28 20:35:23 +02:00
Linus Rath e141abc849 feat: add option to hide total message count on folders (#498) 2026-06-28 20:31:35 +02:00
Linus Rath 1f4afe082b fix: show all built-in themes in admin theme controls #496 2026-06-28 20:31:00 +02:00
Linus Rath e0747c12ee fix: send calendar invites by setting organizerCalendarAddress 2026-06-28 20:27:23 +02:00
Linus Rath f90cd6abc4 fix: sync default identity (preferredPrimaryId) to server settings #507 2026-06-28 20:12:54 +02:00
Linus Rath 63e087f3ef fix: support MFA login via structured auth endpoint 2026-06-28 19:53:08 +02:00
Linus Rath f8b8e0b108 refactor: move S/MIME to generic crypto plugin hooks 2026-06-28 19:13:02 +02:00
Linus Rath 512adab7e3 feat: add privileged same-origin plugin tier + crypto API surface 2026-06-28 16:51:42 +02:00
Linus Rath 4cdc15fc3c chore: update version to 1.7.6 2026-06-25 01:06:51 +02:00
Linus Rath 5e67671f57 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-25 01:05:00 +02:00
Linus Rath 155d99a069 feat: add plugin hooks for email details, headers, and source 2026-06-25 01:04:26 +02:00
Stefan HildebrandtandLinus Rath d863b1fd4b fix: HTML-escape sender/subject in reply/forward quote header (#482)
The forward quote header renders "From: Name <email>", but the HTML variant
interpolated the sender string unescaped. In the rich-text composer the
"<email>" portion is parsed by the browser as a bogus HTML tag and dropped, so
the address silently disappears - the user sees only "From: Display Name". The
plain-text variant and the details panel escape correctly, which is why the
address shows there. This is the regression from #367, which added the
"<email>" into the HTML string without escaping it.

Fix: HTML-escape the user-controlled values (sender, subject, date) in every
HTML quote-header path - the production builder in lib/quote-header.ts and the
composer's inline fallback (both htmlBody and plain-body branches), for forward
and reply. The reply line keeps the bare display name by design (#367), but its
HTML form is now escaped too so a display name containing markup can't break
out. As a side benefit this closes an HTML-injection vector: a crafted subject
or display name was previously injected raw into the composer document.

Adds lib/__tests__/quote-header.test.ts covering: forward text keeps
"Name <email>"; forward HTML escapes the angle brackets (address survives) and
a markup subject/display name; reply stays bare-name and HTML-safe.
2026-06-25 00:25:28 +02:00
Stefan HildebrandtandLinus Rath 70aaf0aac1 fix: stop unified-mailbox from mutating client-returned email objects
fetchUnifiedEmails, fanOutUnifiedQuery and the cross-account fanOutCrossQuery
stamped accountId/accountLabel/source* directly onto each email object
returned by the per-account client. Those objects are shared references;
mutating them in place could surprise any caller that retained them (and
corrupt an account-state snapshot). Decorate shallow copies instead, at all
three fan-out sites.

The original fix/unified-mailbox-no-mutation branch predated the cross-account
"All accounts" feature and only covered two sites; this re-applies the fix to
main's current code, including the third (shared/group) fan-out site, and
preserves all five stamped fields. Flips the characterisation test to assert
the client's object is left untouched.
2026-06-25 00:25:00 +02:00
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
173 changed files with 13173 additions and 8322 deletions
+5
View File
@@ -49,6 +49,11 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# OpenID Connect issuer URL for discovery # OpenID Connect issuer URL for discovery
# OAUTH_ISSUER_URL=https://your-idp.example.com # 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. # 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 # 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. # OAuth issuer's public hostname resolves to an internal IP from this server.
+66
View File
@@ -1,5 +1,71 @@
# Changelog # Changelog
## 1.7.6 (2026-06-28)
### Breaking Changes
- **S/MIME**: The built-in S/MIME implementation has been removed from core and re-delivered through the new generic crypto plugin hooks (privileged same-origin plugin tier). S/MIME signing, encryption, decryption, certificate management, and the related settings UI now live in a plugin rather than the main app. Deployments that relied on built-in S/MIME must install the S/MIME crypto plugin to retain those features.
### Features
- **Plugins**: Privileged same-origin plugin tier with a crypto API surface
- **Plugins**: Plugin hooks for email details, headers, and source
- **Mail**: Option to hide the total message count on folders (#498)
### Fixes
- **Mail**: Hide the server scheduled folder when the virtual one is shown (#495)
- **Mail**: Stop the unified mailbox from mutating client-returned email objects
- **Composer**: HTML-escape sender and subject in the reply/forward quote header (#482)
- **Calendar**: Send calendar invites by setting `organizerCalendarAddress`
- **Identity**: Sync the default identity (`preferredPrimaryId`) to server settings (#507)
- **Auth**: Support MFA login via the structured auth endpoint
- **Admin**: Show all built-in themes in the admin theme controls (#496)
- **i18n**: Add missing translation keys across 19 locales
## 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) ## 1.7.4 (2026-06-15)
### Features ### Features
+6 -2
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) - 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 - 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 - 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 - 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 - 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
@@ -103,7 +105,7 @@
## Internationalization ## 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`. Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
@@ -115,6 +117,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
- Configurable signature position (above or below quoted text) - Configurable signature position (above or below quoted text)
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions - Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
- Shared folders across accounts - 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 - Multiple JMAP servers per deployment with optional auto-pick by email domain
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`) - Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
@@ -122,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 - 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 - 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) - 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 - 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`) - Admin toggle for search-engine indexing (`robots.txt` / `noindex`)
+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) [![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) [![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.4-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-1.7.6-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) [![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/) [![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: 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 - **Calendar** month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
- **Contacts** multiple address books, groups, vCard import/export - **Contacts** multiple address books, groups, vCard import/export
- **Files** Stalwart's JMAP FileNode storage with previews and folder upload - **Files** Stalwart's JMAP FileNode storage with previews and folder upload
+1 -1
View File
@@ -1 +1 @@
1.7.4 1.7.6
+7 -1
View File
@@ -16,6 +16,7 @@ import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useIdentityStore } from "@/stores/identity-store"; import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
import { usePolicyStore } from "@/stores/policy-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query"; import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -97,6 +98,7 @@ export default function CalendarPage() {
removeCalendar, clearCalendarEvents, removeCalendar, clearCalendarEvents,
refreshAllSubscriptions, icalSubscriptions, refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore(); } = useCalendarStore();
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore(); const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors); const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor); const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
@@ -179,10 +181,13 @@ export default function CalendarPage() {
if (initialCheckDone && !isAuthenticated && !authLoading) { if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
redirectToLogin(); 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()) { } else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) {
router.push("/"); router.push("/");
} }
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]); }, [initialCheckDone, isAuthenticated, authLoading, client, calendarEnabled, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
useEffect(() => { useEffect(() => {
if (error) { if (error) {
@@ -1164,6 +1169,7 @@ export default function CalendarPage() {
) : null; ) : null;
if (!isAuthenticated) return null; if (!isAuthenticated) return null;
if (!calendarEnabled) return null;
if (!supportsCalendar) return renderWebcalAccountPicker(); if (!supportsCalendar) return renderWebcalAccountPicker();
const renderView = () => { const renderView = () => {
+95 -1
View File
@@ -18,7 +18,9 @@ import { ContactImportDialog } from "@/components/contacts/contact-import-dialog
import { RenameDialog } from "@/components/files/rename-dialog"; import { RenameDialog } from "@/components/files/rename-dialog";
import { exportContacts } from "@/components/contacts/contact-export"; import { exportContacts } from "@/components/contacts/contact-export";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; 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 { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { usePolicyStore } from "@/stores/policy-store"; import { usePolicyStore } from "@/stores/policy-store";
@@ -82,6 +84,7 @@ export default function ContactsPage() {
bulkDeleteContacts, bulkDeleteContacts,
bulkAddToGroup, bulkAddToGroup,
moveContactToAddressBook, moveContactToAddressBook,
createAddressBook,
renameAddressBook, renameAddressBook,
removeAddressBook, removeAddressBook,
shareAddressBook, shareAddressBook,
@@ -93,6 +96,7 @@ export default function ContactsPage() {
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all"); const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
const [showImportDialog, setShowImportDialog] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false);
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null); const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
const [creatingAddressBook, setCreatingAddressBook] = useState(false);
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null); const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined); const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined); const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
@@ -298,6 +302,34 @@ export default function ContactsPage() {
} }
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]); }, [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[]) => { const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
return importContacts( return importContacts(
supportsSync && client ? client : null, supportsSync && client ? client : null,
@@ -461,6 +493,51 @@ export default function ContactsPage() {
setView("group-edit"); 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 handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
const confirmed = await confirmDialog({ const confirmed = await confirmDialog({
title: t("groups.delete_confirm_title"), title: t("groups.delete_confirm_title"),
@@ -625,6 +702,7 @@ export default function ContactsPage() {
onEdit={handleEditGroup} onEdit={handleEditGroup}
onDelete={handleDeleteGroup} onDelete={handleDeleteGroup}
onRemoveMember={handleRemoveGroupMember} onRemoveMember={handleRemoveGroupMember}
onComposeGroup={(field) => handleComposeGroupFromSidebar(selectedGroup.id, field)}
isMobile={isMobile} isMobile={isMobile}
onSelectMember={(id) => { onSelectMember={(id) => {
setSelectedContact(id); setSelectedContact(id);
@@ -702,6 +780,11 @@ export default function ContactsPage() {
contact={selectedContact} contact={selectedContact}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
onCompose={
selectedContact
? () => handleComposeContact(selectedContact)
: undefined
}
onAddToGroup={ onAddToGroup={
selectedContact selectedContact
? () => handleAddContactToGroup(selectedContact.id) ? () => handleAddContactToGroup(selectedContact.id)
@@ -804,9 +887,11 @@ export default function ContactsPage() {
onSelectCategory={handleSelectCategory} onSelectCategory={handleSelectCategory}
onCreateGroup={handleCreateGroup} onCreateGroup={handleCreateGroup}
onCreateContact={handleCreateNew} onCreateContact={handleCreateNew}
onCreateAddressBook={client ? () => setCreatingAddressBook(true) : undefined}
onImport={() => setShowImportDialog(true)} onImport={() => setShowImportDialog(true)}
onEditGroup={handleEditGroupFromSidebar} onEditGroup={handleEditGroupFromSidebar}
onDeleteGroup={handleDeleteGroupFromSidebar} onDeleteGroup={handleDeleteGroupFromSidebar}
onComposeGroup={handleComposeGroupFromSidebar}
onDropContacts={handleDropContacts} onDropContacts={handleDropContacts}
onDropContactsToCategory={handleDropContactsToCategory} onDropContactsToCategory={handleDropContactsToCategory}
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined} onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
@@ -952,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 && ( {renamingAddressBook && (
<RenameDialog <RenameDialog
currentName={renamingAddressBook.name} currentName={renamingAddressBook.name}
+166 -50
View File
@@ -11,7 +11,7 @@ import type { ComposerDraftData } from "@/components/email/email-composer";
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker"; import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
import { ThreadConversationView } from "@/components/email/thread-conversation-view"; import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader } from "@/components/layout/mobile-header"; import { MobileHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, ALL_MAIL_MAILBOX_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 { useAccountStore } from "@/stores/account-store";
import { usePolicyStore } from "@/stores/policy-store"; import { usePolicyStore } from "@/stores/policy-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
@@ -32,6 +32,7 @@ import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navi
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound"; import { playNotificationSound } from "@/lib/notification-sound";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { import {
ErrorBoundary, ErrorBoundary,
SidebarErrorFallback, SidebarErrorFallback,
@@ -319,7 +320,10 @@ export default function Home() {
clearPendingUndoSend, clearPendingUndoSend,
pendingUndoSend, pendingUndoSend,
fetchUnifiedEmails: fetchUnifiedEmailsAction, fetchUnifiedEmails: fetchUnifiedEmailsAction,
fetchCrossView: fetchCrossViewAction,
refreshUnifiedCounts, refreshUnifiedCounts,
refreshCrossCounts,
crossUnreadCount,
exitUnifiedView, exitUnifiedView,
emptyMailbox, emptyMailbox,
markMailboxAsRead, markMailboxAsRead,
@@ -347,6 +351,19 @@ export default function Home() {
const delayedSendSupported = client?.hasDelayedSend() ?? true; const delayedSendSupported = client?.hasDelayedSend() ?? true;
const allMailViewEnabled = usePolicyStore((s) => s.isFeatureEnabled('allMailViewEnabled')); const allMailViewEnabled = usePolicyStore((s) => s.isFeatureEnabled('allMailViewEnabled'));
const showAllMailMailbox = allMailViewEnabled && enableAllMailView; 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 activeEmails = isScheduledView ? scheduledEmails : emails;
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails; const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading; const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
@@ -718,7 +735,7 @@ export default function Home() {
// Mailbox view // Mailbox view
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
if (mailbox) { if (mailbox) {
const mailboxName = mailbox.name; const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`));
const unreadCount = mailbox.unreadEmails || 0; const unreadCount = mailbox.unreadEmails || 0;
title = unreadCount > 0 title = unreadCount > 0
? `${mailboxName} (${unreadCount}) - ${appName}` ? `${mailboxName} (${unreadCount}) - ${appName}`
@@ -986,14 +1003,16 @@ export default function Home() {
// recounting happened"). The Pro shell always renders the unified mailbox // recounting happened"). The Pro shell always renders the unified mailbox
// regardless of the user setting, so refresh when embedded too. // regardless of the user setting, so refresh when embedded too.
useEffect(() => { useEffect(() => {
if (!enableUnifiedMailbox && !isEmbedded) return; const anyCross = showCrossUnread || showCrossStarred || showCrossAll;
if (!enableUnifiedMailbox && !isEmbedded && !anyCross) return;
if (!isAuthenticated || !client) return; if (!isAuthenticated || !client) return;
buildPopulatedUnifiedAccounts().then((built) => { buildPopulatedUnifiedAccounts().then((built) => {
const hasGroupEntry = built.some((b) => b.isShared); const hasGroupEntry = built.some((b) => b.isShared);
if (anyCross) refreshCrossCounts(built);
if (built.length < 2 && !hasGroupEntry && !isEmbedded) return; if (built.length < 2 && !hasGroupEntry && !isEmbedded) return;
refreshUnifiedCounts(built); 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 // System-notification click handler. The push SW navigates the user back
// here with `?email=<id>` (specific email it built the toast from) or // here with `?email=<id>` (specific email it built the toast from) or
@@ -1039,8 +1058,8 @@ export default function Home() {
// Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before // Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before
// calling selectEmail on the stub), to avoid a duplicate request. // calling selectEmail on the stub), to avoid a duplicate request.
if (!selectedEmail.bodyValues && !isLoadingEmail) { if (!selectedEmail.bodyValues && !isLoadingEmail) {
const perAccountClient = isUnifiedView && selectedEmail.accountId const perAccountClient = isUnifiedView && selectedEmail.sourceClientAccountId
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId) ? useAuthStore.getState().getClientForAccount(selectedEmail.sourceClientAccountId)
: undefined; : undefined;
const fetchClient = perAccountClient ?? client; const fetchClient = perAccountClient ?? client;
setLoadingEmail(true); setLoadingEmail(true);
@@ -1177,8 +1196,15 @@ export default function Home() {
const emailState = useEmailStore.getState(); const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId); const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) { if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
const accountId = client.getAccountId(); // Route to the email's own account so shared/group threads refresh
const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId); // 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) { if (fullEmails.length > 0) {
useEmailStore.setState((state) => { useEmailStore.setState((state) => {
const c = new Map(state.threadEmailsCache); const c = new Map(state.threadEmailsCache);
@@ -1414,10 +1440,11 @@ export default function Home() {
if (!client || !emailToDelete) return; if (!client || !emailToDelete) return;
// In unified view the trash destination and current-folder check must come // 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 = const actionMailboxes =
isUnifiedView && emailToDelete.accountId isUnifiedView && emailToDelete.sourceAccountId
? (accountMailboxes[emailToDelete.accountId] ?? mailboxes) ? (accountMailboxes[emailToDelete.sourceAccountId] ?? mailboxes)
: mailboxes; : mailboxes;
// Check if we're currently in the trash or junk folder. In unified view the // Check if we're currently in the trash or junk folder. In unified view the
@@ -1446,11 +1473,17 @@ export default function Home() {
console.error("Failed to permanently delete email:", error); console.error("Failed to permanently delete email:", error);
} }
} else { } 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 = const trashMailbox =
actionMailboxes.find(m => m.role === 'trash' && !m.isShared) ?? actionMailboxes.find(m => m.role === 'trash' && matchesScope(m)) ??
actionMailboxes.find(m => { actionMailboxes.find(m => {
if (m.isShared) return false; if (!matchesScope(m)) return false;
const lower = m.name.toLowerCase(); const lower = m.name.toLowerCase();
return lower.includes('trash') || lower.includes('deleted'); return lower.includes('trash') || lower.includes('deleted');
}); });
@@ -1473,11 +1506,14 @@ export default function Home() {
if (!client || !emailToArchive) return; if (!client || !emailToArchive) return;
// In unified view the archive folder (and any year/month subfolders we // In unified view the archive folder (and any year/month subfolders we
// create) must live in the email's own account, reached through that // create) must live in the email's own account, reached through the login it
// account's client. (#281) // is reachable via (`sourceClientAccountId`) and routed to its owning JMAP
const archiveAccountId = isUnifiedView ? emailToArchive.accountId : undefined; // account (`sourceAccountId`). For personal sources these resolve to the
const archiveClient = archiveAccountId // account itself, so behavior is unchanged. (#281)
? (useAuthStore.getState().getClientForAccount(archiveAccountId) ?? client) const archiveClientId = isUnifiedView ? emailToArchive.sourceClientAccountId : undefined;
const archiveAccountId = isUnifiedView ? emailToArchive.sourceAccountId : undefined;
const archiveClient = archiveClientId
? (useAuthStore.getState().getClientForAccount(archiveClientId) ?? client)
: client; : client;
// Read fresh mailboxes from the store batch archive calls this in a loop, // Read fresh mailboxes from the store batch archive calls this in a loop,
// and each iteration needs to see folders created by prior iterations. // and each iteration needs to see folders created by prior iterations.
@@ -1512,7 +1548,7 @@ export default function Home() {
m => m.name === year && m.parentId === archiveId m => m.name === year && m.parentId === archiveId
); );
if (!yearMailbox) { if (!yearMailbox) {
yearMailbox = await archiveClient.createMailbox(year, archiveId); yearMailbox = await archiveClient.createMailbox(year, archiveId, archiveAccountId);
await refreshMailboxes(); await refreshMailboxes();
} }
@@ -1525,7 +1561,7 @@ export default function Home() {
m => m.name === month && m.parentId === yearId m => m.name === month && m.parentId === yearId
); );
if (!monthMailbox) { if (!monthMailbox) {
monthMailbox = await archiveClient.createMailbox(month, yearId); monthMailbox = await archiveClient.createMailbox(month, yearId, archiveAccountId);
await refreshMailboxes(); await refreshMailboxes();
} }
await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id); await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id);
@@ -1618,7 +1654,7 @@ export default function Home() {
}); });
} else { } else {
const jmapKey = `$label:${color}`; const jmapKey = `$label:${color}`;
if (keywords[jmapKey] === true) { if (keywords[jmapKey]) {
// Toggle off if already active // Toggle off if already active
keywords[jmapKey] = false; keywords[jmapKey] = false;
} else { } else {
@@ -1714,6 +1750,28 @@ export default function Home() {
return; 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) { if (isUnifiedView) {
exitUnifiedView(); exitUnifiedView();
} }
@@ -2227,8 +2285,15 @@ export default function Home() {
const emailState = useEmailStore.getState(); const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId); const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) { if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
const accountId = client.getAccountId(); // Route to the email's own account so shared/group threads refresh from
const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId); // 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) { if (fullEmails.length > 0) {
useEmailStore.setState((state) => { useEmailStore.setState((state) => {
const c = new Map(state.threadEmailsCache); const c = new Map(state.threadEmailsCache);
@@ -2256,7 +2321,12 @@ export default function Home() {
? t('sidebar.scheduled') ? t('sidebar.scheduled')
: selectedMailbox === ALL_MAIL_MAILBOX_ID : selectedMailbox === ALL_MAIL_MAILBOX_ID
? t('sidebar.mailboxes.all_mail') ? t('sidebar.mailboxes.all_mail')
: mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox"; : (() => {
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 isFocusedMailLayout = mailLayout === 'focus';
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet; const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail); const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
@@ -2273,11 +2343,10 @@ export default function Home() {
setShowComposer(false); 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); const listEmail = activeEmails.find(e => e.id === email.id);
if (listEmail) {
selectEmail(listEmail);
}
setLoadingEmail(true); setLoadingEmail(true);
@@ -2293,21 +2362,25 @@ export default function Home() {
// Fetch the full content // Fetch the full content
try { try {
// In unified view each email carries its own accountId. Use that // In unified view each email carries its source reference: the login it is
// account's client so we fetch from the server that actually owns it. // reachable through (`sourceClientAccountId`) and its owning JMAP account
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined; // (`sourceAccountId`). Resolve both so we fetch from the server that actually
const perAccountClient = emailAccountId // owns it — works uniformly for personal and shared/group sources, since for
? useAuthStore.getState().getClientForAccount(emailAccountId) // 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; : undefined;
const fetchClient = perAccountClient ?? client; const fetchClient = perAccountClient ?? client;
// For shared folders on the primary client, we still need to pass the const accountId = isUnifiedView
// shared account's id. In unified view we use the per-account client ? listEmail?.sourceAccountId
// directly, so no explicit accountId is needed. : (() => {
// Non-unified: shared folders on the active client still need their
// owner accountId passed explicitly.
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = perAccountClient return mailbox?.isShared ? mailbox.accountId : undefined;
? undefined })();
: mailbox?.isShared ? mailbox.accountId : undefined;
const fullEmail = await fetchClient.getEmail(email.id, accountId); const fullEmail = await fetchClient.getEmail(email.id, accountId);
if (fullEmail) { if (fullEmail) {
@@ -2319,9 +2392,13 @@ export default function Home() {
fullEmail.isScheduled = true; fullEmail.isScheduled = true;
fullEmail.isSmimeScheduled = listEmail.isSmimeScheduled; fullEmail.isSmimeScheduled = listEmail.isSmimeScheduled;
} }
if (emailAccountId) { // Re-stamp the source reference so later actions on the open email
fullEmail.accountId = emailAccountId; // resolve to the right account (the fetched object lacks these).
fullEmail.accountLabel = listEmail?.accountLabel; if (isUnifiedView && listEmail) {
fullEmail.accountId = listEmail.accountId;
fullEmail.accountLabel = listEmail.accountLabel;
fullEmail.sourceClientAccountId = listEmail.sourceClientAccountId;
fullEmail.sourceAccountId = listEmail.sourceAccountId;
} }
selectEmail(fullEmail); selectEmail(fullEmail);
// Mark-as-read logic is now handled by useEffect // Mark-as-read logic is now handled by useEffect
@@ -2370,8 +2447,27 @@ export default function Home() {
setActiveView("viewer"); setActiveView("viewer");
try { try {
// Fetch complete thread emails // In unified/aggregate views the thread may belong to another (possibly
const emails = await client.getThreadEmails(thread.threadId); // 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); setConversationEmails(emails);
} catch (error) { } catch (error) {
console.error('Failed to fetch thread emails:', error); console.error('Failed to fetch thread emails:', error);
@@ -2519,6 +2615,10 @@ export default function Home() {
scheduledTotal={scheduledTotal} scheduledTotal={scheduledTotal}
showScheduledMailbox={delayedSendSupported} showScheduledMailbox={delayedSendSupported}
showAllMailMailbox={showAllMailMailbox} showAllMailMailbox={showAllMailMailbox}
showCrossUnread={showCrossUnread}
showCrossStarred={showCrossStarred}
showCrossAll={showCrossAll}
crossUnreadCount={crossUnreadCount}
onMailboxSelect={handleMailboxSelect} onMailboxSelect={handleMailboxSelect}
onTagSelect={handleTagSelect} onTagSelect={handleTagSelect}
onUnreadFilterClick={handleUnreadFilterClick} onUnreadFilterClick={handleUnreadFilterClick}
@@ -2686,19 +2786,19 @@ export default function Home() {
icon={<Paperclip className="w-3.5 h-3.5" />} icon={<Paperclip className="w-3.5 h-3.5" />}
label={t("advanced_search.has_attachment")} label={t("advanced_search.has_attachment")}
value={searchFilters.hasAttachment} 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 <ToggleChip
icon={<Star className="w-3.5 h-3.5" />} icon={<Star className="w-3.5 h-3.5" />}
label={t("advanced_search.starred")} label={t("advanced_search.starred")}
value={searchFilters.isStarred} 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 <ToggleChip
icon={searchFilters.isUnread === false ? <MailOpen className="w-3.5 h-3.5" /> : <Mail className="w-3.5 h-3.5" />} 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")} label={searchFilters.isUnread === false ? t("advanced_search.read") : t("advanced_search.unread")}
value={searchFilters.isUnread} 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>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -3100,7 +3200,17 @@ export default function Home() {
onReply={handleReply} onReply={handleReply}
onReplyAll={handleReplyAll} onReplyAll={handleReplyAll}
onForward={handleForward} 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()} onArchive={() => handleArchive()}
onToggleStar={handleToggleStar} onToggleStar={handleToggleStar}
onSetColorTag={handleSetColorTag} onSetColorTag={handleSetColorTag}
@@ -3109,6 +3219,12 @@ export default function Home() {
onMarkAsRead={async (emailId, read) => { onMarkAsRead={async (emailId, read) => {
if (client) { if (client) {
await markAsRead(client, emailId, read); 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} onDownloadAttachment={handleDownloadAttachment}
@@ -3143,7 +3259,7 @@ export default function Home() {
}} }}
currentUserEmail={client?.getUsername()} currentUserEmail={client?.getUsername()}
currentUserName={client?.getUsername()?.split("@")[0]} 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} mailboxes={mailboxes}
selectedMailbox={selectedMailbox} selectedMailbox={selectedMailbox}
onMoveToMailbox={async (mailboxId) => { onMoveToMailbox={async (mailboxId) => {
+2 -10
View File
@@ -21,7 +21,6 @@ import {
Tags, Tags,
HardDrive, HardDrive,
BookUser, BookUser,
KeyRound,
PanelLeftClose, PanelLeftClose,
Bell, Bell,
Puzzle, Puzzle,
@@ -63,7 +62,6 @@ import { AccountSecuritySettings } from '@/components/settings/account-security-
import { FilesSettingsComponent } from '@/components/settings/files-settings'; import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { DownloadsSettings } from '@/components/settings/downloads-settings'; import { DownloadsSettings } from '@/components/settings/downloads-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings'; import { ContactsSettings } from '@/components/settings/contacts-settings';
import { SmimeSettings } from '@/components/settings/smime-settings';
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings'; import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
import { NotificationSettings } from '@/components/settings/notification-settings'; import { NotificationSettings } from '@/components/settings/notification-settings';
import { ThemesSettings } from '@/components/settings/themes-settings'; import { ThemesSettings } from '@/components/settings/themes-settings';
@@ -102,7 +100,6 @@ type Tab =
| 'folders' | 'folders'
| 'keywords' | 'keywords'
| 'security' | 'security'
| 'encryption'
| 'content_senders' | 'content_senders'
| 'calendar' | 'calendar'
| 'contacts' | 'contacts'
@@ -139,7 +136,6 @@ const tabIcons: Record<Tab, LucideIcon> = {
folders: FolderOpen, folders: FolderOpen,
keywords: Tags, keywords: Tags,
security: Shield, security: Shield,
encryption: KeyRound,
content_senders: EyeOff, content_senders: EyeOff,
calendar: Calendar, calendar: Calendar,
contacts: BookUser, contacts: BookUser,
@@ -217,7 +213,6 @@ const tabSearchPaths: Record<Tab, string[]> = {
folders: ['settings.folders'], folders: ['settings.folders'],
keywords: ['settings.keywords'], keywords: ['settings.keywords'],
security: ['settings.security'], security: ['settings.security'],
encryption: ['smime'],
content_senders: [ content_senders: [
'settings.email_behavior.always_light_mode', 'settings.email_behavior.always_light_mode',
'settings.email_behavior.external_content', 'settings.email_behavior.external_content',
@@ -252,7 +247,6 @@ const tabKeywords: Record<Tab, string> = {
folders: 'mailbox subscribe', folders: 'mailbox subscribe',
keywords: 'tags labels colors', keywords: 'tags labels colors',
security: 'password 2fa two-factor passkey app password mfa', security: 'password 2fa two-factor passkey app password mfa',
encryption: 's/mime smime certificate pgp gpg',
content_senders: 'block sender remote images privacy tracking', content_senders: 'block sender remote images privacy tracking',
calendar: 'event schedule appointment meeting timezone', calendar: 'event schedule appointment meeting timezone',
contacts: 'address book contact', contacts: 'address book contact',
@@ -623,11 +617,10 @@ export default function SettingsPage() {
// Privacy & Security // Privacy & Security
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []), ...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []),
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' }, { id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
// Apps // 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 }] : []), ...(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 }] : []), ...(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 }] : []), ...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
@@ -646,7 +639,7 @@ export default function SettingsPage() {
? ([ ? ([
managedAccount.capabilities.sieve && supportsSieve ? 'filters' : null, managedAccount.capabilities.sieve && supportsSieve ? 'filters' : null,
managedAccount.capabilities.mail && supportsVacation ? 'vacation' : null, managedAccount.capabilities.mail && supportsVacation ? 'vacation' : null,
managedAccount.capabilities.calendars && supportsCalendar ? 'calendar' : null, managedAccount.capabilities.calendars && supportsCalendar && isFeatureEnabled('calendarEnabled') ? 'calendar' : null,
managedAccount.capabilities.contacts && isFeatureEnabled('contactsEnabled') ? 'contacts' : null, managedAccount.capabilities.contacts && isFeatureEnabled('contactsEnabled') ? 'contacts' : null,
].filter(Boolean) as Tab[]) ].filter(Boolean) as Tab[])
: []; : [];
@@ -743,7 +736,6 @@ export default function SettingsPage() {
{effectiveActiveTab === 'folders' && <FolderSettings />} {effectiveActiveTab === 'folders' && <FolderSettings />}
{effectiveActiveTab === 'keywords' && <KeywordSettings />} {effectiveActiveTab === 'keywords' && <KeywordSettings />}
{effectiveActiveTab === 'security' && <AccountSecuritySettings />} {effectiveActiveTab === 'security' && <AccountSecuritySettings />}
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />} {effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
{effectiveActiveTab === 'calendar' && ( {effectiveActiveTab === 'calendar' && (
managedAccountId managedAccountId
+5 -1
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' }, 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' }, customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' }, 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' }, calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' }, contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' }, smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
@@ -21,7 +22,10 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' }, folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' }, 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.' }, 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.' }, 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 = [ const RESTRICTABLE_SETTINGS = [
+4 -5
View File
@@ -5,12 +5,11 @@ import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock,
import type { SettingsPolicy } from '@/lib/admin/types'; import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types'; import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
const BUILTIN_THEME_OPTIONS = [ // Derive from the single source of truth so newly added built-in themes show
{ id: 'builtin-nord', name: 'Nord' }, // up here automatically (was previously a hardcoded subset — see #496).
{ id: 'builtin-catppuccin', name: 'Catppuccin' }, const BUILTIN_THEME_OPTIONS = BUILTIN_THEMES.map(t => ({ id: t.id, name: t.name }));
{ id: 'builtin-solarized', name: 'Solarized' },
];
interface ThemeEntry { interface ThemeEntry {
id: string; id: string;
@@ -0,0 +1,15 @@
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
// Privileged-tier sandbox route. Identical runtime to /plugin-sandbox, but the
// host loads it into a same-origin (`allow-same-origin`) iframe so the bundle
// gets real `crypto.subtle` + IndexedDB. The trust gate (signature + admin
// approval) is enforced host-side before this route is ever framed; the page
// itself carries no extra privilege.
//
// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in
// Next's injected hydration/chunk scripts.
export const dynamic = 'force-dynamic';
export default function PrivilegedPluginSandboxPage() {
return <SandboxRuntime />;
}
+1 -1
View File
@@ -19,7 +19,7 @@ import {
invalidateFrameOriginsCache, invalidateFrameOriginsCache,
} from '@/lib/admin/csp-frame-origins'; } from '@/lib/admin/csp-frame-origins';
import JSZip from 'jszip'; 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 { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
+1
View File
@@ -183,6 +183,7 @@ export async function POST(request: NextRequest) {
author: manifest.author as string, author: manifest.author as string,
description: (manifest.description as string) || '', description: (manifest.description as string) || '',
type: manifest.type as string, type: manifest.type as string,
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
permissions: (manifest.permissions as string[]) || [], permissions: (manifest.permissions as string[]) || [],
entrypoint: manifest.entrypoint as string, entrypoint: manifest.entrypoint as string,
enabled: true, enabled: true,
+7 -2
View File
@@ -8,6 +8,7 @@ import { discoverOAuth } from '@/lib/oauth/discovery';
import { getOauthScopes } from '@/lib/oauth/tokens'; import { getOauthScopes } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { hasSessionSecret } from '@/lib/auth/session-secret'; import { hasSessionSecret } from '@/lib/auth/session-secret';
import { configManager } from '@/lib/admin/config-manager';
const SSO_PENDING_COOKIE = 'sso_pending'; const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE = 300; // 5 minutes const SSO_PENDING_MAX_AGE = 300; // 5 minutes
@@ -102,8 +103,12 @@ export async function POST(request: NextRequest) {
maxAge: SSO_PENDING_MAX_AGE, maxAge: SSO_PENDING_MAX_AGE,
}); });
// Build authorize URL // Build authorize URL. OAUTH_AUTHORIZE_URL, when set, overrides only the
const authUrl = new URL(metadata.authorization_endpoint); // 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('response_type', 'code');
authUrl.searchParams.set('client_id', clientId); authUrl.searchParams.set('client_id', clientId);
authUrl.searchParams.set('redirect_uri', redirect_uri); authUrl.searchParams.set('redirect_uri', redirect_uri);
+161 -151
View File
@@ -1,8 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { getDiscoveryValidator } from '@/lib/oauth/token-exchange';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens'; import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env'; import { readFileEnv } from '@/lib/read-file-env';
@@ -11,81 +9,173 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker'; import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers'; import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { generateCodeVerifier, generateCodeChallenge } from '@/lib/oauth/pkce';
/** /**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens. * Exchange a password + (optional) TOTP code for OAuth tokens.
* *
* This allows 2FA users who log in with basic auth + TOTP to upgrade * Stalwart 0.16+ no longer accepts the legacy `password$totp` convention over
* to token-based auth, avoiding session expiry when the TOTP rotates. * HTTP Basic auth: its Basic decoder hardcodes `mfa_token: None` and never
* splits the secret on `$`, so any TOTP appended to the password is verified
* verbatim against the password hash and fails. The MFA token must instead be
* supplied as a distinct field through the structured login endpoint.
* *
* Tries three strategies: * This route drives that flow server-side (avoiding browser CORS against the
* 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set) * mail server, same as OAuth discovery):
* 2. ROPC grant without client_id * 1. POST {serverUrl}/api/auth -> authenticate with a separate `mfaToken`,
* 3. ROPC grant authenticated via Basic Auth header (Stalwart-style) * receiving a short-lived authorization `clientCode`.
* 2. POST {serverUrl}/auth/token (grant_type=authorization_code) -> exchange
* the code (with PKCE) for access/refresh tokens.
*
* Token-based auth also survives TOTP rotation, unlike basic auth which embeds
* the (30s) code in every request.
*/ */
async function tryTokenRequest( // Fallback OAuth client id used when no client is configured. Stalwart accepts
tokenEndpoint: string, // any client id unless `require_client_registration` is enabled (default off);
params: URLSearchParams, // when it is enabled the admin must configure `oauthClientId` with this
extraHeaders?: Record<string, string>, // redirect URI registered.
): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> { const DEFAULT_CLIENT_ID = 'bulwark-webmail';
try {
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders };
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers,
body: params.toString(),
});
if (!response.ok) { interface LoginResult {
const errorText = await response.text(); type?: string;
return { ok: false, status: response.status, error: errorText.substring(0, 500) }; // The response keeps snake_case: only the LoginResponse variant *tags* are
} // camelCased server-side, not the struct fields (the request fields are).
client_code?: string;
const tokens = await response.json();
if (!tokens.access_token) {
return { ok: false, status: 502, error: 'Response missing access_token' };
}
return { ok: true, tokens };
} catch (err) {
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
}
} }
async function findTokenEndpoint(serverUrl: string, adminTrusted: boolean): Promise<string | null> { function trimUrl(url: string): string {
// Admin-trusted callers (matched server entry or configured JMAP server URL) return url.replace(/\/+$/, '');
// honor the `oauthAllowPrivateEndpoints` opt-in. User-supplied URLs always }
// go through the SSRF validator regardless of the setting.
const validateEndpoint = adminTrusted ? getDiscoveryValidator() : isPublicHttpUrl;
// 1. Try OAuth discovery
const metadata = await discoverOAuth(serverUrl, { validateEndpoint });
if (metadata?.token_endpoint) return metadata.token_endpoint;
// 2. Try common Stalwart token endpoint paths directly async function attemptLogin(
const candidates = [ upstreamUrl: string,
`${serverUrl}/auth/token`, username: string,
`${serverUrl}/api/oauth/token`, password: string,
]; totp: string | undefined,
redirectUri: string,
slot: number,
serverId: string | null,
): Promise<NextResponse> {
const base = trimUrl(upstreamUrl);
for (const url of candidates) { // Per-server OAuth credentials override the global ones when the requested
// server entry has its own oauth block configured.
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
const entry = findServerById(serverList, serverId);
const clientId = entry?.oauth?.clientId
|| configManager.get<string>('oauthClientId', '')
|| process.env.OAUTH_CLIENT_ID
|| DEFAULT_CLIENT_ID;
const clientSecret = entry?.oauth?.clientSecret
|| configManager.get<string>('oauthClientSecret', '')
|| process.env.OAUTH_CLIENT_SECRET
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE)
|| '';
// PKCE proves the token exchange originates from the same client that
// initiated the login, so no client secret is required for public clients.
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
// Step 1: structured login with a separate MFA token.
let login: LoginResult;
try { try {
// A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists const loginResponse = await fetch(`${base}/api/auth`, {
const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' }); method: 'POST',
if (probe.status !== 404 && probe.status !== 405) { headers: { 'Content-Type': 'application/json' },
return url; body: JSON.stringify({
} type: 'authCode',
} catch { accountName: username,
// Network error - endpoint not reachable accountSecret: password,
} ...(totp ? { mfaToken: totp } : {}),
clientId,
redirectUri,
codeChallenge: challenge,
codeChallengeMethod: 'S256',
}),
});
if (!loginResponse.ok) {
const detail = (await loginResponse.text()).substring(0, 500);
logger.warn('TOTP login: /api/auth rejected request', { status: loginResponse.status });
// A 404 means the server predates the structured login endpoint; let the
// caller fall back to the legacy basic-auth path.
return NextResponse.json(
{ error: loginResponse.status === 404 ? 'login_endpoint_missing' : 'login_failed', detail },
{ status: loginResponse.status === 404 ? 404 : 502 },
);
} }
return null; login = await loginResponse.json();
} catch (err) {
logger.warn('TOTP login: /api/auth request failed', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'login_unreachable' }, { status: 502 });
}
switch (login.type) {
case 'authenticated':
break;
case 'mfaRequired':
return NextResponse.json({ error: 'totp_required' }, { status: 401 });
case 'failure':
default:
return NextResponse.json({ error: 'invalid_credentials' }, { status: 401 });
}
if (!login.client_code) {
logger.warn('TOTP login: authenticated response missing client_code');
return NextResponse.json({ error: 'login_failed' }, { status: 502 });
}
// Step 2: exchange the authorization code for tokens.
const tokenParams = new URLSearchParams({
grant_type: 'authorization_code',
code: login.client_code,
client_id: clientId,
redirect_uri: redirectUri,
code_verifier: verifier,
});
// Confidential clients still send their secret; harmless for public clients.
if (clientSecret) tokenParams.set('client_secret', clientSecret);
let tokens: { access_token?: string; expires_in?: number; refresh_token?: string };
try {
const tokenResponse = await fetch(`${base}/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenParams.toString(),
});
if (!tokenResponse.ok) {
const detail = (await tokenResponse.text()).substring(0, 500);
logger.warn('TOTP login: token exchange failed', { status: tokenResponse.status, detail });
return NextResponse.json({ error: 'token_exchange_failed', detail }, { status: 502 });
}
tokens = await tokenResponse.json();
} catch (err) {
logger.warn('TOTP login: token endpoint failed', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'token_exchange_failed' }, { status: 502 });
}
if (!tokens.access_token) {
return NextResponse.json({ error: 'token_exchange_failed', detail: 'Response missing access_token' }, { status: 502 });
}
logger.info('TOTP login succeeded');
void recordLogin(username, base);
return await storeAndRespond(
{ access_token: tokens.access_token, expires_in: tokens.expires_in, refresh_token: tokens.refresh_token },
slot,
serverId,
);
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const { serverUrl, username, password, slot: bodySlot, server_id: bodyServerId } = await request.json(); const { serverUrl, username, password, totp, slot: bodySlot, server_id: bodyServerId, redirectUri: bodyRedirectUri } =
await request.json();
if (!serverUrl || !username || !password) { if (!serverUrl || !username || !password) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
@@ -93,6 +183,7 @@ export async function POST(request: NextRequest) {
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0; const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0;
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null; const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
const totpCode = typeof totp === 'string' && totp ? totp : undefined;
// Pin the upstream URL to a configured JMAP server. The list of allowed // Pin the upstream URL to a configured JMAP server. The list of allowed
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when // servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
@@ -110,20 +201,17 @@ export async function POST(request: NextRequest) {
let upstreamUrl: string; let upstreamUrl: string;
let resolvedServerId: string | null = null; let resolvedServerId: string | null = null;
let adminTrusted = false;
const requestedEntry = findServerById(serverList, requestedServerId); const requestedEntry = findServerById(serverList, requestedServerId);
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl); const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
if (matchedEntry) { if (matchedEntry) {
upstreamUrl = matchedEntry.url; upstreamUrl = matchedEntry.url;
resolvedServerId = matchedEntry.id; resolvedServerId = matchedEntry.id;
adminTrusted = true;
} else if (configuredServerUrl) { } else if (configuredServerUrl) {
upstreamUrl = configuredServerUrl; upstreamUrl = configuredServerUrl;
adminTrusted = true;
} else if (allowCustomEndpoint) { } else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) { if (!(await isPublicHttpUrl(serverUrl))) {
logger.warn('TOTP token exchange: rejected non-public server URL'); logger.warn('TOTP login: rejected non-public server URL');
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 }); return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
} }
upstreamUrl = serverUrl; upstreamUrl = serverUrl;
@@ -131,100 +219,22 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 }); return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
} }
const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted); // The redirect URI must be identical in the login and token-exchange steps,
if (!tokenEndpoint) { // and (when require_client_registration is on) registered for the client.
logger.warn('TOTP token exchange: no token endpoint found'); // Prefer the browser-supplied callback URL the OAuth client already uses;
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 }); // fall back to the upstream URL so the two steps still agree.
} const redirectUri =
typeof bodyRedirectUri === 'string' && /^https?:\/\//.test(bodyRedirectUri)
? bodyRedirectUri
: trimUrl(upstreamUrl);
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot, resolvedServerId); return await attemptLogin(upstreamUrl, username, password, totpCode, redirectUri, slot, resolvedServerId);
} catch (error) { } catch (error) {
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' }); logger.error('TOTP login error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
} }
} }
async function attemptAllStrategies(
tokenEndpoint: string,
serverUrl: string,
username: string,
password: string,
slot: number,
serverId: string | null,
): Promise<NextResponse> {
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
// Per-server OAuth credentials override the global ones when the requested
// server entry has its own oauth block configured.
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
const entry = findServerById(serverList, serverId);
const clientId = entry?.oauth?.clientId
|| configManager.get<string>('oauthClientId', '')
|| process.env.OAUTH_CLIENT_ID;
const clientSecret = entry?.oauth?.clientSecret
|| configManager.get<string>('oauthClientSecret', '')
|| process.env.OAUTH_CLIENT_SECRET
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const attempts: Array<{ strategy: string; error: string }> = [];
// Strategy 1: ROPC with client_id (if configured)
if (clientId) {
const params = new URLSearchParams({ grant_type: 'password', username, password, client_id: clientId });
if (clientSecret) params.set('client_secret', clientSecret);
const result = await tryTokenRequest(tokenEndpoint, params);
if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot, serverId);
}
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
}
// Strategy 2: ROPC without client_id
{
const params = new URLSearchParams({ grant_type: 'password', username, password });
const result = await tryTokenRequest(tokenEndpoint, params);
if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot, serverId);
}
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
}
// Strategy 3: Basic Auth header on token endpoint (some servers accept this)
{
const params = new URLSearchParams({ grant_type: 'password' });
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
if (result.ok) {
logger.info('TOTP token exchange succeeded (Basic Auth header)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot, serverId);
}
attempts.push({ strategy: 'Basic Auth header', error: result.error });
}
// Strategy 4: client_credentials with Basic Auth (last resort)
{
const params = new URLSearchParams({ grant_type: 'client_credentials' });
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
if (result.ok) {
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot, serverId);
}
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
}
logger.warn('TOTP token exchange: all strategies failed', { attempts });
return NextResponse.json({
error: 'token_exchange_failed',
detail: 'All token exchange strategies failed',
attempts,
}, { status: 502 });
}
async function storeAndRespond( async function storeAndRespond(
tokens: { access_token: string; expires_in?: number; refresh_token?: string }, tokens: { access_token: string; expires_in?: number; refresh_token?: string },
slot: number, slot: number,
+3
View File
@@ -37,6 +37,9 @@ export async function GET() {
author: p.author, author: p.author,
description: p.description, description: p.description,
type: p.type, type: p.type,
// Requested execution tier; clients gate the same-origin privileged
// sandbox on this (plus signature + approval + consent).
tier: p.tier,
permissions: p.permissions, permissions: p.permissions,
entrypoint: p.entrypoint, entrypoint: p.entrypoint,
// Policy is the canonical source for force-enable. The per-plugin field // Policy is the canonical source for force-enable. The per-plugin field
+1 -2
View File
@@ -5,7 +5,7 @@ import { useTranslations, useFormatter } from "next-intl";
import { format, isToday, isTomorrow, startOfDay } from "date-fns"; import { format, isToday, isTomorrow, startOfDay } from "date-fns";
import { MapPin, Users } from "lucide-react"; import { MapPin, Users } from "lucide-react";
import { cn } from "@/lib/utils"; 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 { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { getParticipantCount } from "@/lib/calendar-participants"; import { getParticipantCount } from "@/lib/calendar-participants";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
@@ -147,7 +147,6 @@ export function CalendarAgendaView({
const calendar = calId ? calendarMap.get(calId) : undefined; const calendar = calId ? calendarMap.get(calId) : undefined;
const color = getEventColor(ev, calendar); const color = getEventColor(ev, calendar);
const start = getEventStartDate(ev); const start = getEventStartDate(ev);
const durMin = parseDuration(ev.duration);
const end = getEventEndDate(ev); const end = getEventEndDate(ev);
const locationName = ev.locations const locationName = ev.locations
? Object.values(ev.locations)[0]?.name ? Object.values(ev.locations)[0]?.name
+1 -1
View File
@@ -71,7 +71,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
return el; 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 t = useTranslations("calendar");
const [isBeingDragged, setIsBeingDragged] = useState(false); const [isBeingDragged, setIsBeingDragged] = useState(false);
const color = getEventColor(event, calendar); const color = getEventColor(event, calendar);
+1 -1
View File
@@ -8,7 +8,7 @@ import {
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft, X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
Pencil, Trash2, Copy, Send, Check, Pencil, Trash2, Copy, Send, Check,
} from "lucide-react"; } from "lucide-react";
import { format, isSameDay, parseISO } from "date-fns"; import { format, isSameDay } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card"; import { parseDuration, getEventColor } from "./event-card";
+5 -1
View File
@@ -523,9 +523,14 @@ export function EventModal({
effectiveAttendees effectiveAttendees
) as Record<string, CalendarParticipant>; ) as Record<string, CalendarParticipant>;
data.replyTo = { imip: `mailto:${organizerEmail}` }; data.replyTo = { imip: `mailto:${organizerEmail}` };
// Stalwart (calcard) derives the iCalendar ORGANIZER property solely from
// organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP
// scheduling is silently skipped (NoSchedulingInfo), so no invites are sent.
data.organizerCalendarAddress = `mailto:${organizerEmail}`;
} else if (effectiveAttendees.length === 0 && event?.participants) { } else if (effectiveAttendees.length === 0 && event?.participants) {
data.participants = null; data.participants = null;
data.replyTo = null; data.replyTo = null;
data.organizerCalendarAddress = null;
} }
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations; const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
@@ -617,7 +622,6 @@ export function EventModal({
if (isAttendeeMode && event) { if (isAttendeeMode && event) {
const startD = getEventStartDate(event); const startD = getEventStartDate(event);
const durMin = parseDuration(event.duration);
const endD = getEventEndDate(event); const endD = getEventEndDate(event);
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null; const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
const participants = getParticipantList(event); const participants = getParticipantList(event);
+1 -1
View File
@@ -4,7 +4,7 @@ import { useState, useCallback, useRef, useEffect } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react"; 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 { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { getEventStartDate } from "@/lib/calendar-utils"; import { getEventStartDate } from "@/lib/calendar-utils";
@@ -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');
});
});
+13 -116
View File
@@ -2,16 +2,13 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download, MoreHorizontal, Printer } from "lucide-react"; import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer } from "lucide-react";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types"; import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store"; import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
import { ContactActivity } from "./contact-activity"; import { ContactActivity } from "./contact-activity";
import { useSmimeStore } from "@/stores/smime-store";
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
import type { CertificateInfo } from "@/lib/smime/types";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { exportContact } from "./contact-export"; import { exportContact } from "./contact-export";
import { printContact } from "./contact-print"; import { printContact } from "./contact-print";
@@ -32,6 +29,8 @@ interface ContactDetailProps {
onDelete: () => void; onDelete: () => void;
onAddToGroup?: () => void; onAddToGroup?: () => void;
onDuplicate?: () => void; onDuplicate?: () => void;
/** Compose an email to this contact in the app (no OS mailto handoff). */
onCompose?: () => void;
isMobile?: boolean; isMobile?: boolean;
className?: string; className?: string;
} }
@@ -115,51 +114,11 @@ function formatDate(dateInput: AnniversaryDate): string {
return dateStr; 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 t = useTranslations("contacts");
const smimeStore = useSmimeStore();
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : []; const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
useEffect(() => {
if (!contact) return;
let cancelled = false;
const parseCerts = async () => {
const results = new Map<number, CertificateInfo>();
for (let i = 0; i < cryptoKeys.length; i++) {
const key = cryptoKeys[i];
if (typeof key.uri !== 'string') continue;
try {
let derBytes: ArrayBuffer | string | null = null;
if (key.uri.startsWith('data:')) {
const commaIdx = key.uri.indexOf(',');
if (commaIdx === -1) continue;
const b64 = key.uri.substring(commaIdx + 1);
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
derBytes = bytes.buffer;
} else if (key.uri.startsWith('-----BEGIN')) {
derBytes = key.uri;
}
if (!derBytes) continue;
const cert = parseCertificatePemOrDer(derBytes);
const der = typeof derBytes === 'string' ? cert.toSchema(true).toBER(false) : derBytes;
const info = await extractCertificateInfo(cert, der);
if (!cancelled) results.set(i, info);
} catch { /* skip unparseable keys */ }
}
if (!cancelled) setParsedCerts(results);
};
if (cryptoKeys.length > 0) {
parseCerts();
} else {
setParsedCerts(new Map());
}
return () => { cancelled = true; };
}, [contact?.id]); // eslint-disable-line react-hooks/exhaustive-deps
if (!contact) { if (!contact) {
return ( return (
<div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}> <div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
@@ -201,35 +160,10 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
const notes = contact.notes ? Object.values(contact.notes) : []; const notes = contact.notes ? Object.values(contact.notes) : [];
const titles = contact.titles ? Object.values(contact.titles) : []; const titles = contact.titles ? Object.values(contact.titles) : [];
const jobTitles = titles.filter(t => t.kind !== "role"); 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 onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : []; const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : []; const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : [];
const handleImportContactCert = async (keyIndex: number) => {
const key = cryptoKeys[keyIndex];
if (!key?.uri || typeof key.uri !== 'string') return;
try {
let derBytes: ArrayBuffer | string;
if (key.uri.startsWith('data:')) {
const commaIdx = key.uri.indexOf(',');
if (commaIdx === -1) return;
const b64 = key.uri.substring(commaIdx + 1);
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
derBytes = bytes.buffer;
} else if (key.uri.startsWith('-----BEGIN')) {
derBytes = key.uri;
} else {
return;
}
await smimeStore.importPublicCert(derBytes, 'contact', contact.id);
toast.success(t("detail.cert_imported"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("detail.cert_import_failed"));
}
};
const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : []; const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : [];
const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : []; const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : [];
const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : []; const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : [];
@@ -260,14 +194,16 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
</div> </div>
</div> </div>
<div className="flex gap-2 flex-shrink-0 flex-wrap"> <div className="flex gap-2 flex-shrink-0 flex-wrap">
{email && ( {email && onCompose && (
<a <Button
href={`mailto:${email}`} variant="outline"
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" size="sm"
onClick={onCompose}
className="touch-manipulation"
> >
<Send className="w-4 h-4 mr-1" /> <Send className="w-4 h-4 mr-1" />
{t("detail.compose_email")} {t("detail.compose_email")}
</a> </Button>
)} )}
{phone && ( {phone && (
<a <a
@@ -490,45 +426,8 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
{cryptoKeys.length > 0 && ( {cryptoKeys.length > 0 && (
<Section title={t("detail.crypto_keys")}> <Section title={t("detail.crypto_keys")}>
<div className="space-y-3"> <div className="space-y-3">
{cryptoKeys.map((key, i) => { {cryptoKeys.map((key, i) => (
const certInfo = parsedCerts.get(i);
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
const alreadyImported = certInfo?.emailAddresses?.[0]
? !!smimeStore.getPublicCertForEmail(certInfo.emailAddresses[0])
: false;
return (
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1"> <div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
{certInfo ? (
<>
<div className="flex items-center gap-2">
{isExpired ? (
<ShieldAlert className="w-4 h-4 text-destructive flex-shrink-0" />
) : (
<ShieldCheck className="w-4 h-4 text-primary flex-shrink-0" />
)}
<span className="text-sm font-medium truncate">{certInfo.subject}</span>
</div>
<div className="text-xs text-muted-foreground space-y-0.5 pl-6">
<p>{t("detail.cert_issuer")}: {certInfo.issuer}</p>
<p>
{t("detail.cert_expires")}: {new Date(certInfo.notAfter).toLocaleDateString()}
{isExpired && <span className="text-destructive ml-1">({t("detail.cert_expired")})</span>}
</p>
<p>{t("detail.cert_fingerprint")}: {certInfo.fingerprint.substring(0, 20)}...</p>
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
</div>
{!alreadyImported && (
<Button variant="ghost" size="sm" className="ml-4 mt-1" onClick={() => handleImportContactCert(i)}>
<Download className="w-3 h-3 mr-1" />
{t("detail.import_to_smime")}
</Button>
)}
{alreadyImported && (
<p className="text-xs text-green-600 pl-6 mt-1">{t("detail.cert_already_imported")}</p>
)}
</>
) : (
<div className="flex items-start gap-2 text-sm break-all"> <div className="flex items-start gap-2 text-sm break-all">
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" /> <KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
{typeof key.uri === 'string' && key.uri.startsWith("http") ? ( {typeof key.uri === 'string' && key.uri.startsWith("http") ? (
@@ -539,10 +438,8 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span> <span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
)} )}
</div> </div>
)}
</div> </div>
); ))}
})}
</div> </div>
</Section> </Section>
)} )}
+1 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useMemo, useCallback, useEffect, useRef } from "react"; import { useState, useMemo, useCallback, useRef } from "react";
import { useTranslations } from "next-intl"; 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 { 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"; import { Button } from "@/components/ui/button";
+24 -1
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useTranslations } from "next-intl"; 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 { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -15,6 +15,8 @@ interface ContactGroupDetailProps {
onDelete: () => void; onDelete: () => void;
onRemoveMember: (memberId: string) => void; onRemoveMember: (memberId: string) => void;
onSelectMember: (id: 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; isMobile?: boolean;
className?: string; className?: string;
} }
@@ -26,11 +28,13 @@ export function ContactGroupDetail({
onDelete, onDelete,
onRemoveMember, onRemoveMember,
onSelectMember, onSelectMember,
onComposeGroup,
isMobile, isMobile,
className, className,
}: ContactGroupDetailProps) { }: ContactGroupDetailProps) {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const groupName = getContactDisplayName(group); const groupName = getContactDisplayName(group);
const hasEmailMembers = members.some((m) => getContactPrimaryEmail(m).trim());
return ( return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}> <div className={cn("flex flex-col h-full overflow-y-auto", className)}>
@@ -62,6 +66,25 @@ export function ContactGroupDetail({
</Button> </Button>
</div> </div>
</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>
<div className="px-6 py-4"> <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 { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
import { useTranslations } from "next-intl"; 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 { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; 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 { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ContactCard, AddressBook } from "@/lib/jmap/types"; import type { ContactCard, AddressBook } from "@/lib/jmap/types";
@@ -22,9 +22,11 @@ interface ContactsSidebarProps {
onSelectCategory: (category: ContactCategory) => void; onSelectCategory: (category: ContactCategory) => void;
onCreateGroup: () => void; onCreateGroup: () => void;
onCreateContact: () => void; onCreateContact: () => void;
onCreateAddressBook?: () => void;
onImport?: () => void; onImport?: () => void;
onEditGroup?: (groupId: string) => void; onEditGroup?: (groupId: string) => void;
onDeleteGroup?: (groupId: string) => void; onDeleteGroup?: (groupId: string) => void;
onComposeGroup?: (groupId: string, field: "to" | "cc" | "bcc") => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void; onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void; onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
onRenameAddressBook?: (addressBook: AddressBook) => void; onRenameAddressBook?: (addressBook: AddressBook) => void;
@@ -90,9 +92,11 @@ export function ContactsSidebar({
onSelectCategory, onSelectCategory,
onCreateGroup, onCreateGroup,
onCreateContact, onCreateContact,
onCreateAddressBook,
onImport, onImport,
onEditGroup, onEditGroup,
onDeleteGroup, onDeleteGroup,
onComposeGroup,
onDropContacts, onDropContacts,
onDropContactsToCategory, onDropContactsToCategory,
onRenameAddressBook, onRenameAddressBook,
@@ -296,6 +300,15 @@ export function ContactsSidebar({
<UsersRound className="w-4 h-4" /> <UsersRound className="w-4 h-4" />
{t("groups.create")} {t("groups.create")}
</button> </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 && ( {onImport && (
<button <button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left" 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); 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 /> <ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
icon={Trash2} icon={Trash2}
@@ -68,10 +68,12 @@ describe('EmailListItem tag badge', () => {
expect(screen.getByText('Blue')).toBeInTheDocument(); 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 } }); const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } });
render(<EmailListItem email={email} />); 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', () => { it('shows custom keyword label', () => {
@@ -67,21 +67,6 @@ vi.mock('@/stores/account-store', () => {
return { useAccountStore: hook }; 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', () => { vi.mock('@/stores/email-store', () => {
const state = { const state = {
draftSaveEnabled: false, draftSaveEnabled: false,
@@ -160,6 +145,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
vi.mock('@/lib/email-sanitization', () => ({ vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v, sanitizeSignatureHtml: (v: string) => v,
sanitizeEmailHtml: (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/reply-identity', () => ({ resolveReplyFrom: () => null }));
@@ -171,12 +157,6 @@ vi.mock('@/lib/signature-utils', () => ({
getPlainTextSignature: () => '', getPlainTextSignature: () => '',
})); }));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); 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('@/lib/debug', () => ({ debug: () => {} }));
vi.mock('@/components/email/quoted-html', () => ({ vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '', buildQuotedHtmlBlock: () => '',
@@ -0,0 +1,296 @@
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/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/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();
}
});
});
+97 -275
View File
@@ -5,32 +5,27 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown, MailCheck } from "lucide-react"; import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck } from "lucide-react";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { useContextMenu } from "@/hooks/use-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { sanitizeSignatureHtml, sanitizeEmailHtml } from "@/lib/email-sanitization"; import { sanitizeSignatureHtml, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { isFilePreviewable } from "@/lib/file-preview"; import { isFilePreviewable } from "@/lib/file-preview";
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html"; import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
import { buildSignatureBlock } from "@/components/email/signature-block";
import { emailHooks, contactHooks } from "@/lib/plugin-hooks"; import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types"; import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store"; import { useIdentityStore } from "@/stores/identity-store";
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities"; import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
import { useSmimeStore } from "@/stores/smime-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builder";
import type { MimeAttachment } from "@/lib/smime/mime-builder";
import { smimeSign } from "@/lib/smime/smime-sign";
import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginSlot } from "@/components/plugins/plugin-slot";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
import { useContactStore } from "@/stores/contact-store"; import { useContactStore } from "@/stores/contact-store";
import { useTemplateStore } from "@/stores/template-store"; import { useTemplateStore } from "@/stores/template-store";
import { SubAddressHelper } from "@/components/identity/sub-address-helper"; import { SubAddressHelper } from "@/components/identity/sub-address-helper";
@@ -49,6 +44,7 @@ import {
parseRecipient, parseRecipient,
parseRecipientList, parseRecipientList,
formatRecipientList, formatRecipientList,
splitPastedRecipients,
type Recipient, type Recipient,
} from "@/lib/email-composer-utils"; } from "@/lib/email-composer-utils";
import { RichTextEditor } from "@/components/email/rich-text-editor"; import { RichTextEditor } from "@/components/email/rich-text-editor";
@@ -187,11 +183,13 @@ type SignatureIdentityLike = {
textSignature?: string; textSignature?: string;
} | null | undefined; } | null | undefined;
// Render the embedded signature for "above quote" mode. Bracketed with // Render the embedded signature. Bracketed with `data-signature-block` marker
// `data-signature-block` marker paragraphs so we can swap the inner content // paragraphs so we can swap the inner content when the user switches identity
// when the user switches identity without losing the surrounding draft or // without losing the surrounding draft or quoted message. The markers are
// quoted message. The markers are preserved through TipTap by the // preserved through TipTap by the StyledParagraph extension. The HTML
// StyledParagraph extension. // signature itself is wrapped in a SignatureBlock atom node so its inline
// styling survives the editor (see signature-block.ts) instead of being
// flattened by the schema.
function buildEmbeddedSignatureHtml( function buildEmbeddedSignatureHtml(
identity: SignatureIdentityLike, identity: SignatureIdentityLike,
options: { embed: boolean; separator: boolean } options: { embed: boolean; separator: boolean }
@@ -202,7 +200,7 @@ function buildEmbeddedSignatureHtml(
: `<p data-signature-block="start"></p>`; : `<p data-signature-block="start"></p>`;
const endMarker = `<p data-signature-block="end"></p>`; const endMarker = `<p data-signature-block="end"></p>`;
if (identity?.htmlSignature) { if (identity?.htmlSignature) {
return `${startMarker}${sanitizeSignatureHtml(identity.htmlSignature)}${endMarker}`; return `${startMarker}${buildSignatureBlock(sanitizeSignatureHtml(identity.htmlSignature))}${endMarker}`;
} }
if (identity?.textSignature) { if (identity?.textSignature) {
const escaped = identity.textSignature const escaped = identity.textSignature
@@ -343,9 +341,8 @@ export function EmailComposer({
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ""; const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
const from = replyTo.from?.[0]; const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); // Forward "From:" and the reply "On … wrote:" line both show the full
// Forward "From:" shows the full sender incl. address; reply line keeps // sender incl. address ("Name <email>"), like Gmail/Outlook (#482).
// the bare name (reads naturally in the localized "On … wrote:" line).
const fromStrFull = from const fromStrFull = from
? (from.name && from.email && from.name !== from.email ? (from.name && from.email && from.name !== from.email
? `${from.name} <${from.email}>` ? `${from.name} <${from.email}>`
@@ -373,7 +370,7 @@ export function EmailComposer({
if (mode === 'forward') { if (mode === 'forward') {
return `${prefix}${signatureBlock}\n\n${tQuote('forwarded_separator')}\n${tQuote('from_label')}: ${fromStrFull}\n${tQuote('date_label')}: ${date}\n${tQuote('subject_label')}: ${replyTo.subject || ''}\n\n${originalText}`; return `${prefix}${signatureBlock}\n\n${tQuote('forwarded_separator')}\n${tQuote('from_label')}: ${fromStrFull}\n${tQuote('date_label')}: ${date}\n${tQuote('subject_label')}: ${replyTo.subject || ''}\n\n${originalText}`;
} else if (mode === 'reply' || mode === 'replyAll') { } else if (mode === 'reply' || mode === 'replyAll') {
return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStr })}\n${quotedText}`; return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStrFull })}\n${quotedText}`;
} }
return prefix; return prefix;
} }
@@ -395,7 +392,7 @@ export function EmailComposer({
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ""; const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
const from = replyTo.from?.[0]; const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); // Forward and reply quote lines both show the full "Name <email>" sender (#482).
const fromStrFull = from const fromStrFull = from
? (from.name && from.email && from.name !== from.email ? (from.name && from.email && from.name !== from.email
? `${from.name} <${from.email}>` ? `${from.name} <${from.email}>`
@@ -430,9 +427,11 @@ export function EmailComposer({
// Build quoted content as HTML // Build quoted content as HTML
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
// HTML-escape user-controlled values: an unescaped sender "Name <email>"
// has its "<email>" eaten as a bogus HTML tag by the rich-text editor (#482).
const quoteHeader = mode === 'forward' const quoteHeader = mode === 'forward'
? `${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>` ? `${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${escapeHtml(fromStrFull)}<br>${tQuote('date_label')}: ${escapeHtml(date)}<br>${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}<br><br>`
: `${tQuote('reply_line', { date, from: fromStr })}<br>`; : `${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br>`;
// Embed the original as a QuotedHtml island (verbatim, schema-free) so // Embed the original as a QuotedHtml island (verbatim, schema-free) so
// its layout survives the editor round-trip. Sanitize first to strip // its layout survives the editor round-trip. Sanitize first to strip
// scripts/styles/head; cid rewrite afterwards so data-cid markers // scripts/styles/head; cid rewrite afterwards so data-cid markers
@@ -446,9 +445,9 @@ export function EmailComposer({
if (replyTo.body) { if (replyTo.body) {
const escapedOriginal = replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>'); const escapedOriginal = replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
if (mode === 'forward') { if (mode === 'forward') {
return `${prefix}${signatureBlock}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>${escapedOriginal}`; return `${prefix}${signatureBlock}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${escapeHtml(fromStrFull)}<br>${tQuote('date_label')}: ${escapeHtml(date)}<br>${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}<br><br>${escapedOriginal}`;
} else if (mode === 'reply' || mode === 'replyAll') { } else if (mode === 'reply' || mode === 'replyAll') {
return `${prefix}${signatureBlock}<br><br>${tQuote('reply_line', { date, from: fromStr })}<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`; return `${prefix}${signatureBlock}<br><br>${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
} }
} }
return prefix; return prefix;
@@ -517,11 +516,6 @@ export function EmailComposer({
const [showCloseDialog, setShowCloseDialog] = useState(false); const [showCloseDialog, setShowCloseDialog] = useState(false);
const [showAllAttachments, setShowAllAttachments] = useState(false); const [showAllAttachments, setShowAllAttachments] = useState(false);
const [previewAttachment, setPreviewAttachment] = useState<ComposerAttachment | null>(null); const [previewAttachment, setPreviewAttachment] = useState<ComposerAttachment | null>(null);
const [smimeSign_, setSmimeSign] = useState(false);
const [smimeEncrypt_, setSmimeEncrypt] = useState(false);
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
const [smimePassphraseInput, setSmimePassphraseInput] = useState('');
const [smimePassphraseError, setSmimePassphraseError] = useState('');
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false); const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState(''); const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
const [attachmentWarningDelayedUntil, setAttachmentWarningDelayedUntil] = useState<string | undefined>(); const [attachmentWarningDelayedUntil, setAttachmentWarningDelayedUntil] = useState<string | undefined>();
@@ -646,6 +640,11 @@ export function EmailComposer({
if (nextHtml !== currentHtml) { if (nextHtml !== currentHtml) {
editor.commands.setContent(nextHtml, { emitUpdate: true }); editor.commands.setContent(nextHtml, { emitUpdate: true });
} }
// Intentionally keyed to the signature-relevant fields plus the internal
// prev*Ref guards above; depending on the whole `signatureIdentity` object
// would re-run on unrelated identity-field changes and re-splice the
// signature into the live editor.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]); }, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
useEffect(() => { useEffect(() => {
@@ -792,34 +791,9 @@ export function EmailComposer({
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender); const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook); const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
const addTemplate = useTemplateStore((s) => s.addTemplate); const addTemplate = useTemplateStore((s) => s.addTemplate);
const sendRawEmail = useEmailStore((s) => s.sendRawEmail); // Sign/encrypt is provided by crypto plugins (S/MIME, PGP) via the
const smimeStore = useSmimeStore(); // composer-toolbar slot + the onComposeSend hook — the host stays
// crypto-agnostic.
// Determine S/MIME availability for the selected identity
const currentSmimeIdentityId = selectedIdentityId || primaryIdentity?.id;
const smimeKeyRecord = currentSmimeIdentityId ? smimeStore.getKeyRecordForIdentity(currentSmimeIdentityId) : undefined;
const canSmimeSign = !!smimeKeyRecord;
const canSmimeEncrypt = (() => {
if (!smimeKeyRecord) return false;
const allRecipients = [
...withInput(to, toInput),
...withInput(cc, ccInput),
...withInput(bcc, bccInput),
].map(r => r.email);
if (allRecipients.length === 0) return false;
const { missing } = smimeStore.getRecipientCerts(allRecipients);
return missing.length === 0;
})();
// Initialize S/MIME defaults from store when identity changes
useEffect(() => {
if (currentSmimeIdentityId) {
setSmimeSign(!!smimeStore.defaultSignIdentity[currentSmimeIdentityId] && canSmimeSign);
}
setSmimeEncrypt(smimeStore.defaultEncrypt && canSmimeEncrypt);
// Only run when identity changes, not on every recipient edit
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSmimeIdentityId]);
// Serialized recipient strings for ComposerDraftData (string-shaped) and for // Serialized recipient strings for ComposerDraftData (string-shaped) and for
// by-value dirty comparison. Folds in any uncommitted typed text. // by-value dirty comparison. Folds in any uncommitted typed text.
@@ -1484,7 +1458,16 @@ export function EmailComposer({
}; };
}; };
// Guard against double-submit. Rapid Send clicks (or a click racing the
// keyboard shortcut) used to invoke handleSend once per click before the
// first submission resolved, sending the message multiple times. The ref is
// a synchronous re-entry guard - state updates are async and wouldn't block a
// second click in the same tick - and isSending drives button disabling.
const [isSending, setIsSending] = useState(false);
const isSendingRef = useRef(false);
const handleSend = async (skipAttachmentCheck = false, delayedUntil?: string) => { const handleSend = async (skipAttachmentCheck = false, delayedUntil?: string) => {
if (isSendingRef.current) return;
const ccAddresses = withInput(cc, ccInput); const ccAddresses = withInput(cc, ccInput);
const bccAddresses = withInput(bcc, bccInput); const bccAddresses = withInput(bcc, bccInput);
@@ -1519,6 +1502,11 @@ export function EmailComposer({
} }
} }
// Past every "don't send" early return - mark the send in flight so a
// second click is a no-op until this resolves (reset in the finally below).
isSendingRef.current = true;
setIsSending(true);
// Resolve the freshest draftId we can. Two cases: // Resolve the freshest draftId we can. Two cases:
// 1. An autosave is currently in flight - wait for it; don't issue a // 1. An autosave is currently in flight - wait for it; don't issue a
// parallel destroy/create that would race with it on the same id. // parallel destroy/create that would race with it on the same id.
@@ -1624,145 +1612,39 @@ export function EmailComposer({
const sendAllowed = await emailHooks.onBeforeEmailSend.intercept(sendablePreview); const sendAllowed = await emailHooks.onBeforeEmailSend.intercept(sendablePreview);
if (!sendAllowed) return; if (!sendAllowed) return;
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail // Hand off to a crypto plugin (S/MIME, PGP, …) if one wants to take over
if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) { // the send: it builds raw MIME, signs/encrypts, and submits via
// S/MIME keys are scoped to one JMAP account's identity - sending // api.jmap.sendRaw. A handler returning false means "I sent it" — the
// from a cross-account identity via S/MIME would mix accounts' // host then skips its own plaintext submission but still cleans up the
// certs/clients. Refuse upfront and tell the user to switch. // draft (and fires the scheduled-send callback for a delayed send).
const crossAccount = stripCrossAccountIdentityPrefix(currentIdentity.id); const composeSendRequest = {
if (crossAccount.localAccountId) { to: toAddresses.map(r => formatRecipient(r.name, r.email)),
throw new Error('S/MIME sending from another accounts identity is not supported. Switch to that account first.'); cc: ccAddresses.map(r => formatRecipient(r.name, r.email)),
} bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)),
// 1. Resolve S/MIME key
if (smimeSign_ && !smimeKeyRecord) {
throw new Error('No S/MIME key bound to this identity');
}
// S/MIME binds to the identity's key; sending from an override address
// would produce a signature whose Subject differs from the visible
// From, which most clients reject or flag. Refuse up front.
if (overrideActive) {
throw new Error('Cannot use From override with S/MIME - disable one to send.');
}
// 2. Ensure key is unlocked for signing
if (smimeSign_ && smimeKeyRecord && !smimeStore.isKeyUnlocked(smimeKeyRecord.id)) {
const passphrase = await new Promise<string>((resolve, reject) => {
setSmimePassphrasePrompt({ keyId: smimeKeyRecord.id, resolve, reject });
});
try {
await smimeStore.unlockKey(smimeKeyRecord.id, passphrase);
} finally {
setSmimePassphrasePrompt(null);
setSmimePassphraseInput('');
setSmimePassphraseError('');
}
}
// 3. Resolve attachments as ArrayBuffers
const mimeAttachments: MimeAttachment[] = [];
for (const att of attachments) {
if (att.error || att.uploading) continue;
let content: ArrayBuffer;
if (att.file && att.file.size > 0) {
content = await att.file.arrayBuffer();
} else if (att.blobId && client) {
content = await client.fetchBlobArrayBuffer(att.blobId, att.name, att.type);
} else {
continue;
}
mimeAttachments.push({
filename: att.name,
contentType: att.type || 'application/octet-stream',
content,
});
}
for (const inline of inlineAttachments) {
if (!client) break;
const content = await client.fetchBlobArrayBuffer(inline.blobId, inline.name, inline.type);
mimeAttachments.push({
filename: inline.name,
contentType: inline.type,
content,
cid: inline.cid,
});
}
// 4. Build canonical MIME
// mime-builder takes inReplyTo as a single ref-form msg-id (with brackets);
// references stays an array. threadingHeaders contains bare msg-ids.
const mimeInReplyTo = threadingHeaders?.inReplyTo[0]
? `<${threadingHeaders.inReplyTo[0]}>`
: undefined;
const mimeReferences = threadingHeaders?.references.length
? threadingHeaders.references.map(id => `<${id}>`)
: undefined;
const mimeBytes = buildMimeMessage({
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
to: toAddresses,
cc: ccAddresses.length > 0 ? ccAddresses : undefined,
bcc: bccAddresses.length > 0 ? bccAddresses : undefined,
subject, subject,
inReplyTo: mimeInReplyTo, htmlBody: finalHtmlBody || '',
references: mimeReferences,
textBody: finalBody, textBody: finalBody,
htmlBody: finalHtmlBody, identityId: currentIdentity?.id || '',
attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined, fromEmail,
}); fromName,
inReplyTo: threadingHeaders?.inReplyTo?.[0],
let payload: Blob = new Blob([mimeBytes.buffer as ArrayBuffer], { type: 'message/rfc822' }); references: threadingHeaders?.references,
delayedUntil: effectiveDelayedUntil,
const smimeHeaders = { attachments: [
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email }, ...attachments
to: toAddresses, .filter(att => att.blobId && !att.uploading && !att.error)
cc: ccAddresses.length > 0 ? ccAddresses : undefined, .map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size, blobId: a.blobId })),
subject, ...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })),
inReplyTo: mimeInReplyTo, ],
references: mimeReferences,
}; };
const sendHandledByPlugin = (await emailHooks.onComposeSend.intercept(composeSendRequest)) === false;
// 5. Sign if enabled if (sendHandledByPlugin) {
if (smimeSign_ && smimeKeyRecord) { if (finalDraftId) {
const privateKey = smimeStore.getUnlockedKey(smimeKeyRecord.id); client?.deleteEmail(finalDraftId).catch((err) => {
if (!privateKey) throw new Error('S/MIME key is not unlocked'); debug.warn('email', 'Plugin handled the send, but draft cleanup failed:', err);
const cmsBlob = await smimeSign(
mimeBytes,
privateKey,
smimeKeyRecord.certificate,
smimeKeyRecord.certificateChain || [],
);
const cmsBytes = new Uint8Array(await cmsBlob.arrayBuffer());
payload = wrapCmsAsSmimeMessage(cmsBytes, { ...smimeHeaders, smimeType: 'signed-data' });
}
// 6. Encrypt if enabled
if (smimeEncrypt_ && smimeKeyRecord) {
const allRecipients = [...toAddresses, ...ccAddresses, ...bccAddresses].map(r => r.email);
const { found, missing } = smimeStore.getRecipientCerts(allRecipients);
if (missing.length > 0) {
throw new Error(`Missing certificates for: ${missing.join(', ')}`);
}
const recipientCertsDer = found.map(c => c.certificate instanceof ArrayBuffer ? c.certificate : new Uint8Array(c.certificate as ArrayBuffer).buffer);
const payloadBytes = new Uint8Array(await payload.arrayBuffer());
const cmsBlob = await smimeEncrypt(
payloadBytes,
recipientCertsDer,
smimeKeyRecord.certificate,
);
const cmsBytes = new Uint8Array(await cmsBlob.arrayBuffer());
payload = wrapCmsAsSmimeMessage(cmsBytes, { ...smimeHeaders, smimeType: 'enveloped-data' });
}
// 7. Send via raw email path
const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil, [...toAddresses, ...ccAddresses, ...bccAddresses].map(r => r.email));
if (effectiveDelayedUntil && finalDraftId) {
client.deleteEmail(finalDraftId).catch(err => {
debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err);
toast.warning(t('schedule_send_cleanup_warning'));
}); });
} }
if (result.scheduled) { if (effectiveDelayedUntil) await onScheduledSendCreated?.();
await onScheduledSendCreated?.();
}
} else { } else {
// Standard JMAP send path // Standard JMAP send path
// Collect uploaded attachment blobIds for the send request // Collect uploaded attachment blobIds for the send request
@@ -1849,6 +1731,9 @@ export function EmailComposer({
} catch (err) { } catch (err) {
debug.error('Failed to send email:', err); debug.error('Failed to send email:', err);
toast.error(err instanceof Error ? err.message : t('send_failed')); toast.error(err instanceof Error ? err.message : t('send_failed'));
} finally {
isSendingRef.current = false;
setIsSending(false);
} }
}; };
@@ -1943,7 +1828,6 @@ export function EmailComposer({
showTemplatePicker || showTemplatePicker ||
showSaveAsTemplate || showSaveAsTemplate ||
showScheduleDialog || showScheduleDialog ||
smimePassphrasePrompt ||
showAttachmentWarning || showAttachmentWarning ||
showCloseDialog showCloseDialog
) return; ) return;
@@ -2028,7 +1912,7 @@ export function EmailComposer({
{/* Mobile: send button in header */} {/* Mobile: send button in header */}
<Button <Button
onClick={() => handleSend()} onClick={() => handleSend()}
disabled={!canSend} disabled={!canSend || isSending}
title={getSendTooltip()} title={getSendTooltip()}
size="sm" size="sm"
className="md:hidden h-9 px-4" className="md:hidden h-9 px-4"
@@ -2479,31 +2363,8 @@ export function EmailComposer({
> >
<BookmarkPlus className="w-4 h-4" /> <BookmarkPlus className="w-4 h-4" />
</Button> </Button>
{/* S/MIME toggles */} {/* Sign/encrypt controls are contributed by crypto plugins via the
{canSmimeSign && ( composer-toolbar slot (rendered below). */}
<>
<div className="w-px h-5 bg-border mx-1" />
<Button
variant="ghost"
size="icon"
onClick={() => setSmimeSign(v => !v)}
className={cn("h-9 w-9", smimeSign_ && "bg-primary/10 text-primary")}
title={smimeSign_ ? t('smime_sign_on') : t('smime_sign_off')}
>
<ShieldCheck className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setSmimeEncrypt(v => !v)}
disabled={!canSmimeEncrypt}
className={cn("h-9 w-9", smimeEncrypt_ && "bg-primary/10 text-primary")}
title={smimeEncrypt_ ? t('smime_encrypt_on') : canSmimeEncrypt ? t('smime_encrypt_off') : t('smime_encrypt_unavailable')}
>
<Lock className="w-4 h-4" />
</Button>
</>
)}
{/* Read-receipt request toggle */} {/* Read-receipt request toggle */}
<Button <Button
@@ -2535,7 +2396,7 @@ export function EmailComposer({
<div ref={sendMenuRef} className="relative hidden md:inline-flex"> <div ref={sendMenuRef} className="relative hidden md:inline-flex">
<Button <Button
onClick={() => handleSend()} onClick={() => handleSend()}
disabled={!canSend} disabled={!canSend || isSending}
title={getSendTooltip()} title={getSendTooltip()}
className="rounded-r-none border-r border-primary-foreground/20" className="rounded-r-none border-r border-primary-foreground/20"
> >
@@ -2545,7 +2406,7 @@ export function EmailComposer({
<Button <Button
type="button" type="button"
onClick={() => setShowSendMenu((open) => !open)} onClick={() => setShowSendMenu((open) => !open)}
disabled={!canSend} disabled={!canSend || isSending}
title={t('schedule_send')} title={t('schedule_send')}
className="rounded-l-none px-2" className="rounded-l-none px-2"
aria-haspopup="menu" aria-haspopup="menu"
@@ -2573,7 +2434,7 @@ export function EmailComposer({
) : ( ) : (
<Button <Button
onClick={() => handleSend()} onClick={() => handleSend()}
disabled={!canSend} disabled={!canSend || isSending}
title={getSendTooltip()} title={getSendTooltip()}
className="hidden md:inline-flex" className="hidden md:inline-flex"
> >
@@ -2636,61 +2497,7 @@ export function EmailComposer({
{scheduleError && <p className="mt-2 text-sm text-destructive">{scheduleError}</p>} {scheduleError && <p className="mt-2 text-sm text-destructive">{scheduleError}</p>}
<div className="mt-5 flex justify-end gap-2"> <div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setShowScheduleDialog(false)}>{tCommon('cancel')}</Button> <Button variant="ghost" onClick={() => setShowScheduleDialog(false)}>{tCommon('cancel')}</Button>
<Button onClick={handleScheduleSend} disabled={!canSend}>{t('schedule_send')}</Button> <Button onClick={handleScheduleSend} disabled={!canSend || isSending}>{t('schedule_send')}</Button>
</div>
</div>
</div>
)}
{/* S/MIME passphrase prompt */}
{smimePassphrasePrompt && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
>
<div
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-sm animate-in zoom-in-95 duration-200"
>
<div className="p-6">
<h2 className="text-lg font-semibold text-foreground">{t('smime_unlock_title')}</h2>
<p className="mt-2 text-sm text-muted-foreground">{t('smime_unlock_message')}</p>
<input
type="password"
autoFocus
value={smimePassphraseInput}
onChange={(e) => {
setSmimePassphraseInput(e.target.value);
setSmimePassphraseError('');
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && smimePassphraseInput) {
smimePassphrasePrompt.resolve(smimePassphraseInput);
}
}}
placeholder={t('smime_passphrase_placeholder')}
className="mt-3 w-full px-3 py-2 border border-border rounded-md text-sm bg-background text-foreground outline-none focus:ring-2 focus:ring-primary"
/>
{smimePassphraseError && (
<p className="mt-1 text-xs text-red-500">{smimePassphraseError}</p>
)}
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button variant="outline" onClick={() => {
smimePassphrasePrompt.reject();
setSmimePassphrasePrompt(null);
setSmimePassphraseInput('');
setSmimePassphraseError('');
}}>
{t('cancel')}
</Button>
<Button
disabled={!smimePassphraseInput}
onClick={() => smimePassphrasePrompt.resolve(smimePassphraseInput)}
>
{t('smime_unlock_button')}
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -2919,6 +2726,20 @@ function RecipientChipInput({
} }
}; };
// Pasting a list of addresses (comma/semicolon/whitespace separated) splits
// into one chip per valid address; anything that isn't a valid address is
// left in the input for the user to fix. A single address with no separator
// falls through to the browser's normal paste so it stays editable.
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
const text = e.clipboardData.getData('text');
if (!/[\s,;]/.test(text.trim())) return;
const { valid, invalid } = splitPastedRecipients(text, chips.map(c => c.email));
if (valid.length === 0) return;
e.preventDefault();
onChipsChange([...chips, ...valid]);
onInputChange([inputText.trim(), invalid.join(' ')].filter(Boolean).join(' '));
};
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (activeAutoField === field && autocompleteResults.length > 0) { if (activeAutoField === field && autocompleteResults.length > 0) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Escape' || if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Escape' ||
@@ -3113,6 +2934,7 @@ function RecipientChipInput({
value={inputText} value={inputText}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={handleBlur} onBlur={handleBlur}
className="flex-1 min-w-[120px] border-0 outline-none h-7 text-sm bg-transparent text-foreground placeholder:text-muted-foreground" className="flex-1 min-w-[120px] border-0 outline-none h-7 text-sm bg-transparent text-foreground placeholder:text-muted-foreground"
role="combobox" role="combobox"
+5 -2
View File
@@ -34,6 +34,7 @@ import {
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
interface Position { interface Position {
@@ -143,6 +144,7 @@ export function EmailContextMenu({
onRescheduleScheduled, onRescheduleScheduled,
}: EmailContextMenuProps) { }: EmailContextMenuProps) {
const t = useTranslations("context_menu"); const t = useTranslations("context_menu");
const tSidebar = useTranslations("sidebar");
const _tColor = useTranslations("email_viewer.color_tag"); const _tColor = useTranslations("email_viewer.color_tag");
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
@@ -302,12 +304,13 @@ export function EmailContextMenu({
return nodes.map((node) => { return nodes.map((node) => {
const Icon = getMailboxIcon(node.role); const Icon = getMailboxIcon(node.role);
const isTarget = moveTargetIds.has(node.id); const isTarget = moveTargetIds.has(node.id);
const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
return ( return (
<div key={node.id}> <div key={node.id}>
{isTarget ? ( {isTarget ? (
<ContextMenuItem <ContextMenuItem
icon={Icon} icon={Icon}
label={node.name} label={nodeLabel}
onClick={() => onClick={() =>
handleAction(() => handleAction(() =>
showBatchActions 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"> <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" /> <Icon className="w-4 h-4 flex-shrink-0" />
<span>{node.name}</span> <span>{nodeLabel}</span>
</div> </div>
)} )}
{node.children.length > 0 && ( {node.children.length > 0 && (
+19 -4
View File
@@ -4,7 +4,7 @@ import { Email } from "@/lib/jmap/types";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import type { HoverAction } from "@/stores/settings-store"; import type { HoverAction } from "@/stores/settings-store";
import { cn } from "@/lib/utils"; 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 { useTranslations } from "next-intl";
import { useIsMobile } from "@/hooks/use-media-query"; import { useIsMobile } from "@/hooks/use-media-query";
@@ -17,6 +17,10 @@ interface EmailHoverActionsProps {
onArchive?: () => void; onArchive?: () => void;
onSetColorTag?: (color: string | null) => void; onSetColorTag?: (color: string | null) => void;
onMarkAsSpam?: () => 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, { const ACTION_CONFIG: Record<HoverAction, {
@@ -72,6 +76,8 @@ export function EmailHoverActions({
onArchive, onArchive,
onSetColorTag, onSetColorTag,
onMarkAsSpam, onMarkAsSpam,
isInJunk = false,
onUndoSpam,
}: EmailHoverActionsProps) { }: EmailHoverActionsProps) {
const hoverActions = useSettingsStore((state) => state.hoverActions); const hoverActions = useSettingsStore((state) => state.hoverActions);
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode); const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
@@ -106,7 +112,8 @@ export function EmailHoverActions({
onSetColorTag?.(null); onSetColorTag?.(null);
break; break;
case "spam": case "spam":
onMarkAsSpam?.(); if (isInJunk) onUndoSpam?.();
else onMarkAsSpam?.();
break; break;
} }
}; };
@@ -116,20 +123,28 @@ export function EmailHoverActions({
if (!config) return null; if (!config) return null;
const Icon = config.icon; const Icon = config.icon;
// In a junk context the spam action becomes "not spam".
const isNotSpam = actionId === "spam" && isInJunk;
const DisplayIcon = actionId === "markRead" const DisplayIcon = actionId === "markRead"
? (isUnread ? MailOpen : Mail) ? (isUnread ? MailOpen : Mail)
: actionId === "star" && isStarred : actionId === "star" && isStarred
? Star ? Star
: isNotSpam
? ShieldCheck
: Icon; : 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 ( return (
<button <button
key={actionId} key={actionId}
onClick={(e) => handleAction(e, actionId)} onClick={(e) => handleAction(e, actionId)}
title={t(config.titleKey)} title={title}
className={cn( className={cn(
"p-1.5 rounded-md transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10", "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 <DisplayIcon
+13 -7
View File
@@ -29,11 +29,12 @@ interface EmailListItemProps {
onArchive?: () => void; onArchive?: () => void;
onSetColorTag?: (color: string | null) => void; onSetColorTag?: (color: string | null) => void;
onMarkAsSpam?: () => 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 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 showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -46,8 +47,11 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
const isImportant = email.keywords?.["$important"]; const isImportant = email.keywords?.["$important"];
const isAnswered = email.keywords?.$answered; const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded; const isForwarded = email.keywords?.$forwarded;
// In Sent/Drafts folders, show recipient instead of sender (which is always "me") // In Sent/Drafts folders, show recipient instead of sender (which is always "me").
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role; // 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 showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const isMobile = useUIStore((state) => state.isMobile); const isMobile = useUIStore((state) => state.isMobile);
@@ -162,7 +166,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
{/* Unread indicator */} {/* Unread indicator */}
{isUnread && ( {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" /> <Circle className="w-2 h-2 fill-unread text-unread" />
</div> </div>
)} )}
@@ -191,13 +195,13 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
</span> </span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm"> <div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={cn( <span className={cn(
'shrink-0 truncate', 'min-w-0 truncate',
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90' isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
)}> )}>
{email.subject || t('no_subject')} {email.subject || t('no_subject')}
</span> </span>
{inlinePreview && ( {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>
</div> </div>
@@ -321,6 +325,8 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
onArchive={onArchive} onArchive={onArchive}
onSetColorTag={onSetColorTag} onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam} onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
/> />
</div> </div>
); );
+25 -2
View File
@@ -101,14 +101,25 @@ export function EmailList({
toggleThreadExpansion, toggleThreadExpansion,
fetchThreadEmails, fetchThreadEmails,
markThreadAsRead, markThreadAsRead,
collapseAllThreads,
threadEmailCounts, threadEmailCounts,
searchFilters, searchFilters,
setSearchFilters, setSearchFilters,
clearSearchFilters, clearSearchFilters,
advancedSearch, advancedSearch,
searchQuery, searchQuery,
isUnifiedView,
unifiedRole,
} = useEmailStore(); } = 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 disableThreading = useSettingsStore((state) => state.disableThreading);
const threadGroups = useMemo(() => { const threadGroups = useMemo(() => {
@@ -477,7 +488,18 @@ export function EmailList({
isLoading={isLoadingThread === thread.threadId} isLoading={isLoadingThread === thread.threadId}
expandedEmails={threadEmailsCache.get(thread.threadId)} expandedEmails={threadEmailsCache.get(thread.threadId)}
onToggleExpand={() => handleToggleThreadExpansion(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} onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
onContextMenu={openContextMenu} onContextMenu={openContextMenu}
onOpenConversation={onOpenConversation} onOpenConversation={onOpenConversation}
@@ -487,6 +509,7 @@ export function EmailList({
onArchive={onArchive ? (email) => onArchive(email) : undefined} onArchive={onArchive ? (email) => onArchive(email) : undefined}
onSetColorTag={onSetColorTag} onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined} onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
/> />
</div> </div>
); );
@@ -520,7 +543,7 @@ export function EmailList({
menuRef={menuRef} menuRef={menuRef}
mailboxes={mailboxes} mailboxes={mailboxes}
selectedMailbox={selectedMailbox} selectedMailbox={selectedMailbox}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role} currentMailboxRole={effectiveMailboxRole}
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)} isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
selectedCount={selectedEmailIds.size} selectedCount={selectedEmailIds.size}
onReply={() => onReply?.(contextMenu.data!)} onReply={() => onReply?.(contextMenu.data!)}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -4,6 +4,8 @@ import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
import { DOMSerializer } from "@tiptap/pm/model"; import { DOMSerializer } from "@tiptap/pm/model";
import type { Editor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { buildSignatureBlock } from "@/components/email/signature-block";
// Marker attribute that identifies the quoted-original wrapper in serialized // Marker attribute that identifies the quoted-original wrapper in serialized
// HTML, so parseHTML can recognise it on the way back in. // HTML, so parseHTML can recognise it on the way back in.
export const QUOTED_HTML_MARKER = "data-quoted-html"; 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) || "")); parts.push(buildQuotedHtmlBlock((node.attrs.html as string) || ""));
return; 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 fragment = serializer.serializeNode(node);
const tmp = document.createElement("div"); const tmp = document.createElement("div");
tmp.appendChild(fragment); 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 { TableHeader } from "@tiptap/extension-table-header";
import { TableCell } from "@tiptap/extension-table-cell"; import { TableCell } from "@tiptap/extension-table-cell";
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html"; import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
import { SignatureBlock } from "@/components/email/signature-block";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
Bold, Bold,
@@ -235,6 +236,10 @@ export function RichTextEditor({
// Quoted/forwarded original email body - held verbatim as an atomic // Quoted/forwarded original email body - held verbatim as an atomic
// node so layout-heavy HTML survives 1:1 (see quoted-html.ts). // node so layout-heavy HTML survives 1:1 (see quoted-html.ts).
QuotedHtml, 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, content,
editorProps: { 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>`;
}
-138
View File
@@ -1,138 +0,0 @@
"use client";
import React from "react";
import { ShieldCheck, ShieldAlert, ShieldX, Lock, LockOpen, AlertTriangle, Info } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import type { SmimeStatus } from "@/lib/smime/types";
interface SmimeStatusBannerProps {
status: SmimeStatus;
onUnlockKey?: () => void;
className?: string;
}
type SmimeVariant = 'success' | 'warning' | 'error' | 'info';
const variantTone: Record<SmimeVariant, string> = {
success: 'bg-success/15 text-success',
warning: 'bg-warning/15 text-warning',
error: 'bg-destructive/15 text-destructive',
info: 'bg-info/15 text-info',
};
export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatusBannerProps) {
const t = useTranslations('smime');
const items: Array<{
icon: React.ReactNode;
text: string;
variant: SmimeVariant;
}> = [];
// Encryption status
if (status.isEncrypted) {
if (status.decryptionError) {
if (status.decryptionError === 'locked') {
items.push({
icon: <Lock className="w-5 h-5" />,
text: t('unlock_key_desc'),
variant: 'warning',
});
} else if (status.decryptionError === 'no-key') {
items.push({
icon: <Lock className="w-5 h-5" />,
text: t('status_encrypted_no_key'),
variant: 'warning',
});
} else {
items.push({
icon: <ShieldX className="w-5 h-5" />,
text: t('status_encrypted_failed'),
variant: 'error',
});
}
} else {
items.push({
icon: <LockOpen className="w-5 h-5" />,
text: t('status_encrypted_ok'),
variant: 'success',
});
}
}
// Signature status
if (status.isSigned) {
if (status.signatureValid === true) {
if (status.selfSigned) {
items.push({
icon: <AlertTriangle className="w-5 h-5" />,
text: t('status_signed_self_signed'),
variant: 'warning',
});
} else if (status.signerEmailMatch === false) {
items.push({
icon: <AlertTriangle className="w-5 h-5" />,
text: t('status_signed_mismatch'),
variant: 'warning',
});
} else {
items.push({
icon: <ShieldCheck className="w-5 h-5" />,
text: t('status_signed_valid'),
variant: 'success',
});
}
} else if (status.signatureValid === false) {
items.push({
icon: <ShieldAlert className="w-5 h-5" />,
text: status.signatureError || t('status_signed_invalid'),
variant: 'error',
});
}
}
// Unsupported S/MIME
if (status.unsupportedReason) {
items.push({
icon: <Info className="w-5 h-5" />,
text: t('status_unsupported'),
variant: 'info',
});
}
if (items.length === 0) return null;
return (
<div className={cn("flex flex-col gap-3 py-1", className)}>
{items.map((item, i) => (
<div key={i} className="flex items-start gap-3">
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
variantTone[item.variant],
)}>
{item.icon}
</div>
<div className="flex-1 min-w-0 flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
S/MIME
</div>
<div className="text-sm font-medium text-foreground break-words">
{item.text}
</div>
</div>
{item.variant === 'warning' && status.decryptionError === 'locked' && onUnlockKey && (
<button
onClick={onUnlockKey}
className="text-xs font-medium underline hover:no-underline flex-shrink-0"
>
{t('unlock_key')}
</button>
)}
</div>
</div>
))}
</div>
);
}
+77 -34
View File
@@ -2,10 +2,10 @@
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils"; 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 { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar"; 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 { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
@@ -17,6 +17,23 @@ import { ThreadEmailItem } from "./thread-email-item";
import { EmailHoverActions } from "./email-hover-actions"; import { EmailHoverActions } from "./email-hover-actions";
import { useTranslations } from "next-intl"; 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 { interface ThreadListItemProps {
thread: ThreadGroup; thread: ThreadGroup;
isExpanded: boolean; isExpanded: boolean;
@@ -24,6 +41,7 @@ interface ThreadListItemProps {
isLoading?: boolean; isLoading?: boolean;
expandedEmails?: Email[]; expandedEmails?: Email[];
onToggleExpand: () => void; onToggleExpand: () => void;
onCollapseAllThreads?: () => void;
onEmailSelect: (email: Email) => void; onEmailSelect: (email: Email) => void;
onEmailDoubleClick?: (email: Email) => void; onEmailDoubleClick?: (email: Email) => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void; onContextMenu?: (e: React.MouseEvent, email: Email) => void;
@@ -34,6 +52,7 @@ interface ThreadListItemProps {
onArchive?: (email: Email) => void; onArchive?: (email: Email) => void;
onSetColorTag?: (emailId: string, color: string | null) => void; onSetColorTag?: (emailId: string, color: string | null) => void;
onMarkAsSpam?: (email: Email) => void; onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void;
} }
interface SingleEmailItemProps { interface SingleEmailItemProps {
@@ -50,18 +69,22 @@ interface SingleEmailItemProps {
onArchive?: () => void; onArchive?: () => void;
onSetColorTag?: (color: string | null) => void; onSetColorTag?: (color: string | null) => void;
onMarkAsSpam?: () => void; onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
} }
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>( 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 t = useTranslations('email_viewer');
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered; const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded; const isForwarded = email.keywords?.$forwarded;
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
// In Sent/Drafts folders, show recipient instead of sender (which is always "me") // In Sent/Drafts folders, show recipient instead of sender (which is always
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role; // "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 showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
@@ -70,7 +93,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const timeFormat = useSettingsStore((state) => state.timeFormat); const timeFormat = useSettingsStore((state) => state.timeFormat);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !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 getAccountById = useAccountStore((state) => state.getAccountById);
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined; const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
const isChecked = selectedEmailIds.has(email.id); const isChecked = selectedEmailIds.has(email.id);
@@ -187,7 +211,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)} )}
{isUnread && ( {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" /> <Circle className="w-2 h-2 fill-unread text-unread" />
</div> </div>
)} )}
@@ -221,13 +245,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</span> </span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm"> <div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={cn( <span className={cn(
'shrink-0 truncate', 'min-w-0 truncate',
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90' isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
)}> )}>
{email.subject || '(no subject)'} {email.subject || '(no subject)'}
</span> </span>
{inlinePreview && ( {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>
</div> </div>
@@ -245,6 +269,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{resolvedKeywordDefs.map((kd) => ( {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')} /> <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 ? ( {scheduledSendLabel ? (
<span <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" 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} {kd.label}
</span> </span>
))} ))}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
{scheduledSendLabel ? ( {scheduledSendLabel ? (
<span <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" 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} onArchive={onArchive}
onSetColorTag={onSetColorTag} onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam} onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
/> />
)} )}
</div> </div>
@@ -384,6 +412,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
isLoading = false, isLoading = false,
expandedEmails, expandedEmails,
onToggleExpand, onToggleExpand,
onCollapseAllThreads,
onEmailSelect, onEmailSelect,
onEmailDoubleClick, onEmailDoubleClick,
onContextMenu, onContextMenu,
@@ -394,6 +423,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onArchive, onArchive,
onSetColorTag, onSetColorTag,
onMarkAsSpam, onMarkAsSpam,
onUndoSpam,
}, ref) { }, ref) {
const t = useTranslations('threads'); const t = useTranslations('threads');
const tEmailViewer = useTranslations('email_viewer'); const tEmailViewer = useTranslations('email_viewer');
@@ -412,11 +442,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
? formatDateTime(latestEmail.scheduledSendAt, timeFormat) ? formatDateTime(latestEmail.scheduledSendAt, timeFormat)
: null; : 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 getAccountById = useAccountStore((state) => state.getAccountById);
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined; const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
// In Sent/Drafts folders, show recipient instead of sender (which is always "me") // In Sent/Drafts folders, show recipient instead of sender (which is always
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role; // "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 showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const displayNames = showRecipient const displayNames = showRecipient
? Array.from(new Set( ? Array.from(new Set(
@@ -470,6 +504,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onArchive={onArchive ? () => onArchive(latestEmail) : undefined} onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
/> />
); );
} }
@@ -515,6 +550,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
} else { } else {
if (selectedEmailIds.size > 0) clearSelection(); if (selectedEmailIds.size > 0) clearSelection();
if (!isExpanded) { if (!isExpanded) {
onCollapseAllThreads?.();
onToggleExpand(); onToggleExpand();
} }
onEmailSelect(latestEmail); onEmailSelect(latestEmail);
@@ -581,6 +617,21 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</button> </button>
)} )}
{hasUnread && (
<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' && (
<div className="relative flex-shrink-0">
<Avatar
name={avatarPerson?.name}
email={avatarPerson?.email}
size={isFocusedMailLayout ? "sm" : "md"}
className="shadow-sm"
disableImages={hideJunkAvatarImages}
/>
{!isMobile && !isFocusedMailLayout && ( {!isMobile && !isFocusedMailLayout && (
<button <button
data-expand-toggle data-expand-toggle
@@ -589,40 +640,28 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onToggleExpand(); onToggleExpand();
}} }}
className={cn( className={cn(
"p-1 rounded mt-2 flex-shrink-0 transition-all duration-200", "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", "hover:bg-muted/50 hover:scale-110",
"active:scale-95", "active:scale-95",
"text-muted-foreground hover:text-foreground" "text-muted-foreground hover:text-foreground",
"bg-background border border-border"
)} )}
aria-expanded={isExpanded} aria-expanded={isExpanded}
aria-label={t('toggle_thread')} aria-label={t('toggle_thread')}
> >
{isLoading ? ( {isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-3 h-3 animate-spin" />
) : isExpanded ? ( ) : isExpanded ? (
<ChevronDown className="w-4 h-4" /> <ChevronDown className="w-3 h-3" />
) : ( ) : (
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-3 h-3" />
)} )}
</button> </button>
)} )}
{hasUnread && (
<div className="absolute left-1 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-unread text-unread" />
</div> </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="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{isFocusedMailLayout ? ( {isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@@ -652,13 +691,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</span> </span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm"> <div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={cn( <span className={cn(
'shrink-0 truncate', 'min-w-0 truncate',
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90' hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
)}> )}>
{latestEmail.subject || '(no subject)'} {latestEmail.subject || '(no subject)'}
</span> </span>
{inlinePreview && ( {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>
</div> </div>
@@ -676,6 +715,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{keywordDef && ( {keywordDef && (
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} /> <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 ? ( {scheduledSendLabel ? (
<span <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" 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} {keywordDef.label}
</span> </span>
)} )}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
{scheduledSendLabel ? ( {scheduledSendLabel ? (
<span <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" 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} onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
isInJunk={currentMailboxRole === 'junk'}
/> />
)} )}
</div> </div>
@@ -9,6 +9,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { IdentityForm } from './identity-form'; import { IdentityForm } from './identity-form';
import { useIdentityStore } from '@/stores/identity-store'; import { useIdentityStore } from '@/stores/identity-store';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
function useSyncIdentities() { function useSyncIdentities() {
const syncIdentities = useAuthStore((state) => state.syncIdentities); const syncIdentities = useAuthStore((state) => state.syncIdentities);
@@ -206,6 +207,17 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
const handleSetPrimary = useCallback((identity: Identity) => { const handleSetPrimary = useCallback((identity: Identity) => {
setPreferredPrimary(identity.id); setPreferredPrimary(identity.id);
// Persist to the synced settings (keyed by username, matching how
// loadIdentities reads it back) so the choice survives a new browser /
// cleared site data and reaches other devices (#507).
const username = useAuthStore.getState().username || '';
if (username) {
const current = useSettingsStore.getState().preferredIdentityIds;
useSettingsStore.getState().updateSetting('preferredIdentityIds', {
...current,
[username]: identity.id,
});
}
// Re-sort: move the preferred identity to the front // Re-sort: move the preferred identity to the front
const reordered = [identity, ...identities.filter((id) => id.id !== identity.id)]; const reordered = [identity, ...identities.filter((id) => id.id !== identity.id)];
useIdentityStore.getState().setIdentities(reordered); useIdentityStore.getState().setIdentities(reordered);
+2 -2
View File
@@ -166,7 +166,6 @@ export function NavigationRail({
collapsed = false, collapsed = false,
className, className,
quota, quota,
isPushConnected,
onLogout, onLogout,
onShowShortcuts, onShowShortcuts,
onManageApps, onManageApps,
@@ -191,6 +190,7 @@ export function NavigationRail({
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled')); const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled')); const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : []; const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0; const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
@@ -269,7 +269,7 @@ export function NavigationRail({
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread }, { 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: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts", hidden: !supportsContacts || !contactsEnabled },
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: !supportsFiles || !filesEnabled }, { id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: !supportsFiles || !filesEnabled },
]; ];
+49 -9
View File
@@ -34,13 +34,15 @@ import {
CalendarClock, CalendarClock,
BellOff, BellOff,
Mails, Mails,
MailOpen,
} from "lucide-react"; } from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { Mailbox } from "@/lib/jmap/types"; import { Mailbox } from "@/lib/jmap/types";
import { useContextMenu } from "@/hooks/use-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu";
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu"; import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
import { useAccountStore } from '@/stores/account-store'; 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 type { UnifiedMailboxRole } from '@/lib/jmap/types';
import { useDragDropContext } from "@/contexts/drag-drop-context"; import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useMailboxDrop } from "@/hooks/use-mailbox-drop"; import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
@@ -79,6 +81,12 @@ interface SidebarProps {
showScheduledMailbox?: boolean; showScheduledMailbox?: boolean;
/** Gated "All Mail" virtual folder that merges all of the account's folders. */ /** Gated "All Mail" virtual folder that merges all of the account's folders. */
showAllMailMailbox?: boolean; 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; className?: string;
/** /**
* Multi-account (Pro) mode props. When `multiAccountMode` is true, the * Multi-account (Pro) mode props. When `multiAccountMode` is true, the
@@ -169,7 +177,6 @@ function getIconClass(isSelected: boolean, isVirtual: boolean, colorful: boolean
function SidebarRowCounts({ function SidebarRowCounts({
unread, unread,
total, total,
isSelected,
onUnreadClick, onUnreadClick,
}: { }: {
unread?: number; unread?: number;
@@ -177,8 +184,9 @@ function SidebarRowCounts({
isSelected: boolean; isSelected: boolean;
onUnreadClick?: () => void; onUnreadClick?: () => void;
}) { }) {
const showFolderTotalCount = useSettingsStore(s => s.showFolderTotalCount);
const unreadCount = unread ?? 0; const unreadCount = unread ?? 0;
const totalCount = total ?? 0; const totalCount = showFolderTotalCount ? (total ?? 0) : 0;
if (unreadCount === 0 && totalCount === 0) return null; if (unreadCount === 0 && totalCount === 0) return null;
@@ -212,7 +220,7 @@ function SidebarRowCounts({
) : null; ) : null;
return ( return (
<span className="ml-2 flex-shrink-0 flex items-baseline gap-1" title={`${unreadCount} unread / ${totalCount} total`}> <span className="ml-2 flex-shrink-0 flex items-baseline gap-1" title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}>
{unreadNode} {unreadNode}
{unreadCount > 0 && totalCount > 0 && ( {unreadCount > 0 && totalCount > 0 && (
<span className="text-xs text-muted-foreground/60">/</span> <span className="text-xs text-muted-foreground/60">/</span>
@@ -433,12 +441,14 @@ function MailboxTreeItem({
onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void; onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void;
}) { }) {
const tNotifications = useTranslations('notifications'); const tNotifications = useTranslations('notifications');
const tSidebar = useTranslations('sidebar');
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
const isExpanded = expandedFolders.has(node.id); const isExpanded = expandedFolders.has(node.id);
const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id); const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id);
const isVirtualNode = node.id.startsWith('shared-'); const isVirtualNode = node.id.startsWith('shared-');
const isSelected = selectedMailbox === node.id; const isSelected = selectedMailbox === node.id;
const roleKey = resolveRoleKey(node.role, node.name); const roleKey = resolveRoleKey(node.role, node.name);
const label = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
const { isDragging: globalDragging } = useDragDropContext(); const { isDragging: globalDragging } = useDragDropContext();
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({ const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
@@ -465,7 +475,7 @@ function MailboxTreeItem({
<> <>
<SidebarRow <SidebarRow
icon={<Icon className={getIconClass(isSelected, isVirtualNode, colorful, roleKey)} />} icon={<Icon className={getIconClass(isSelected, isVirtualNode, colorful, roleKey)} />}
label={node.name} label={label}
depth={node.depth} depth={node.depth}
isSelected={isSelected} isSelected={isSelected}
isVirtual={isVirtualNode} isVirtual={isVirtualNode}
@@ -682,6 +692,10 @@ export function Sidebar({
scheduledTotal = 0, scheduledTotal = 0,
showScheduledMailbox = false, showScheduledMailbox = false,
showAllMailMailbox = false, showAllMailMailbox = false,
showCrossUnread = false,
showCrossStarred = false,
showCrossAll = false,
crossUnreadCount = 0,
className, className,
multiAccountMode = false, multiAccountMode = false,
accountMailboxes, accountMailboxes,
@@ -797,8 +811,14 @@ export function Sidebar({
}); });
}; };
// When the app renders its own virtual "Scheduled" folder (for delayed
// sends, driven by EmailSubmission), hide the server-provided scheduled
// mailbox (e.g. Stalwart's auto-created Scheduled folder, role === 'scheduled')
// so it does not appear twice. (#495)
const isServerScheduledNode = (n: MailboxNode) => showScheduledMailbox && n.role === 'scheduled';
const mailboxTree = buildMailboxTree(mailboxes); const mailboxTree = buildMailboxTree(mailboxes);
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-')); const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-') && !isServerScheduledNode(n));
const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-')); const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-'));
// Multi-account mode (Pro shell): render every connected account as its // Multi-account mode (Pro shell): render every connected account as its
@@ -813,7 +833,7 @@ export function Sidebar({
? mailboxes ? mailboxes
: (accountMailboxes?.[account.id] ?? []); : (accountMailboxes?.[account.id] ?? []);
const tree = buildMailboxTree(accountMailboxList).filter( const tree = buildMailboxTree(accountMailboxList).filter(
(n) => !n.id.startsWith('shared-account-') (n) => !n.id.startsWith('shared-account-') && !(isActive && isServerScheduledNode(n))
); );
return { account, isActive, tree }; return { account, isActive, tree };
}) })
@@ -992,7 +1012,7 @@ export function Sidebar({
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
/> />
)} )}
{showUnified && ( {(showUnified || showCrossUnread || showCrossStarred || showCrossAll) && (
<div> <div>
<SidebarSectionHeader <SidebarSectionHeader
label={t("all_accounts")} label={t("all_accounts")}
@@ -1003,7 +1023,7 @@ export function Sidebar({
/> />
{((unifiedExpanded && !isCollapsed) || isCollapsed) && ( {((unifiedExpanded && !isCollapsed) || isCollapsed) && (
<> <>
{unifiedCounts.map((count) => { {showUnified && unifiedCounts.map((count) => {
const unifiedId = UNIFIED_MAILBOX_IDS[count.role]; const unifiedId = UNIFIED_MAILBOX_IDS[count.role];
const Icon = getUnifiedIcon(count.role); const Icon = getUnifiedIcon(count.role);
const isSelected = !selectedKeyword && selectedMailbox === unifiedId; const isSelected = !selectedKeyword && selectedMailbox === unifiedId;
@@ -1021,6 +1041,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> </div>
@@ -52,6 +52,7 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
slot, slot,
code: active.code, code: active.code,
locale, locale,
tier: active.tier,
extraProps: extraProps ?? {}, extraProps: extraProps ?? {},
hostContainer: wrapperRef.current, hostContainer: wrapperRef.current,
onResize: (h) => setHeight(h), onResize: (h) => setHeight(h),
+1 -3
View File
@@ -25,11 +25,9 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
const t = useTranslations(); const t = useTranslations();
const client = useAuthStore((s) => s.client); const client = useAuthStore((s) => s.client);
const sendEmail = useEmailStore((s) => s.sendEmail); const sendEmail = useEmailStore((s) => s.sendEmail);
const fetchEmails = useEmailStore((s) => s.fetchEmails);
const refreshCurrentMailbox = useEmailStore((s) => s.refreshCurrentMailbox); const refreshCurrentMailbox = useEmailStore((s) => s.refreshCurrentMailbox);
const fetchScheduledEmails = useEmailStore((s) => s.fetchScheduledEmails); const fetchScheduledEmails = useEmailStore((s) => s.fetchScheduledEmails);
const refreshScheduledMetadata = useEmailStore((s) => s.refreshScheduledMetadata); const refreshScheduledMetadata = useEmailStore((s) => s.refreshScheduledMetadata);
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
const isScheduledView = useEmailStore((s) => s.isScheduledView); const isScheduledView = useEmailStore((s) => s.isScheduledView);
const closeTab = useProTabStore((s) => s.closeTab); const closeTab = useProTabStore((s) => s.closeTab);
const updateTabTitle = useProTabStore((s) => s.updateTabTitle); const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
@@ -116,7 +114,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
console.error('Failed to send email:', error); console.error('Failed to send email:', error);
toast.error(t('notifications.error_sending')); 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(() => { const handleClose = useCallback(() => {
closeTab(tabIdRef.current); closeTab(tabIdRef.current);
+5 -1
View File
@@ -8,6 +8,7 @@ import daMessages from '@/locales/da/common.json';
import deMessages from '@/locales/de/common.json'; import deMessages from '@/locales/de/common.json';
import enMessages from '@/locales/en/common.json'; import enMessages from '@/locales/en/common.json';
import esMessages from '@/locales/es/common.json'; import esMessages from '@/locales/es/common.json';
import faMessages from '@/locales/fa/common.json';
import frMessages from '@/locales/fr/common.json'; import frMessages from '@/locales/fr/common.json';
import huMessages from '@/locales/hu/common.json'; import huMessages from '@/locales/hu/common.json';
import itMessages from '@/locales/it/common.json'; import itMessages from '@/locales/it/common.json';
@@ -17,6 +18,7 @@ import lvMessages from '@/locales/lv/common.json';
import nlMessages from '@/locales/nl/common.json'; import nlMessages from '@/locales/nl/common.json';
import plMessages from '@/locales/pl/common.json'; import plMessages from '@/locales/pl/common.json';
import ptMessages from '@/locales/pt/common.json'; import ptMessages from '@/locales/pt/common.json';
import roMessages from '@/locales/ro/common.json';
import ruMessages from '@/locales/ru/common.json'; import ruMessages from '@/locales/ru/common.json';
import trMessages from '@/locales/tr/common.json'; import trMessages from '@/locales/tr/common.json';
import ukMessages from '@/locales/uk/common.json'; import ukMessages from '@/locales/uk/common.json';
@@ -29,6 +31,7 @@ const ALL_MESSAGES = {
de: deMessages, de: deMessages,
en: enMessages, en: enMessages,
es: esMessages, es: esMessages,
fa: faMessages,
fr: frMessages, fr: frMessages,
hu: huMessages, hu: huMessages,
it: itMessages, it: itMessages,
@@ -38,6 +41,7 @@ const ALL_MESSAGES = {
nl: nlMessages, nl: nlMessages,
pl: plMessages, pl: plMessages,
pt: ptMessages, pt: ptMessages,
ro: roMessages,
ru: ruMessages, ru: ruMessages,
tr: trMessages, tr: trMessages,
uk: ukMessages, uk: ukMessages,
@@ -88,7 +92,7 @@ export function IntlProvider({ locale: initialLocale, children }: IntlProviderPr
return ( return (
<NextIntlClientProvider <NextIntlClientProvider
locale={activeLocale} 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} timeZone={timeZone}
> >
{children} {children}
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslations } from "next-intl"; 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 { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useManagedAccountStore } from "@/stores/managed-account-store"; import { useManagedAccountStore } from "@/stores/managed-account-store";
@@ -11,6 +11,7 @@ import { SettingsSection } from "./settings-section";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { AddressBook, AddressBookRights } from "@/lib/jmap/types"; import type { AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "./share-collection-dialog"; import { ShareCollectionDialog } from "./share-collection-dialog";
import { RenameDialog } from "@/components/files/rename-dialog";
function AddressBookEditRow({ function AddressBookEditRow({
initial, initial,
@@ -73,10 +74,11 @@ export function AddressBookManagementSettings() {
const tSettings = useTranslations("settings.contacts"); const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore(); const { client } = useAuthStore();
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId); const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore(); const { addressBooks, contacts, supportsSync, fetchAddressBooks, createAddressBook, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [editingKeyword, setEditingKeyword] = useState<string | null>(null); const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
const [sharingId, setSharingId] = useState<string | null>(null); const [sharingId, setSharingId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
useEffect(() => { useEffect(() => {
@@ -85,6 +87,21 @@ export function AddressBookManagementSettings() {
} }
}, [client, addressBooks.length, fetchAddressBooks]); }, [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) => { const handleUpdate = async (book: AddressBook, newName: string) => {
if (!client) return; if (!client) return;
setIsLoading(true); setIsLoading(true);
@@ -226,6 +243,19 @@ export function AddressBookManagementSettings() {
{addressBooks.length === 0 && ( {addressBooks.length === 0 && (
<p className="text-sm text-muted-foreground py-2">{tSettings("no_address_books")}</p> <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> </div>
</SettingsSection> </SettingsSection>
@@ -278,6 +308,16 @@ export function AddressBookManagementSettings() {
</div> </div>
)} )}
{creating && (
<RenameDialog
currentName=""
title={t("create")}
label={t("name_label")}
onCancel={() => setCreating(false)}
onConfirm={handleCreate}
/>
)}
{sharingId && client && (() => { {sharingId && client && (() => {
const book = addressBooks.find((b) => b.id === sharingId); const book = addressBooks.find((b) => b.id === sharingId);
if (!book) return null; if (!book) return null;
+17
View File
@@ -160,6 +160,7 @@ export function FilterSettings() {
const tNotifications = useTranslations("notifications"); const tNotifications = useTranslations("notifications");
const { client } = useAuthStore(); const { client } = useAuthStore();
const storeMailboxes = useEmailStore((s) => s.mailboxes); const storeMailboxes = useEmailStore((s) => s.mailboxes);
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
const expandedFilterView = useSettingsStore((s) => s.expandedFilterView); const expandedFilterView = useSettingsStore((s) => s.expandedFilterView);
const updateSetting = useSettingsStore((s) => s.updateSetting); const updateSetting = useSettingsStore((s) => s.updateSetting);
@@ -212,6 +213,22 @@ export function FilterSettings() {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [client, managedAccountId]); }, [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 mailboxes = managedAccountId ? scopedMailboxes : storeMailboxes;
const vacationStoreEnabled = useVacationStore((s) => s.isEnabled); const vacationStoreEnabled = useVacationStore((s) => s.isEnabled);
+55 -6
View File
@@ -118,27 +118,46 @@ function MailLayoutPreview({
export function LayoutSettings() { export function LayoutSettings() {
const t = useTranslations('settings.appearance'); const t = useTranslations('settings.appearance');
const tEmail = useTranslations('settings.email_behavior'); const tEmail = useTranslations('settings.email_behavior');
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore(); const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, showFolderTotalCount, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore(); const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts); const accounts = useAccountStore(s => s.accounts);
const activeAccountId = useAccountStore(s => s.activeAccountId);
const mailboxes = useEmailStore(s => s.mailboxes); const mailboxes = useEmailStore(s => s.mailboxes);
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]); const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
const allMailViewAllowed = isFeatureEnabled('allMailViewEnabled'); 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 current All Mail selection. `null` = // Own (non-shared) folders and the active account's All Mail selection. The
// never configured, which defaults to all non-special (no-role) folders. // 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 ownMailboxes = useMemo(() => mailboxes.filter(m => !m.isShared), [mailboxes]);
const currentAllMailEntry = activeAccountId ? allMailFolderIds[activeAccountId] : undefined;
const allMailSelected = new Set( const allMailSelected = new Set(
allMailFolderIds === null currentAllMailEntry === undefined
? ownMailboxes.filter(m => !m.role).map(m => m.id) ? ownMailboxes.filter(m => !m.role).map(m => m.id)
: allMailFolderIds : currentAllMailEntry
); );
const toggleAllMailFolder = (id: string) => { const toggleAllMailFolder = (id: string) => {
if (!activeAccountId) return;
const next = new Set(allMailSelected); const next = new Set(allMailSelected);
if (next.has(id)) next.delete(id); if (next.has(id)) next.delete(id);
else next.add(id); else next.add(id);
updateSetting('allMailFolderIds', ownMailboxes.filter(m => next.has(m.id)).map(m => m.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 ( return (
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
@@ -198,6 +217,13 @@ export function LayoutSettings() {
/> />
</SettingItem> </SettingItem>
<SettingItem label={t('show_folder_total_count.label')} description={t('show_folder_total_count.description')}>
<ToggleSwitch
checked={showFolderTotalCount}
onChange={(checked) => updateSetting('showFolderTotalCount', checked)}
/>
</SettingItem>
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && ( {(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
<SettingItem <SettingItem
label={t('unified_mailbox.label')} label={t('unified_mailbox.label')}
@@ -226,6 +252,26 @@ export function LayoutSettings() {
</div> </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') && ( {allMailViewAllowed && !isSettingHidden('enableAllMailView') && (
<SettingItem <SettingItem
label={t('all_mail.label')} label={t('all_mail.label')}
@@ -244,6 +290,9 @@ export function LayoutSettings() {
<div> <div>
<div className="text-sm font-medium text-foreground">{t('all_mail.folders_label')}</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> <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> </div>
{ownMailboxes.length === 0 ? ( {ownMailboxes.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('all_mail.no_folders')}</p> <p className="text-xs text-muted-foreground">{t('all_mail.no_folders')}</p>
+8
View File
@@ -22,6 +22,7 @@ export function ReadingSettings() {
markAsReadDelay, markAsReadDelay,
deleteAction, deleteAction,
permanentlyDeleteJunk, permanentlyDeleteJunk,
returnToListAfterAction,
showPreview, showPreview,
mailLayout, mailLayout,
disableThreading, disableThreading,
@@ -176,6 +177,13 @@ export function ReadingSettings() {
/> />
</SettingItem> </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') && ( {!isSettingHidden('showPreview') && (
<SettingItem <SettingItem
label={t('show_preview.label')} label={t('show_preview.label')}
@@ -1,117 +0,0 @@
"use client";
import { useId } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { ShieldCheck, X } from "lucide-react";
import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types";
interface SmimeCertificateModalProps {
isOpen: boolean;
onClose: () => void;
record: SmimeKeyRecord | SmimePublicCert | null;
type: "private" | "public";
}
export function SmimeCertificateModal({
isOpen,
onClose,
record,
type: _type,
}: SmimeCertificateModalProps) {
const t = useTranslations("smime");
const id = useId();
const dialogRef = useFocusTrap({
isActive: isOpen,
onEscape: onClose,
restoreFocus: true,
});
if (!isOpen || !record) return null;
const isExpired = new Date(record.notAfter) < new Date();
const isNotYetValid = new Date(record.notBefore) > new Date();
const rows: { label: string; value: string }[] = [
{ label: t("cert_subject"), value: record.subject ?? "" },
{ label: t("cert_issuer"), value: record.issuer ?? "" },
{ label: t("cert_email"), value: record.email },
{
label: t("cert_validity"),
value: `${new Date(record.notBefore).toLocaleDateString()} - ${new Date(record.notAfter).toLocaleDateString()}`,
},
{ label: t("cert_fingerprint"), value: record.fingerprint },
];
if ("serialNumber" in record) {
rows.splice(2, 0, { label: t("cert_serial"), value: record.serialNumber });
}
if ("algorithm" in record) {
rows.push({ label: t("cert_algorithm"), value: record.algorithm });
}
if ("capabilities" in record) {
const caps: string[] = [];
if (record.capabilities.canSign) caps.push(t("cap_sign"));
if (record.capabilities.canEncrypt) caps.push(t("cap_encrypt"));
rows.push({ label: t("cert_capabilities"), value: caps.join(", ") || t("cap_none") });
}
if ("source" in record) {
rows.push({ label: t("cert_source"), value: record.source });
}
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-lg animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between p-6 pb-4 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center">
<ShieldCheck className="w-5 h-5 text-primary" />
</div>
<h2 id={`${id}-title`} className="text-lg font-semibold text-foreground">
{t("certificate_details")}
</h2>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
<div className="p-6 space-y-3 max-h-[60vh] overflow-y-auto">
{(isExpired || isNotYetValid) && (
<div className="px-3 py-2 rounded-md bg-destructive/10 text-destructive text-sm">
{isExpired ? t("cert_expired") : t("cert_not_yet_valid")}
</div>
)}
{rows.map(({ label, value }) => (
<div key={label}>
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{label}
</dt>
<dd className="text-sm text-foreground mt-0.5 break-all font-mono">
{value}
</dd>
</div>
))}
</div>
<div className="flex justify-end px-6 pb-6">
<Button variant="ghost" onClick={onClose}>
{t("close")}
</Button>
</div>
</div>
</div>
);
}
@@ -1,169 +0,0 @@
"use client";
import { useState, useId } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { KeyRound, Eye, EyeOff } from "lucide-react";
interface SmimePassphraseDialogProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (passphrase: string) => void | Promise<void>;
title: string;
description?: string;
submitText?: string;
error?: string | null;
/** Show a second passphrase field for import/export confirmation. */
showConfirm?: boolean;
}
export function SmimePassphraseDialog({
isOpen,
onClose,
onSubmit,
title,
description,
submitText,
error,
showConfirm = false,
}: SmimePassphraseDialogProps) {
const t = useTranslations("smime");
const id = useId();
const [passphrase, setPassphrase] = useState("");
const [confirm, setConfirm] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const dialogRef = useFocusTrap({
isActive: isOpen,
onEscape: onClose,
restoreFocus: true,
});
if (!isOpen) return null;
const mismatch = showConfirm && passphrase !== confirm && confirm.length > 0;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!passphrase || (showConfirm && passphrase !== confirm)) return;
setIsSubmitting(true);
try {
await onSubmit(passphrase);
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
setPassphrase("");
setConfirm("");
setShowPassword(false);
onClose();
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
aria-describedby={description ? `${id}-desc` : undefined}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
>
<form onSubmit={handleSubmit}>
<div className="p-6">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<KeyRound className="w-5 h-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h2
id={`${id}-title`}
className="text-lg font-semibold text-foreground"
>
{title}
</h2>
{description && (
<p
id={`${id}-desc`}
className="text-sm text-muted-foreground mt-1"
>
{description}
</p>
)}
</div>
</div>
<div className="mt-4 space-y-3">
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder={t("passphrase_placeholder")}
autoFocus
className="pr-10"
autoComplete="off"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
aria-label={showPassword ? t("hide_passphrase") : t("show_passphrase")}
>
{showPassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
{showConfirm && (
<div>
<Input
type={showPassword ? "text" : "password"}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder={t("confirm_passphrase_placeholder")}
autoComplete="off"
/>
{mismatch && (
<p className="text-xs text-destructive mt-1">
{t("passphrase_mismatch")}
</p>
)}
</div>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
</div>
<div className="flex justify-end gap-2 px-6 pb-6">
<Button
type="button"
variant="ghost"
onClick={handleClose}
disabled={isSubmitting}
>
{t("cancel")}
</Button>
<Button
type="submit"
disabled={!passphrase || isSubmitting || (showConfirm && passphrase !== confirm)}
>
{isSubmitting ? t("processing") : (submitText ?? t("unlock"))}
</Button>
</div>
</form>
</div>
</div>
);
}
-549
View File
@@ -1,549 +0,0 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import {
Upload,
Trash2,
Eye,
Lock,
Unlock,
Download,
ShieldCheck,
ShieldAlert,
Users,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsSection, SettingItem, ToggleSwitch } from "@/components/settings/settings-section";
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
import { SmimeCertificateModal } from "@/components/settings/smime-certificate-modal";
import { useSmimeStore } from "@/stores/smime-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useAuthStore } from "@/stores/auth-store";
import { exportPkcs12, downloadPkcs12 } from "@/lib/smime/pkcs12-export";
import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types";
export function SmimeSettings() {
const t = useTranslations("smime");
const {
keyRecords,
publicCerts,
identityKeyBindings,
defaultSignIdentity,
defaultEncrypt,
autoImportSignerCerts,
isLoading,
error,
load,
importPKCS12,
removeKeyRecord,
removePublicCert,
bindIdentityToKey,
unlockKey,
lockKey,
setSignDefault,
setEncryptDefault,
setAutoImportSignerCerts,
isKeyUnlocked,
setError,
} = useSmimeStore();
const { identities } = useIdentityStore();
const activeAccountId = useAuthStore((s) => s.activeAccountId);
// Local UI state
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [unlockDialogOpen, setUnlockDialogOpen] = useState(false);
const [unlockTargetId, setUnlockTargetId] = useState<string | null>(null);
const [certModalRecord, setCertModalRecord] = useState<SmimeKeyRecord | SmimePublicCert | null>(null);
const [certModalType, setCertModalType] = useState<"private" | "public">("private");
const [importError, setImportError] = useState<string | null>(null);
const [unlockError, setUnlockError] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<ArrayBuffer | null>(null);
const [pendingP12Pass, setPendingP12Pass] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const pubCertInputRef = useRef<HTMLInputElement>(null);
// State for the two-step PKCS#12 flow
const [importStep, setImportStep] = useState<"p12" | "storage">("p12");
// Export flow state
const [exportDialogOpen, setExportDialogOpen] = useState(false);
const [exportTargetRecord, setExportTargetRecord] = useState<SmimeKeyRecord | null>(null);
const [exportStep, setExportStep] = useState<"storage" | "export">("storage");
const [exportStoragePass, setExportStoragePass] = useState("");
const [exportError, setExportError] = useState<string | null>(null);
useEffect(() => {
load(activeAccountId ?? undefined);
}, [load, activeAccountId]);
// ── PKCS#12 import flow ────────────────────────────────────────
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
setPendingFile(reader.result as ArrayBuffer);
setImportStep("p12");
setImportError(null);
setImportDialogOpen(true);
};
reader.readAsArrayBuffer(file);
// Reset so same file can be re-selected
e.target.value = "";
};
const handleImportSubmit = async (passphrase: string) => {
if (importStep === "p12") {
setPendingP12Pass(passphrase);
setImportStep("storage");
setImportError(null);
return;
}
// Storage passphrase step
if (!pendingFile) return;
try {
await importPKCS12(pendingFile, pendingP12Pass, passphrase);
setImportDialogOpen(false);
setPendingFile(null);
setPendingP12Pass("");
setImportError(null);
} catch (err) {
setImportError(err instanceof Error ? err.message : "Import failed");
}
};
// ── Public cert import ─────────────────────────────────────────
const handlePublicCertFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
try {
const store = useSmimeStore.getState();
await store.importPublicCert(reader.result as ArrayBuffer, "manual");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to import certificate");
}
};
reader.readAsArrayBuffer(file);
e.target.value = "";
};
// ── Unlock ─────────────────────────────────────────────────────
const handleUnlockRequest = (id: string) => {
setUnlockTargetId(id);
setUnlockError(null);
setUnlockDialogOpen(true);
};
const handleUnlockSubmit = async (passphrase: string) => {
if (!unlockTargetId) return;
try {
await unlockKey(unlockTargetId, passphrase);
setUnlockDialogOpen(false);
setUnlockTargetId(null);
setUnlockError(null);
} catch (err) {
setUnlockError(err instanceof Error ? err.message : "Unlock failed");
}
};
// ── Export flow ────────────────────────────────────────────────
const handleExportRequest = (record: SmimeKeyRecord) => {
setExportTargetRecord(record);
setExportStep("storage");
setExportStoragePass("");
setExportError(null);
setExportDialogOpen(true);
};
const handleExportSubmit = async (passphrase: string) => {
if (!exportTargetRecord) return;
if (exportStep === "storage") {
// Verify storage passphrase by attempting to decrypt
try {
const { decryptPrivateKeyBytes } = await import("@/lib/smime/pkcs12-import");
await decryptPrivateKeyBytes(exportTargetRecord, passphrase);
setExportStoragePass(passphrase);
setExportStep("export");
setExportError(null);
} catch {
setExportError(t("incorrect_passphrase"));
}
return;
}
// Export passphrase step
try {
const p12Bytes = await exportPkcs12(exportTargetRecord, exportStoragePass, passphrase);
const filename = `${exportTargetRecord.email.replace(/[^a-zA-Z0-9.-]/g, '_')}.p12`;
downloadPkcs12(p12Bytes, filename);
setExportDialogOpen(false);
setExportTargetRecord(null);
setExportStoragePass("");
setExportError(null);
} catch (err) {
setExportError(err instanceof Error ? err.message : "Export failed");
}
};
// ── Helpers ────────────────────────────────────────────────────
const isExpired = (dateStr: string) => new Date(dateStr) < new Date();
const formatDate = (dateStr: string) => {
try {
return new Date(dateStr).toLocaleDateString();
} catch {
return dateStr;
}
};
const getBoundIdentityNames = (keyId: string): string[] => {
return Object.entries(identityKeyBindings)
.filter(([, kId]) => kId === keyId)
.map(([identityId]) => {
const identity = identities.find((i) => i.id === identityId);
return identity?.email ?? identityId;
});
};
return (
<div className="space-y-8">
{error && (
<div className="px-4 py-3 rounded-md bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{/* ── Your Certificates ──────────────────────────────────── */}
<SettingsSection
title={t("your_certificates")}
description={t("your_certificates_desc")}
>
<div className="space-y-2">
{keyRecords.map((record) => {
const expired = isExpired(record.notAfter);
const unlocked = isKeyUnlocked(record.id);
const boundIdentities = getBoundIdentityNames(record.id);
return (
<div
key={record.id}
className="flex items-center justify-between p-3 rounded-lg border border-border"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${expired ? "bg-destructive/10" : "bg-primary/10"}`}>
{expired ? (
<ShieldAlert className="w-4 h-4 text-destructive" />
) : (
<ShieldCheck className="w-4 h-4 text-primary" />
)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{record.email || record.subject}
</p>
<p className="text-xs text-muted-foreground">
{record.issuer} · {t("expires")} {formatDate(record.notAfter)}
{expired && <span className="text-destructive ml-1">({t("expired")})</span>}
</p>
{boundIdentities.length > 0 && (
<p className="text-xs text-muted-foreground">
{t("bound_to")}: {boundIdentities.join(", ")}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1">
{unlocked ? (
<Button
variant="ghost"
size="icon"
onClick={() => lockKey(record.id)}
title={t("lock")}
>
<Unlock className="w-4 h-4 text-green-600" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
onClick={() => handleUnlockRequest(record.id)}
title={t("unlock")}
>
<Lock className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => {
setCertModalRecord(record);
setCertModalType("private");
}}
title={t("details")}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleExportRequest(record)}
title={t("export")}
>
<Download className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => removeKeyRecord(record.id)}
title={t("delete")}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
</div>
);
})}
{keyRecords.length === 0 && !isLoading && (
<p className="text-sm text-muted-foreground py-4 text-center">
{t("no_certificates")}
</p>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept=".p12,.pfx"
className="hidden"
onChange={handleFileSelect}
/>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
className="mt-2"
>
<Upload className="w-4 h-4 mr-2" />
{t("import_pkcs12")}
</Button>
</SettingsSection>
{/* ── Recipient Certificates ─────────────────────────────── */}
<SettingsSection
title={t("recipient_certificates")}
description={t("recipient_certificates_desc")}
>
<div className="space-y-2">
{publicCerts.map((cert) => {
const expired = isExpired(cert.notAfter);
return (
<div
key={cert.id}
className="flex items-center justify-between p-3 rounded-lg border border-border"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
<Users className="w-4 h-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{cert.email || cert.subject}
</p>
<p className="text-xs text-muted-foreground">
{cert.issuer} · {cert.source}
{expired && <span className="text-destructive ml-1">({t("expired")})</span>}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => {
setCertModalRecord(cert);
setCertModalType("public");
}}
title={t("details")}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => removePublicCert(cert.id)}
title={t("delete")}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
</div>
);
})}
{publicCerts.length === 0 && !isLoading && (
<p className="text-sm text-muted-foreground py-4 text-center">
{t("no_recipient_certs")}
</p>
)}
</div>
<input
ref={pubCertInputRef}
type="file"
accept=".pem,.cer,.crt,.der"
className="hidden"
onChange={handlePublicCertFile}
/>
<Button
variant="outline"
onClick={() => pubCertInputRef.current?.click()}
disabled={isLoading}
className="mt-2"
>
<Upload className="w-4 h-4 mr-2" />
{t("import_public_cert")}
</Button>
</SettingsSection>
{/* ── Identity Bindings ──────────────────────────────────── */}
{identities.length > 0 && keyRecords.length > 0 && (
<SettingsSection
title={t("identity_bindings")}
description={t("identity_bindings_desc")}
>
{identities.map((identity) => {
const boundKeyId = identityKeyBindings[identity.id];
return (
<SettingItem key={identity.id} label={identity.email}>
<select
value={boundKeyId ?? ""}
onChange={(e) =>
bindIdentityToKey(identity.id, e.target.value || null)
}
className="text-sm bg-background border border-border rounded-md px-2 py-1"
>
<option value="">{t("no_key_bound")}</option>
{keyRecords.map((kr) => (
<option key={kr.id} value={kr.id}>
{kr.email} ({kr.algorithm})
</option>
))}
</select>
</SettingItem>
);
})}
</SettingsSection>
)}
{/* ── Defaults ───────────────────────────────────────────── */}
<SettingsSection
title={t("defaults_title")}
description={t("defaults_desc")}
>
<SettingItem
label={t("encrypt_by_default")}
description={t("encrypt_by_default_desc")}
>
<ToggleSwitch
checked={defaultEncrypt}
onChange={setEncryptDefault}
/>
</SettingItem>
<SettingItem
label={t("auto_import_signer_certs")}
description={t("auto_import_signer_certs_desc")}
>
<ToggleSwitch
checked={autoImportSignerCerts}
onChange={setAutoImportSignerCerts}
/>
</SettingItem>
{identities.map((identity) => {
const bound = identityKeyBindings[identity.id];
if (!bound) return null;
return (
<SettingItem
key={identity.id}
label={`${t("sign_default_for")} ${identity.email}`}
>
<ToggleSwitch
checked={defaultSignIdentity[identity.id] ?? false}
onChange={(v) => setSignDefault(identity.id, v)}
/>
</SettingItem>
);
})}
</SettingsSection>
{/* ── Dialogs ────────────────────────────────────────────── */}
<SmimePassphraseDialog
isOpen={importDialogOpen}
onClose={() => {
setImportDialogOpen(false);
setPendingFile(null);
setPendingP12Pass("");
setImportError(null);
setImportStep("p12");
}}
onSubmit={handleImportSubmit}
title={importStep === "p12" ? t("enter_p12_passphrase") : t("enter_storage_passphrase")}
description={importStep === "p12" ? t("p12_passphrase_desc") : t("storage_passphrase_desc")}
submitText={importStep === "p12" ? t("next") : t("import")}
error={importError}
showConfirm={importStep === "storage"}
/>
<SmimePassphraseDialog
isOpen={unlockDialogOpen}
onClose={() => {
setUnlockDialogOpen(false);
setUnlockTargetId(null);
setUnlockError(null);
}}
onSubmit={handleUnlockSubmit}
title={t("unlock_key")}
description={t("unlock_key_desc")}
error={unlockError}
/>
<SmimeCertificateModal
isOpen={!!certModalRecord}
onClose={() => setCertModalRecord(null)}
record={certModalRecord}
type={certModalType}
/>
<SmimePassphraseDialog
isOpen={exportDialogOpen}
onClose={() => {
setExportDialogOpen(false);
setExportTargetRecord(null);
setExportStoragePass("");
setExportError(null);
setExportStep("storage");
}}
onSubmit={handleExportSubmit}
title={exportStep === "storage" ? t("enter_storage_passphrase") : t("enter_export_passphrase")}
description={exportStep === "storage" ? t("export_storage_desc") : t("export_passphrase_desc")}
submitText={exportStep === "storage" ? t("next") : t("export")}
error={exportError}
showConfirm={exportStep === "export"}
/>
</div>
);
}
+3 -1
View File
@@ -6,6 +6,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { useWebDAVStore } from "@/stores/webdav-store"; import { useWebDAVStore } from "@/stores/webdav-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { getTourSteps, type TourStep } from "./tour-steps"; import { getTourSteps, type TourStep } from "./tour-steps";
import { TourOverlay } from "./tour-overlay"; import { TourOverlay } from "./tour-overlay";
@@ -38,6 +39,7 @@ export function TourProvider({ children }: { children: ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const { isDemoMode } = useAuthStore(); const { isDemoMode } = useAuthStore();
const { supportsCalendar } = useCalendarStore(); const { supportsCalendar } = useCalendarStore();
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
const { supportsWebDAV } = useWebDAVStore(); const { supportsWebDAV } = useWebDAVStore();
const tourCompleted = useSettingsStore((s) => s.tourCompleted); const tourCompleted = useSettingsStore((s) => s.tourCompleted);
const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices); const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices);
@@ -47,7 +49,7 @@ export function TourProvider({ children }: { children: ReactNode }) {
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
const [hasCompletedTour, setHasCompletedTour] = useState(false); const [hasCompletedTour, setHasCompletedTour] = useState(false);
const steps = getTourSteps({ isDemoMode, supportsCalendar, supportsWebDAV: supportsWebDAV !== false }); const steps = getTourSteps({ isDemoMode, supportsCalendar: supportsCalendar && calendarEnabled, supportsWebDAV: supportsWebDAV !== false });
useEffect(() => { useEffect(() => {
// One-time migration: if the legacy per-device flag is set but the synced // One-time migration: if the legacy per-device flag is set but the synced
+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 */ /** Map locale codes to flag components */
export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = { export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = {
cs: FlagCS, cs: FlagCS,
@@ -238,8 +261,10 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
nl: FlagNL, nl: FlagNL,
pl: FlagPL, pl: FlagPL,
pt: FlagBR, pt: FlagBR,
ro: FlagRO,
ru: FlagRU, ru: FlagRU,
tr: FlagTR, tr: FlagTR,
uk: FlagUA, uk: FlagUA,
zh: FlagCN, zh: FlagCN,
fa: FlagIR,
}; };
+2
View File
@@ -12,6 +12,7 @@ const languages = [
{ value: 'da', label: 'Dansk' }, { value: 'da', label: 'Dansk' },
{ value: 'de', label: 'Deutsch' }, { value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' }, { value: 'en', label: 'English' },
{ value: 'fa', label: 'فارسی' },
{ value: 'es', label: 'Español' }, { value: 'es', label: 'Español' },
{ value: 'fr', label: 'Français' }, { value: 'fr', label: 'Français' },
{ value: 'it', label: 'Italiano' }, { value: 'it', label: 'Italiano' },
@@ -20,6 +21,7 @@ const languages = [
{ value: 'nl', label: 'Nederlands' }, { value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' }, { value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' }, { value: 'pt', label: 'Português' },
{ value: 'ro', label: 'Română' },
{ value: 'tr', label: 'Türkçe' }, { value: 'tr', label: 'Türkçe' },
{ value: 'ru', label: 'Русский' }, { value: 'ru', label: 'Русский' },
{ value: 'uk', label: 'Українська' }, { value: 'uk', label: 'Українська' },
+1 -1
View File
@@ -222,7 +222,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
} finally { } finally {
endDrag(); 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(); const valid = isValidTarget();
+6
View File
@@ -23,6 +23,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'es': case 'es':
messages = (await import('../locales/es/common.json')).default; messages = (await import('../locales/es/common.json')).default;
break; break;
case 'fa':
messages = (await import('../locales/fa/common.json')).default;
break;
case 'fr': case 'fr':
messages = (await import('../locales/fr/common.json')).default; messages = (await import('../locales/fr/common.json')).default;
break; break;
@@ -50,6 +53,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'pt': case 'pt':
messages = (await import('../locales/pt/common.json')).default; messages = (await import('../locales/pt/common.json')).default;
break; break;
case 'ro':
messages = (await import('../locales/ro/common.json')).default;
break;
case 'ru': case 'ru':
messages = (await import('../locales/ru/common.json')).default; messages = (await import('../locales/ru/common.json')).default;
break; break;
+1 -1
View File
@@ -12,7 +12,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'always' | 'always'
| 'as-needed'; | '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 // 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 // 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();
});
});
+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); expect(Object.keys(map)).toHaveLength(3);
const org = map['organizer']; // Entries are keyed by generated UUIDs (RFC 8984 participant ids), so
expect(org.name).toBe('Alice'); // look them up by identity rather than by a fixed key.
expect(org.email).toBe('alice@example.com'); const entries = Object.values(map);
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 att0 = map['attendee-0']; const org = entries.find(p => p.roles?.owner);
expect(att0.name).toBe('Bob'); expect(org).toBeDefined();
expect(att0.email).toBe('bob@example.com'); expect(org!.name).toBe('Alice');
expect(att0.roles).toEqual({ attendee: true }); expect(org!.email).toBe('alice@example.com');
expect(att0.participationStatus).toBe('needs-action'); expect(org!.roles).toEqual({ owner: true, attendee: true });
expect(att0.scheduleAgent).toBe('server'); expect(org!.participationStatus).toBe('accepted');
expect(att0.expectReply).toBe(true); expect(org!.scheduleAgent).toBe('server');
expect(org!.sendTo).toEqual({ imip: 'mailto:alice@example.com' });
expect(org!.expectReply).toBe(false);
const att1 = map['attendee-1']; const att0 = entries.find(p => p.email === 'bob@example.com');
expect(att1.name).toBe('Carol'); expect(att0).toBeDefined();
expect(att1.email).toBe('carol@example.com'); 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', () => { it('creates only organizer when no attendees', () => {
@@ -302,7 +307,9 @@ describe('buildParticipantMap', () => {
[] []
); );
expect(Object.keys(map)).toHaveLength(1); 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', () => { 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 type { CalendarEvent } from '@/lib/jmap/types';
import { import {
buildTimedFullDayWeekSegments, buildTimedFullDayWeekSegments,
@@ -14,6 +14,18 @@ import {
normalizeAllDayDuration, normalizeAllDayDuration,
} from '../calendar-utils'; } 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) { 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.getFullYear()).toBe(year);
expect(date.getMonth()).toBe(month - 1); 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({ expect(getTimedEventBoundsForDay(event, new Date('2026-03-14T00:00:00Z'))).toMatchObject({
startMinutes: 1380, startMinutes: 1320,
endMinutes: 1440, endMinutes: 1440,
continuesBefore: false, continuesBefore: false,
continuesAfter: true, continuesAfter: true,
@@ -132,7 +144,7 @@ describe('calendar-utils all-day handling', () => {
expect(getTimedEventBoundsForDay(event, new Date('2026-03-15T00:00:00Z'))).toMatchObject({ expect(getTimedEventBoundsForDay(event, new Date('2026-03-15T00:00:00Z'))).toMatchObject({
startMinutes: 0, startMinutes: 0,
endMinutes: 180, endMinutes: 120,
continuesBefore: true, continuesBefore: true,
continuesAfter: false, continuesAfter: false,
}); });
@@ -152,7 +164,7 @@ describe('calendar-utils all-day handling', () => {
expect(layout).toHaveLength(1); expect(layout).toHaveLength(1);
expect(layout[0]).toMatchObject({ expect(layout[0]).toMatchObject({
startMinutes: 0, startMinutes: 0,
endMinutes: 180, endMinutes: 120,
column: 0, column: 0,
totalColumns: 1, totalColumns: 1,
continuesBefore: true, 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: '' });
});
});
@@ -9,6 +9,7 @@ import {
parseRecipient, parseRecipient,
parseRecipientList, parseRecipientList,
formatRecipientList, formatRecipientList,
splitPastedRecipients,
} from "../email-composer-utils"; } from "../email-composer-utils";
describe("plainTextToComposerBody", () => { describe("plainTextToComposerBody", () => {
@@ -150,6 +151,16 @@ describe("splitRecipients", () => {
it("returns an empty array for an empty string", () => { it("returns an empty array for an empty string", () => {
expect(splitRecipients("")).toEqual([]); 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", () => { describe("formatRecipient / parseRecipient", () => {
@@ -198,3 +209,68 @@ describe("parseRecipientList / formatRecipientList", () => {
expect(parseRecipientList("")).toEqual([]); 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'); expect(status.color).toContain('red');
}); });
it('returns amber for softfail', () => { it('returns a warning color for softfail', () => {
const status = getSecurityStatus('softfail'); const status = getSecurityStatus('softfail');
expect(status.icon).toBe('alert'); expect(status.icon).toBe('alert');
expect(status.color).toContain('amber'); expect(status.color).toContain('warning');
}); });
it('returns amber for neutral and temperror', () => { it('returns amber for neutral and temperror', () => {
+206
View File
@@ -7,6 +7,13 @@ import {
hasRichFormatting, hasRichFormatting,
plainTextToSafeHtml, plainTextToSafeHtml,
EMAIL_SANITIZE_CONFIG, EMAIL_SANITIZE_CONFIG,
EMAIL_IFRAME_SANITIZE_CONFIG,
isExternalResourceUrl,
decodeCssEscapes,
styleHasExternalUrl,
stripExternalCssUrls,
blockExternalResourcesOnNode,
TRANSPARENT_BLOCKED_PIXEL,
} from '../email-sanitization'; } from '../email-sanitization';
describe('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', () => { describe('plainTextToSafeHtml', () => {
it('escapes HTML-special characters in surrounding text', () => { it('escapes HTML-special characters in surrounding text', () => {
const result = plainTextToSafeHtml('<script>alert(1)</script> & "q" \'q\''); 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;
+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', () => { 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(); 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
const externals = (globalThis as any).__PLUGIN_EXTERNALS__; expect((globalThis as any).__PLUGIN_EXTERNALS__).toBeUndefined();
expect(externals).toBeDefined();
expect(externals.React).toBeDefined();
expect(externals.ReactDOM).toBeDefined();
expect(externals.ReactJSX).toBeDefined();
}); });
}); });
+23 -21
View File
@@ -1,14 +1,22 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react'; import React from 'react';
import { render } from '@testing-library/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 vi.mock('@/lib/plugin-sandbox/registry', () => ({
const mockSlots: Record<string, SlotRegistration[]> = {}; offersForSlot: (slot: string) => mockOffers[slot] ?? EMPTY_OFFERS,
subscribe: () => () => {},
}));
vi.mock('@/stores/plugin-store', () => ({ vi.mock('@/components/plugins/plugin-iframe-slot', () => ({
usePluginStore: (selector: (s: { slots: typeof mockSlots }) => unknown) => PluginIframeSlot: ({ pluginId, slot }: { pluginId: string; slot: string }) =>
selector({ slots: mockSlots }), React.createElement('span', { 'data-iframe-plugin': pluginId }, `iframe:${slot}:${pluginId}`),
})); }));
// Import after mocks // Import after mocks
@@ -16,42 +24,36 @@ import { PluginSlot } from '@/components/plugins/plugin-slot';
import { PluginErrorBoundary } from '@/components/plugins/plugin-error-boundary'; import { PluginErrorBoundary } from '@/components/plugins/plugin-error-boundary';
beforeEach(() => { beforeEach(() => {
Object.keys(mockSlots).forEach(k => delete mockSlots[k]); Object.keys(mockOffers).forEach(k => delete mockOffers[k]);
}); });
describe('PluginSlot', () => { describe('PluginSlot', () => {
it('renders null when no registrations', () => { it('renders null when the slot has an empty offer list', () => {
mockSlots['toolbar-actions'] = []; mockOffers['toolbar-actions'] = [];
const { container } = render( const { container } = render(
React.createElement(PluginSlot, { name: 'toolbar-actions' }) React.createElement(PluginSlot, { name: 'toolbar-actions' })
); );
expect(container.innerHTML).toBe(''); expect(container.innerHTML).toBe('');
}); });
it('renders null when slot has undefined registrations', () => { it('renders null when the slot has no offers at all', () => {
// slot entry doesn't exist at all // slot entry doesn't exist in the registry
const { container } = render( const { container } = render(
React.createElement(PluginSlot, { name: 'toolbar-actions' }) React.createElement(PluginSlot, { name: 'toolbar-actions' })
); );
expect(container.innerHTML).toBe(''); expect(container.innerHTML).toBe('');
}); });
it('renders registered components', () => { it('renders an iframe slot per offer', () => {
const TestComponent = () => React.createElement('span', null, 'Hello Plugin'); mockOffers['email-footer'] = [{ pluginId: 'test' }];
mockSlots['email-footer'] = [
{ pluginId: 'test', component: TestComponent, order: 100 },
];
const { getByText } = render( const { getByText } = render(
React.createElement(PluginSlot, { name: 'email-footer' }) React.createElement(PluginSlot, { name: 'email-footer' })
); );
expect(getByText('Hello Plugin')).toBeTruthy(); expect(getByText('iframe:email-footer:test')).toBeTruthy();
}); });
it('sets data-plugin-slot attribute', () => { it('sets data-plugin-slot attribute', () => {
const TestComponent = () => React.createElement('span', null, 'x'); mockOffers['sidebar-widget'] = [{ pluginId: 'sw' }];
mockSlots['sidebar-widget'] = [
{ pluginId: 'sw', component: TestComponent, order: 100 },
];
const { container } = render( const { container } = render(
React.createElement(PluginSlot, { name: 'sidebar-widget' }) 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); expect(MAX_PLUGIN_SIZE).toBe(5 * 1024 * 1024);
}); });
it('MAX_THEME_SIZE is 1 MB', () => { it('MAX_THEME_SIZE is 2 MB', () => {
expect(MAX_THEME_SIZE).toBe(1 * 1024 * 1024); expect(MAX_THEME_SIZE).toBe(2 * 1024 * 1024);
}); });
}); });
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import { buildQuoteHeader } from '@/lib/quote-header';
const base = {
newTo: [] as string[],
newCc: [] as string[],
locale: 'en',
timeFormat: '24h' as const,
unknownLabel: 'Unknown',
};
const sender = { name: 'Display Name', email: 'user@domain.tld' };
describe('buildQuoteHeader (#482 — sender address survives HTML rendering)', () => {
it('forward TEXT keeps the full "Name <email>" sender', async () => {
const h = await buildQuoteHeader({
mode: 'forward',
email: { from: [sender], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
...base,
});
expect(h.text).toContain('From: Display Name <user@domain.tld>');
});
it('forward HTML escapes the angle brackets so the address is not eaten as a tag', async () => {
const h = await buildQuoteHeader({
mode: 'forward',
email: { from: [sender], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
...base,
});
// The regression: a raw "<user@domain.tld>" is parsed as an HTML tag by the
// rich-text composer and dropped, leaving only "From: Display Name".
expect(h.html).toContain('Display Name &lt;user@domain.tld&gt;');
expect(h.html).not.toContain('<user@domain.tld>');
});
it('forward HTML escapes a subject containing markup (injection hardening)', async () => {
const h = await buildQuoteHeader({
mode: 'forward',
email: { from: [sender], subject: 'Hi <b>x</b>', receivedAt: '2026-01-01T10:00:00Z' },
...base,
});
expect(h.html).toContain('Hi &lt;b&gt;x&lt;/b&gt;');
expect(h.html).not.toContain('<b>x</b>');
});
it('forward HTML escapes a malicious display name', async () => {
const h = await buildQuoteHeader({
mode: 'forward',
email: {
from: [{ name: '<img src=x onerror=alert(1)>', email: 'evil@x.tld' }],
subject: 'Hello',
receivedAt: '2026-01-01T10:00:00Z',
},
...base,
});
expect(h.html).not.toContain('<img src=x');
expect(h.html).toContain('&lt;img src=x');
});
it('reply line includes the full "Name <email>" sender, escaped in HTML', async () => {
const h = await buildQuoteHeader({
mode: 'reply',
email: { from: [sender], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
...base,
});
// TEXT keeps the real angle brackets ("On <date>, Display Name <user@domain.tld> wrote:").
expect(h.text).toContain('Display Name <user@domain.tld> wrote:');
// HTML escapes them so the address survives the rich-text editor (#482).
expect(h.html).toContain('Display Name &lt;user@domain.tld&gt;');
expect(h.html).not.toContain('<user@domain.tld>');
});
it('reply line stays HTML-safe for a display name containing markup', async () => {
const evil = await buildQuoteHeader({
mode: 'reply',
email: { from: [{ name: '<b>x</b>', email: 'e@x.tld' }], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
...base,
});
expect(evil.html).not.toContain('<b>x</b>');
expect(evil.html).toContain('&lt;b&gt;x&lt;/b&gt;');
});
it('reply line falls back to bare email when there is no display name', async () => {
const h = await buildQuoteHeader({
mode: 'reply',
email: { from: [{ email: 'noname@x.tld' }], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
...base,
});
expect(h.text).toContain('noname@x.tld wrote:');
expect(h.text).not.toContain('<noname@x.tld>');
});
});
+59 -50
View File
@@ -1,6 +1,15 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { expandRecurringEvents } from '../recurrence-expansion'; 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 */ /** Helper: create a minimal CalendarEvent for testing recurrence */
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent { function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
@@ -41,7 +50,7 @@ describe('expandRecurringEvents', () => {
describe('daily frequency', () => { describe('daily frequency', () => {
it('expands daily events within range', () => { it('expands daily events within range', () => {
const event = makeEvent({ 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'); const result = expand(event, '2025-01-06T00:00:00', '2025-01-09T00:00:00');
expect(starts(result)).toEqual([ expect(starts(result)).toEqual([
@@ -53,7 +62,7 @@ describe('expandRecurringEvents', () => {
it('respects interval', () => { it('respects interval', () => {
const event = makeEvent({ 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'); const result = expand(event, '2025-01-06T00:00:00', '2025-01-12T00:00:00');
expect(starts(result)).toEqual([ expect(starts(result)).toEqual([
@@ -65,7 +74,7 @@ describe('expandRecurringEvents', () => {
it('respects count', () => { it('respects count', () => {
const event = makeEvent({ 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'); const result = expand(event, '2025-01-06T00:00:00', '2025-12-31T00:00:00');
expect(result).toHaveLength(3); expect(result).toHaveLength(3);
@@ -73,7 +82,7 @@ describe('expandRecurringEvents', () => {
it('respects until', () => { it('respects until', () => {
const event = makeEvent({ 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'); const result = expand(event, '2025-01-06T00:00:00', '2025-12-31T00:00:00');
expect(result).toHaveLength(3); expect(result).toHaveLength(3);
@@ -86,7 +95,7 @@ describe('expandRecurringEvents', () => {
describe('weekly frequency', () => { describe('weekly frequency', () => {
it('expands weekly with implicit byDay (same weekday as start)', () => { it('expands weekly with implicit byDay (same weekday as start)', () => {
const event = makeEvent({ const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any], recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
}); });
// Jan 6 is Monday, so every Monday // Jan 6 is Monday, so every Monday
const result = expand(event, '2025-01-06T00:00:00', '2025-01-28T00:00:00'); 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)', () => { it('expands weekly with explicit byDay (MWF)', () => {
const event = makeEvent({ const event = makeEvent({
recurrenceRules: [{ recurrenceRules: [rule({
'@type': 'RecurrenceRule', '@type': 'RecurrenceRule',
frequency: 'weekly', frequency: 'weekly',
byDay: [{ day: 'mo' }, { day: 'we' }, { day: 'fr' }], byDay: [{ day: 'mo' }, { day: 'we' }, { day: 'fr' }],
} as any], })],
}); });
const result = expand(event, '2025-01-06T00:00:00', '2025-01-13T00:00:00'); const result = expand(event, '2025-01-06T00:00:00', '2025-01-13T00:00:00');
expect(starts(result)).toEqual([ expect(starts(result)).toEqual([
@@ -116,12 +125,12 @@ describe('expandRecurringEvents', () => {
it('expands weekly with interval=2', () => { it('expands weekly with interval=2', () => {
const event = makeEvent({ const event = makeEvent({
recurrenceRules: [{ recurrenceRules: [rule({
'@type': 'RecurrenceRule', '@type': 'RecurrenceRule',
frequency: 'weekly', frequency: 'weekly',
interval: 2, interval: 2,
byDay: [{ day: 'mo' }], byDay: [{ day: 'mo' }],
} as any], })],
}); });
const result = expand(event, '2025-01-06T00:00:00', '2025-02-03T00:00:00'); const result = expand(event, '2025-01-06T00:00:00', '2025-02-03T00:00:00');
expect(starts(result)).toEqual([ expect(starts(result)).toEqual([
@@ -138,7 +147,7 @@ describe('expandRecurringEvents', () => {
it('expands monthly with implicit byMonthDay', () => { it('expands monthly with implicit byMonthDay', () => {
const event = makeEvent({ const event = makeEvent({
start: '2025-01-15T10:00:00', 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'); const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
expect(starts(result)).toEqual([ expect(starts(result)).toEqual([
@@ -151,11 +160,11 @@ describe('expandRecurringEvents', () => {
it('expands monthly with byMonthDay', () => { it('expands monthly with byMonthDay', () => {
const event = makeEvent({ const event = makeEvent({
start: '2025-01-01T08:00:00', start: '2025-01-01T08:00:00',
recurrenceRules: [{ recurrenceRules: [rule({
'@type': 'RecurrenceRule', '@type': 'RecurrenceRule',
frequency: 'monthly', frequency: 'monthly',
byMonthDay: [1, 15], byMonthDay: [1, 15],
} as any], })],
}); });
const result = expand(event, '2025-01-01T00:00:00', '2025-02-28T00:00:00'); const result = expand(event, '2025-01-01T00:00:00', '2025-02-28T00:00:00');
expect(starts(result)).toEqual([ expect(starts(result)).toEqual([
@@ -169,11 +178,11 @@ describe('expandRecurringEvents', () => {
it('expands monthly with negative byMonthDay (-1 = last day)', () => { it('expands monthly with negative byMonthDay (-1 = last day)', () => {
const event = makeEvent({ const event = makeEvent({
start: '2025-01-31T08:00:00', start: '2025-01-31T08:00:00',
recurrenceRules: [{ recurrenceRules: [rule({
'@type': 'RecurrenceRule', '@type': 'RecurrenceRule',
frequency: 'monthly', frequency: 'monthly',
byMonthDay: [-1], byMonthDay: [-1],
} as any], })],
}); });
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00'); const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10)); const days = result.map(e => e.start.substring(0, 10));
@@ -183,11 +192,11 @@ describe('expandRecurringEvents', () => {
it('expands monthly with byDay + nthOfPeriod (2nd Tuesday)', () => { it('expands monthly with byDay + nthOfPeriod (2nd Tuesday)', () => {
const event = makeEvent({ const event = makeEvent({
start: '2025-01-14T09:00:00', // 2nd Tuesday start: '2025-01-14T09:00:00', // 2nd Tuesday
recurrenceRules: [{ recurrenceRules: [rule({
'@type': 'RecurrenceRule', '@type': 'RecurrenceRule',
frequency: 'monthly', frequency: 'monthly',
byDay: [{ day: 'tu', nthOfPeriod: 2 }], byDay: [{ day: 'tu', nthOfPeriod: 2 }],
} as any], })],
}); });
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00'); const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10)); 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)', () => { it('expands monthly with byDay nthOfPeriod=-1 (last Friday)', () => {
const event = makeEvent({ const event = makeEvent({
start: '2025-01-31T09:00:00', // last Friday of Jan start: '2025-01-31T09:00:00', // last Friday of Jan
recurrenceRules: [{ recurrenceRules: [rule({
'@type': 'RecurrenceRule', '@type': 'RecurrenceRule',
frequency: 'monthly', frequency: 'monthly',
byDay: [{ day: 'fr', nthOfPeriod: -1 }], byDay: [{ day: 'fr', nthOfPeriod: -1 }],
} as any], })],
}); });
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00'); const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10)); const days = result.map(e => e.start.substring(0, 10));
@@ -216,7 +225,7 @@ describe('expandRecurringEvents', () => {
it('expands yearly on the same date', () => { it('expands yearly on the same date', () => {
const event = makeEvent({ const event = makeEvent({
start: '2023-03-15T12:00:00', 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'); const result = expand(event, '2023-01-01T00:00:00', '2026-01-01T00:00:00');
expect(starts(result)).toEqual([ expect(starts(result)).toEqual([
@@ -229,12 +238,12 @@ describe('expandRecurringEvents', () => {
it('expands yearly with byMonth and byDay (last Friday of November = Thanksgiving-ish)', () => { it('expands yearly with byMonth and byDay (last Friday of November = Thanksgiving-ish)', () => {
const event = makeEvent({ const event = makeEvent({
start: '2025-11-28T09:00:00', // last Friday of Nov 2025 start: '2025-11-28T09:00:00', // last Friday of Nov 2025
recurrenceRules: [{ recurrenceRules: [rule({
'@type': 'RecurrenceRule', '@type': 'RecurrenceRule',
frequency: 'yearly', frequency: 'yearly',
byMonth: ['11'], byMonth: ['11'],
byDay: [{ day: 'fr', nthOfPeriod: -1 }], byDay: [{ day: 'fr', nthOfPeriod: -1 }],
} as any], })],
}); });
const result = expand(event, '2025-01-01T00:00:00', '2028-01-01T00:00:00'); const result = expand(event, '2025-01-01T00:00:00', '2028-01-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10)); const days = result.map(e => e.start.substring(0, 10));
@@ -246,12 +255,12 @@ describe('expandRecurringEvents', () => {
const event = makeEvent({ const event = makeEvent({
start: '2025-07-04T00:00:00', start: '2025-07-04T00:00:00',
showWithoutTime: true, showWithoutTime: true,
recurrenceRules: [{ recurrenceRules: [rule({
'@type': 'RecurrenceRule', '@type': 'RecurrenceRule',
frequency: 'yearly', frequency: 'yearly',
byMonth: ['7'], byMonth: ['7'],
byMonthDay: [4], byMonthDay: [4],
} as any], })],
}); });
const result = expand(event, '2025-01-01T00:00:00', '2028-01-01T00:00:00'); const result = expand(event, '2025-01-01T00:00:00', '2028-01-01T00:00:00');
expect(result).toHaveLength(3); expect(result).toHaveLength(3);
@@ -265,12 +274,12 @@ describe('expandRecurringEvents', () => {
it('selects first and last from monthly byDay expansion', () => { it('selects first and last from monthly byDay expansion', () => {
const event = makeEvent({ const event = makeEvent({
start: '2025-01-06T10:00:00', start: '2025-01-06T10:00:00',
recurrenceRules: [{ recurrenceRules: [rule({
'@type': 'RecurrenceRule', '@type': 'RecurrenceRule',
frequency: 'monthly', frequency: 'monthly',
byDay: [{ day: 'mo' }, { day: 'tu' }, { day: 'we' }, { day: 'th' }, { day: 'fr' }], byDay: [{ day: 'mo' }, { day: 'tu' }, { day: 'we' }, { day: 'th' }, { day: 'fr' }],
bySetPosition: [1, -1], // first and last weekday of month 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 result = expand(event, '2025-01-01T00:00:00', '2025-03-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10)); const days = result.map(e => e.start.substring(0, 10));
@@ -289,7 +298,7 @@ describe('expandRecurringEvents', () => {
describe('recurrenceOverrides', () => { describe('recurrenceOverrides', () => {
it('applies overrides to matching occurrences', () => { it('applies overrides to matching occurrences', () => {
const event = makeEvent({ const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any], recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
recurrenceOverrides: { recurrenceOverrides: {
'2025-01-07T09:00:00': { title: 'Modified' }, '2025-01-07T09:00:00': { title: 'Modified' },
}, },
@@ -301,9 +310,9 @@ describe('expandRecurringEvents', () => {
it('excludes occurrences marked as excluded', () => { it('excludes occurrences marked as excluded', () => {
const event = makeEvent({ const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any], recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
recurrenceOverrides: { 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'); 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', () => { it('adds RDATE-style overrides not generated by rules', () => {
const event = makeEvent({ const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any], recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
recurrenceOverrides: { recurrenceOverrides: {
'2025-01-08T09:00:00': { title: 'Extra Wednesday' }, // Not a Monday '2025-01-08T09:00:00': { title: 'Extra Wednesday' }, // Not a Monday
}, },
@@ -329,12 +338,12 @@ describe('expandRecurringEvents', () => {
describe('excludedRecurrenceRules', () => { describe('excludedRecurrenceRules', () => {
it('removes occurrences generated by excluded rules', () => { it('removes occurrences generated by excluded rules', () => {
const event = makeEvent({ const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any], recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
excludedRecurrenceRules: [{ excludedRecurrenceRules: [rule({
'@type': 'RecurrenceRule', '@type': 'RecurrenceRule',
frequency: 'weekly', frequency: 'weekly',
byDay: [{ day: 'tu' }], byDay: [{ day: 'tu' }],
} as any], })],
}); });
// Jan 6-12 2025: Mon-Sun, Tuesday Jan 7 excluded // 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 result = expand(event, '2025-01-06T00:00:00', '2025-01-13T00:00:00');
@@ -353,7 +362,7 @@ describe('expandRecurringEvents', () => {
it('expands a daily series started years before the visible range', () => { it('expands a daily series started years before the visible range', () => {
const event = makeEvent({ const event = makeEvent({
start: '2022-01-03T09:00:00', start: '2022-01-03T09:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any], recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
}); });
const result = expand(event, '2025-06-02T00:00:00', '2025-06-05T00:00:00'); const result = expand(event, '2025-06-02T00:00:00', '2025-06-05T00:00:00');
expect(starts(result)).toEqual([ expect(starts(result)).toEqual([
@@ -366,7 +375,7 @@ describe('expandRecurringEvents', () => {
it('expands a weekly series started years before the visible range', () => { it('expands a weekly series started years before the visible range', () => {
const event = makeEvent({ const event = makeEvent({
start: '2020-01-06T09:00:00', // Monday start: '2020-01-06T09:00:00', // Monday
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any], recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
}); });
const result = expand(event, '2025-06-01T00:00:00', '2025-06-30T00:00:00'); const result = expand(event, '2025-06-01T00:00:00', '2025-06-30T00:00:00');
const days = result.map(e => e.start.substring(0, 10)); const days = result.map(e => e.start.substring(0, 10));
@@ -376,7 +385,7 @@ describe('expandRecurringEvents', () => {
it('still respects count for old series (no fast-forward shortcut)', () => { it('still respects count for old series (no fast-forward shortcut)', () => {
const event = makeEvent({ const event = makeEvent({
start: '2025-01-06T09:00:00', start: '2025-01-06T09:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 } as any], recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 })],
}); });
// Range far after the 5 occurrences ran out // Range far after the 5 occurrences ran out
const result = expand(event, '2025-06-01T00:00:00', '2025-06-30T00:00:00'); const result = expand(event, '2025-06-01T00:00:00', '2025-06-30T00:00:00');
@@ -395,13 +404,13 @@ describe('expandRecurringEvents', () => {
timeZone: 'America/New_York', timeZone: 'America/New_York',
utcStart: '2025-03-03T15:00:00Z', utcStart: '2025-03-03T15:00:00Z',
utcEnd: '2025-03-03T16:00:00Z', utcEnd: '2025-03-03T16:00:00Z',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any], recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
} as Partial<CalendarEvent>); } as Partial<CalendarEvent>);
const result = expand(event, '2025-03-03T00:00:00', '2025-03-17T00:00:00'); const result = expand(event, '2025-03-03T00:00:00', '2025-03-17T00:00:00');
const utcStarts = result.map(e => (e as any).utcStart); const utcStarts = result.map(e => e.utcStart);
expect(utcStarts[0]).toBe('2025-03-03T15:00:00.000Z'); // EST: 10:00 -5 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 expect(utcStarts[1]).toBe('2025-03-10T14:00:00.000Z'); // EDT: 10:00 -4
const utcEnds = result.map(e => (e as any).utcEnd); const utcEnds = result.map(e => e.utcEnd);
expect(utcEnds[1]).toBe('2025-03-10T15:00:00.000Z'); expect(utcEnds[1]).toBe('2025-03-10T15:00:00.000Z');
}); });
}); });
@@ -414,7 +423,7 @@ describe('expandRecurringEvents', () => {
const event = makeEvent({ const event = makeEvent({
start: '2025-01-06T00:00:00', start: '2025-01-06T00:00:00',
showWithoutTime: true, 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'); const result = expand(event, '2025-01-06T00:00:00', '2025-01-28T00:00:00');
expect(result).toHaveLength(4); // 4 Mondays: 6, 13, 20, 27 expect(result).toHaveLength(4); // 4 Mondays: 6, 13, 20, 27
@@ -427,7 +436,7 @@ describe('expandRecurringEvents', () => {
describe('edge cases', () => { describe('edge cases', () => {
it('does not exceed 500 occurrences', () => { it('does not exceed 500 occurrences', () => {
const event = makeEvent({ 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'); const result = expand(event, '2025-01-01T00:00:00', '2030-01-01T00:00:00');
expect(result.length).toBeLessThanOrEqual(500); expect(result.length).toBeLessThanOrEqual(500);
@@ -436,7 +445,7 @@ describe('expandRecurringEvents', () => {
it('handles invalid start date gracefully', () => { it('handles invalid start date gracefully', () => {
const event = makeEvent({ const event = makeEvent({
start: 'invalid', 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'); const result = expand(event, '2025-01-01T00:00:00', '2025-02-01T00:00:00');
expect(result).toHaveLength(0); expect(result).toHaveLength(0);
@@ -444,7 +453,7 @@ describe('expandRecurringEvents', () => {
it('generates synthetic IDs for occurrences', () => { it('generates synthetic IDs for occurrences', () => {
const event = makeEvent({ 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'); 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'); expect(result[0].id).toBe('evt1:2025-01-06T09:00:00');
@@ -453,7 +462,7 @@ describe('expandRecurringEvents', () => {
it('preserves originalId pointing to master', () => { it('preserves originalId pointing to master', () => {
const event = makeEvent({ 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'); const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
expect(result[0].originalId).toBe('evt1'); expect(result[0].originalId).toBe('evt1');
@@ -465,7 +474,7 @@ describe('expandRecurringEvents', () => {
id: 'master1', id: 'master1',
uid: 'shared-uid', uid: 'shared-uid',
start: '2025-01-06T09:00:00', start: '2025-01-06T09:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any], recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
}); });
const override1 = makeEvent({ const override1 = makeEvent({
id: 'override1', id: 'override1',
@@ -527,10 +536,10 @@ describe('expandRecurringEvents', () => {
const event = makeEvent({ const event = makeEvent({
start: '2026-09-01T12:00:00', start: '2026-09-01T12:00:00',
utcStart: '2026-09-01T10:00:00Z', 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 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 // Each occurrence should have a unique utcStart
expect(new Set(utcStarts).size).toBe(result.length); expect(new Set(utcStarts).size).toBe(result.length);
// First occurrence keeps master's UTC offset relationship // First occurrence keeps master's UTC offset relationship
@@ -545,10 +554,10 @@ describe('expandRecurringEvents', () => {
duration: 'PT1H', duration: 'PT1H',
utcStart: '2026-09-01T10:00:00Z', utcStart: '2026-09-01T10:00:00Z',
utcEnd: '2026-09-01T11: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 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 // Each occurrence should have a unique utcEnd
expect(new Set(utcEnds).size).toBe(result.length); expect(new Set(utcEnds).size).toBe(result.length);
expect(utcEnds[0]).toContain('2026-09-01'); expect(utcEnds[0]).toContain('2026-09-01');
@@ -559,7 +568,7 @@ describe('expandRecurringEvents', () => {
it('does not set utcStart when master has none', () => { it('does not set utcStart when master has none', () => {
const event = makeEvent({ const event = makeEvent({
start: '2025-01-06T09:00:00', 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'); const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
expect(result[0].utcStart).toBeUndefined(); 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');
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import {
stripSubjectPrefixes,
buildReplySubject,
buildForwardSubject,
} from '@/lib/subject-prefix';
describe('stripSubjectPrefixes', () => {
it('strips a chain of mixed-language prefixes', () => {
expect(stripSubjectPrefixes('Re: AW: WG: foo')).toBe('foo');
});
it('strips the Outlook [N] and Eudora *N counters', () => {
expect(stripSubjectPrefixes('Re[2]: foo')).toBe('foo');
expect(stripSubjectPrefixes('Re*3: foo')).toBe('foo');
});
it('is case-insensitive and idempotent', () => {
expect(stripSubjectPrefixes('RE: Re: foo')).toBe('foo');
expect(stripSubjectPrefixes(stripSubjectPrefixes('RE: Re: foo'))).toBe('foo');
});
it('strips a Cyrillic token and an ASCII-colon Chinese token', () => {
expect(stripSubjectPrefixes('Ответ: foo')).toBe('foo');
expect(stripSubjectPrefixes('回复: foo')).toBe('foo');
});
it('strips a token followed by a full-width colon (CJK clients)', () => {
expect(stripSubjectPrefixes('回复:foo')).toBe('foo');
expect(stripSubjectPrefixes('回覆:foo')).toBe('foo');
expect(stripSubjectPrefixes('Refoo')).toBe('foo');
});
it('still does not strip a bare single-letter "R:"', () => {
expect(stripSubjectPrefixes('R: budget 2024')).toBe('R: budget 2024');
});
it('returns "" for empty / null / undefined and leaves clean subjects alone', () => {
expect(stripSubjectPrefixes('')).toBe('');
expect(stripSubjectPrefixes(null)).toBe('');
expect(stripSubjectPrefixes(undefined)).toBe('');
expect(stripSubjectPrefixes('foo')).toBe('foo');
});
});
describe('buildReplySubject / buildForwardSubject', () => {
it('replaces a prefix chain (incl. a full-width colon) with the given prefix', () => {
expect(buildReplySubject('回复:foo', 'Re:')).toBe('Re: foo');
expect(buildForwardSubject('Re: foo', 'Fwd:')).toBe('Fwd: foo');
});
it('prepends to a clean subject and returns the bare prefix for empty input', () => {
expect(buildReplySubject('foo', 'AW:')).toBe('AW: foo');
expect(buildReplySubject('', 'AW:')).toBe('AW:');
});
});
Binary file not shown.
+169
View File
@@ -0,0 +1,169 @@
import { describe, it, expect, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import {
getCrossIncludedMailboxes,
buildCrossFilter,
getCrossUnreadTotal,
fetchCrossViewEmails,
resolveSourceFolderName,
type UnifiedAccountClient,
} from '@/lib/unified-mailbox';
const mb = (id: string, role: string | undefined, unread = 0, originalId?: string): Mailbox =>
({ id, name: id, role, unreadEmails: unread, totalEmails: 0, originalId } as unknown as Mailbox);
const makeAccount = (
over: Partial<UnifiedAccountClient> & { accountId: string },
clientImpl: Partial<IJMAPClient> = {},
): UnifiedAccountClient => ({
accountLabel: over.accountId,
mailboxes: [],
client: clientImpl as unknown as IJMAPClient,
clientAccountId: over.accountId,
jmapAccountId: over.accountId,
...over,
});
describe('getCrossIncludedMailboxes', () => {
it('excludes junk/sent/archive/trash/drafts, keeps inbox + custom folders', () => {
const account = makeAccount({
accountId: 'a',
mailboxes: [
mb('inbox', 'inbox'),
mb('projects', undefined),
mb('junk', 'junk'),
mb('sent', 'sent'),
mb('archive', 'archive'),
mb('trash', 'trash'),
mb('drafts', 'drafts'),
],
});
const ids = getCrossIncludedMailboxes(account).map((m) => m.id);
expect(ids).toEqual(['inbox', 'projects']);
});
});
describe('buildCrossFilter', () => {
it('all → single inMailbox for one folder', () => {
expect(buildCrossFilter('all', ['m1'])).toEqual({ inMailbox: 'm1' });
});
it('all → OR of inMailbox for multiple folders', () => {
expect(buildCrossFilter('all', ['m1', 'm2'])).toEqual({
operator: 'OR',
conditions: [{ inMailbox: 'm1' }, { inMailbox: 'm2' }],
});
});
it('unread → AND(membership, notKeyword $seen)', () => {
expect(buildCrossFilter('unread', ['m1', 'm2'])).toEqual({
operator: 'AND',
conditions: [
{ operator: 'OR', conditions: [{ inMailbox: 'm1' }, { inMailbox: 'm2' }] },
{ notKeyword: '$seen' },
],
});
});
it('starred → AND(membership, hasKeyword $flagged)', () => {
expect(buildCrossFilter('starred', ['m1'])).toEqual({
operator: 'AND',
conditions: [{ inMailbox: 'm1' }, { hasKeyword: '$flagged' }],
});
});
});
describe('getCrossUnreadTotal', () => {
it('sums unread across included folders of every account, ignoring excluded roles', () => {
const a = makeAccount({
accountId: 'a',
mailboxes: [mb('inbox', 'inbox', 3), mb('proj', undefined, 2), mb('junk', 'junk', 50)],
});
const b = makeAccount({
accountId: 'b',
mailboxes: [mb('inbox', 'inbox', 5), mb('sent', 'sent', 99)],
});
expect(getCrossUnreadTotal([a, b])).toBe(10);
});
});
describe('resolveSourceFolderName', () => {
const emailIn = (ids: string[]): Email =>
({ mailboxIds: Object.fromEntries(ids.map((id) => [id, true])) } as unknown as Email);
it('returns the name of the folder the email is in (personal account)', () => {
const boxes = [mb('inbox', 'inbox'), mb('proj', undefined)];
expect(resolveSourceFolderName(emailIn(['proj']), boxes)).toBe('proj');
});
it('matches shared mailboxes by originalId (email keyed by owner-side id)', () => {
// shared mailbox: namespaced store id, but email.mailboxIds uses originalId
const shared = { id: 'owner:inbox', role: 'inbox', unreadEmails: 0, totalEmails: 0, originalId: 'orig-inbox', name: 'Team Inbox' } as unknown as Mailbox;
expect(resolveSourceFolderName(emailIn(['orig-inbox']), [shared])).toBe('Team Inbox');
});
it('returns undefined when no known folder contains the email', () => {
expect(resolveSourceFolderName(emailIn(['unknown']), [mb('inbox', 'inbox')])).toBeUndefined();
});
});
describe('fetchCrossViewEmails', () => {
it('merges + date-sorts across accounts and stamps account info', async () => {
const clientA = {
advancedSearchEmails: vi.fn().mockResolvedValue({
emails: [{ id: 'a1', receivedAt: '2026-01-01T10:00:00Z' } as Email],
total: 1,
hasMore: false,
}),
};
const clientB = {
advancedSearchEmails: vi.fn().mockResolvedValue({
emails: [{ id: 'b1', receivedAt: '2026-01-02T10:00:00Z' } as Email],
total: 1,
hasMore: true,
}),
};
const a = makeAccount({ accountId: 'a', accountLabel: 'A', mailboxes: [mb('inbox', 'inbox')] }, clientA);
const b = makeAccount({ accountId: 'b', accountLabel: 'B', mailboxes: [mb('inbox', 'inbox')] }, clientB);
const result = await fetchCrossViewEmails([a, b], 'all', 50, 0);
expect(result.emails.map((e) => e.id)).toEqual(['b1', 'a1']); // newest first
expect(result.emails[0].accountId).toBe('b');
expect(result.emails[1].accountLabel).toBe('A');
expect(result.total).toBe(2);
expect(result.hasMore).toBe(true);
});
it('resolves shared folders via originalId + owner accountId', async () => {
const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false });
const shared = makeAccount(
{ accountId: 'owner-1', accountLabel: 'Shared', isShared: true, mailboxes: [mb('ns:inbox', 'inbox', 0, 'orig-inbox')] },
{ advancedSearchEmails },
);
await fetchCrossViewEmails([shared], 'unread', 50, 0);
const [filter, accountId] = advancedSearchEmails.mock.calls[0];
expect(accountId).toBe('owner-1');
// filter membership uses the originalId, not the namespaced id
expect(JSON.stringify(filter)).toContain('orig-inbox');
expect(JSON.stringify(filter)).not.toContain('ns:inbox');
});
it('collects per-account errors without failing the whole fan-out', async () => {
const ok = makeAccount(
{ accountId: 'ok', mailboxes: [mb('inbox', 'inbox')] },
{ advancedSearchEmails: vi.fn().mockResolvedValue({ emails: [{ id: 'x', receivedAt: '2026-01-01T00:00:00Z' } as Email], total: 1, hasMore: false }) },
);
const bad = makeAccount(
{ accountId: 'bad', mailboxes: [mb('inbox', 'inbox')] },
{ advancedSearchEmails: vi.fn().mockRejectedValue(new Error('boom')) },
);
const result = await fetchCrossViewEmails([ok, bad], 'all', 50, 0);
expect(result.emails.map((e) => e.id)).toEqual(['x']);
expect(result.errors.get('bad')).toBe('boom');
});
});
+210
View File
@@ -0,0 +1,210 @@
import { describe, it, expect, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import {
findMailboxByRole,
fetchUnifiedEmails,
searchUnifiedEmails,
advancedSearchUnifiedEmails,
fetchUnifiedMailboxCounts,
getUnifiedRoles,
type UnifiedAccountClient,
} from '@/lib/unified-mailbox';
// ── factories ────────────────────────────────────────────────────────────────
const makeEmail = (id: string, receivedAt: string): Email =>
({ id, receivedAt } as unknown as Email);
const makeMailbox = (over: Partial<Mailbox> & { role: string }): Mailbox =>
({ id: `mb-${over.role}`, unreadEmails: 0, totalEmails: 0, ...over } as unknown as Mailbox);
type FetchResult = { emails: Email[]; total: number; hasMore: boolean };
function makeAccount(
over: Partial<UnifiedAccountClient> & { accountId: string },
clientImpl: Partial<IJMAPClient> = {},
): UnifiedAccountClient {
return {
accountLabel: over.accountId,
mailboxes: [],
client: clientImpl as unknown as IJMAPClient,
clientAccountId: over.accountId,
jmapAccountId: over.accountId,
...over,
};
}
describe('findMailboxByRole', () => {
it('returns the first mailbox matching the role', () => {
const a = makeMailbox({ role: 'inbox', id: 'a' });
const b = makeMailbox({ role: 'inbox', id: 'b' });
expect(findMailboxByRole([a, b], 'inbox')).toBe(a);
});
it('returns undefined when no mailbox has the role', () => {
expect(findMailboxByRole([makeMailbox({ role: 'sent' })], 'inbox')).toBeUndefined();
});
});
describe('fetchUnifiedEmails', () => {
it('merges across accounts and sorts by receivedAt descending, decorating each email', async () => {
const acc1 = makeAccount(
{ accountId: 'A', accountLabel: 'Account A', mailboxes: [makeMailbox({ role: 'inbox', id: 'a-in' })] },
{ getEmails: vi.fn(async (): Promise<FetchResult> => ({
emails: [makeEmail('a1', '2026-01-01T10:00:00Z'), makeEmail('a2', '2026-01-03T10:00:00Z')],
total: 5, hasMore: false,
})) },
);
const acc2 = makeAccount(
{ accountId: 'B', accountLabel: 'Account B', mailboxes: [makeMailbox({ role: 'inbox', id: 'b-in' })] },
{ getEmails: vi.fn(async (): Promise<FetchResult> => ({
emails: [makeEmail('b1', '2026-01-02T10:00:00Z')],
total: 3, hasMore: true,
})) },
);
const result = await fetchUnifiedEmails([acc1, acc2], 'inbox', 20, 0);
expect(result.emails.map((e) => e.id)).toEqual(['a2', 'b1', 'a1']); // newest first
expect(result.total).toBe(8); // sum of per-account totals, not merged length
expect(result.hasMore).toBe(true); // OR across accounts
expect(result.errors.size).toBe(0);
// decoration
const a2 = result.emails.find((e) => e.id === 'a2')!;
expect(a2.accountId).toBe('A');
expect(a2.accountLabel).toBe('Account A');
expect(a2.sourceClientAccountId).toBe('A');
expect(a2.sourceAccountId).toBe('A');
// getEmails called with (mailboxId, accountId=undefined for personal, limit, position)
expect(acc1.client.getEmails).toHaveBeenCalledWith('a-in', undefined, 20, 0);
});
it('isolates per-account errors and still returns the rest', async () => {
const ok = makeAccount(
{ accountId: 'OK', mailboxes: [makeMailbox({ role: 'inbox', id: 'ok-in' })] },
{ getEmails: vi.fn(async (): Promise<FetchResult> => ({ emails: [makeEmail('x', '2026-01-01T00:00:00Z')], total: 1, hasMore: false })) },
);
const boom = makeAccount(
{ accountId: 'BOOM', mailboxes: [makeMailbox({ role: 'inbox', id: 'boom-in' })] },
{ getEmails: vi.fn(async (): Promise<FetchResult> => { throw new Error('network down'); }) },
);
const result = await fetchUnifiedEmails([ok, boom], 'inbox', 20, 0);
expect(result.emails.map((e) => e.id)).toEqual(['x']);
expect(result.total).toBe(1);
expect(result.errors.get('BOOM')).toBe('network down');
});
it('stringifies a non-Error rejection', async () => {
const acc = makeAccount(
{ accountId: 'S', mailboxes: [makeMailbox({ role: 'inbox' })] },
{ getEmails: vi.fn(async (): Promise<FetchResult> => { throw 'boom-string'; }) },
);
const result = await fetchUnifiedEmails([acc], 'inbox', 20, 0);
expect(result.errors.get('S')).toBe('boom-string');
});
it('skips accounts that have no mailbox for the role (no error recorded)', async () => {
const getEmails = vi.fn(async (): Promise<FetchResult> => ({ emails: [], total: 0, hasMore: false }));
const acc = makeAccount(
{ accountId: 'NOROLE', mailboxes: [makeMailbox({ role: 'sent' })] },
{ getEmails },
);
const result = await fetchUnifiedEmails([acc], 'inbox', 20, 0);
expect(result).toEqual({ emails: [], total: 0, hasMore: false, errors: new Map() });
expect(getEmails).not.toHaveBeenCalled();
});
it('returns an empty result for no accounts', async () => {
const result = await fetchUnifiedEmails([], 'inbox', 20, 0);
expect(result).toEqual({ emails: [], total: 0, hasMore: false, errors: new Map() });
});
it('does NOT mutate the source email objects (decorates copies)', async () => {
const original = makeEmail('m1', '2026-01-01T00:00:00Z');
const acc = makeAccount(
{ accountId: 'A', accountLabel: 'Label A', mailboxes: [makeMailbox({ role: 'inbox' })] },
{ getEmails: vi.fn(async (): Promise<FetchResult> => ({ emails: [original], total: 1, hasMore: false })) },
);
const res = await fetchUnifiedEmails([acc], 'inbox', 20, 0);
// The returned email carries the account info, but the client's object is untouched.
expect(res.emails[0]).toMatchObject({ id: 'm1', accountId: 'A', accountLabel: 'Label A' });
expect('accountId' in original).toBe(false);
expect('accountLabel' in original).toBe(false);
});
});
describe('resolveJmapTarget (via searchUnifiedEmails / advancedSearchUnifiedEmails)', () => {
const empty = async (): Promise<FetchResult> => ({ emails: [], total: 0, hasMore: false });
it('personal account: uses mailbox.id and undefined accountId', async () => {
const searchEmails = vi.fn(empty);
const acc = makeAccount(
{ accountId: 'A', mailboxes: [makeMailbox({ role: 'inbox', id: 'real-id' })] },
{ searchEmails },
);
await searchUnifiedEmails([acc], 'inbox', 'hello', 10, 0);
expect(searchEmails).toHaveBeenCalledWith('hello', 'real-id', undefined, 10, 0);
});
it('shared account: uses mailbox.originalId and the owner accountId', async () => {
const searchEmails = vi.fn(empty);
const acc = makeAccount(
{ accountId: 'OWNER', isShared: true, mailboxes: [makeMailbox({ role: 'inbox', id: 'OWNER:orig', originalId: 'orig' })] },
{ searchEmails },
);
await searchUnifiedEmails([acc], 'inbox', 'q', 10, 5);
expect(searchEmails).toHaveBeenCalledWith('q', 'orig', 'OWNER', 10, 5);
});
it('shared account without originalId: falls back to mailbox.id', async () => {
const searchEmails = vi.fn(empty);
const acc = makeAccount(
{ accountId: 'OWNER', isShared: true, mailboxes: [makeMailbox({ role: 'inbox', id: 'just-id' })] },
{ searchEmails },
);
await searchUnifiedEmails([acc], 'inbox', 'q', 10, 0);
expect(searchEmails).toHaveBeenCalledWith('q', 'just-id', 'OWNER', 10, 0);
});
it('advancedSearch: builds the filter from the resolved mailbox id and forwards accountId', async () => {
const advancedSearchEmails = vi.fn(empty);
const acc = makeAccount(
{ accountId: 'A', mailboxes: [makeMailbox({ role: 'inbox', id: 'mbx' })] },
{ advancedSearchEmails },
);
const filterFor = vi.fn((mailboxId: string) => ({ inMailbox: mailboxId, from: 'x' }));
await advancedSearchUnifiedEmails([acc], 'inbox', filterFor, 10, 0);
expect(filterFor).toHaveBeenCalledWith('mbx');
expect(advancedSearchEmails).toHaveBeenCalledWith({ inMailbox: 'mbx', from: 'x' }, undefined, 10, 0);
});
});
describe('fetchUnifiedMailboxCounts', () => {
it('aggregates counts per role across accounts, in ALL_UNIFIED_ROLES order, omitting absent roles', () => {
const acc1 = makeAccount({ accountId: 'A', mailboxes: [
makeMailbox({ role: 'inbox', unreadEmails: 2, totalEmails: 10 }),
makeMailbox({ role: 'sent', unreadEmails: 0, totalEmails: 4 }),
] });
const acc2 = makeAccount({ accountId: 'B', mailboxes: [
makeMailbox({ role: 'inbox', unreadEmails: 3, totalEmails: 7 }),
] });
expect(fetchUnifiedMailboxCounts([acc1, acc2])).toEqual([
{ role: 'inbox', unreadEmails: 5, totalEmails: 17 },
{ role: 'sent', unreadEmails: 0, totalEmails: 4 },
]);
});
it('returns an empty array when no accounts have mailboxes', () => {
expect(fetchUnifiedMailboxCounts([makeAccount({ accountId: 'A' })])).toEqual([]);
});
});
describe('getUnifiedRoles', () => {
it('lists roles present in at least one account once, in canonical order', () => {
const acc1 = makeAccount({ accountId: 'A', mailboxes: [makeMailbox({ role: 'drafts' }), makeMailbox({ role: 'inbox' })] });
const acc2 = makeAccount({ accountId: 'B', mailboxes: [makeMailbox({ role: 'inbox' }), makeMailbox({ role: 'trash' })] });
expect(getUnifiedRoles([acc1, acc2])).toEqual(['inbox', 'drafts', 'trash']);
});
});
+133
View File
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
// ── module mocks (hoisted) ───────────────────────────────────────────────────
vi.mock('next/server', () => {
class NextResponse {
body: unknown;
status: number;
headers: Headers;
constructor(body: unknown, init?: { status?: number; headers?: Headers }) {
this.body = body;
this.status = init?.status ?? 200;
this.headers = init?.headers ?? new Headers();
}
static json(data: unknown, init?: { status?: number }) {
return { status: init?.status ?? 200, headers: new Headers(), json: async () => data };
}
}
return { NextResponse, NextRequest: class {} };
});
vi.mock('@/lib/logger', () => ({ logger: { error: () => {}, debug: () => {} } }));
vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: vi.fn() }));
import { POST } from '@/app/api/webdav/route';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
const mockCreds = getStalwartCredentials as unknown as Mock;
const CREDS = { serverUrl: 'https://mail.example.com', username: 'user@example.com', authHeader: 'Basic abc' };
type Resp = { status: number; headers: Headers; body?: unknown; text?: () => Promise<string> };
let fetchSpy: Mock;
function makeReq(headers: Record<string, string> = {}, body: unknown = null): Parameters<typeof POST>[0] {
const lc: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) lc[k.toLowerCase()] = v;
return {
headers: { get: (n: string) => lc[n.toLowerCase()] ?? null },
arrayBuffer: async () => new ArrayBuffer(0),
body,
} as unknown as Parameters<typeof POST>[0];
}
// The route returns either our mocked NextResponse instance or NextResponse.json's object.
function read(res: unknown): { status: number; headers?: Headers; json?: () => Promise<unknown>; body?: unknown } {
return res as { status: number; headers?: Headers; json?: () => Promise<unknown>; body?: unknown };
}
beforeEach(() => {
mockCreds.mockResolvedValue(CREDS);
fetchSpy = vi.fn(async (): Promise<Resp> => ({
status: 207,
headers: new Headers({ 'Content-Type': 'text/plain' }),
body: 'UPSTREAM-BODY',
text: async () => '<xml/>',
}));
vi.stubGlobal('fetch', fetchSpy);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('POST /api/webdav — guards', () => {
it('401 when there are no credentials', async () => {
mockCreds.mockResolvedValue(null);
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'GET' })));
expect(res.status).toBe(401);
await expect(res.json!()).resolves.toEqual({ error: 'Not authenticated' });
});
it('400 for a missing or disallowed method', async () => {
expect(read(await POST(makeReq({}))).status).toBe(400);
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'BOGUS' })));
expect(res.status).toBe(400);
await expect(res.json!()).resolves.toEqual({ error: 'Invalid WebDAV method' });
});
it('400 on a path-traversal segment', async () => {
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'PROPFIND', 'X-WebDAV-Path': '../etc' })));
expect(res.status).toBe(400);
await expect(res.json!()).resolves.toEqual({ error: 'Invalid WebDAV path segment' });
});
it('400 on bad percent-encoding in the path', async () => {
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'PUT', 'X-WebDAV-Path': '%zz' })));
expect(res.status).toBe(400);
await expect(res.json!()).resolves.toEqual({ error: 'Invalid WebDAV path encoding' });
});
});
describe('POST /api/webdav — proxying', () => {
it('GET builds the upstream URL, forwards auth, and streams the body back', async () => {
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'get', 'X-WebDAV-Path': 'file.txt' })));
const target = 'https://mail.example.com/dav/file/user%40example.com/file.txt';
expect(fetchSpy).toHaveBeenCalledWith(
target,
expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ Authorization: 'Basic abc' }) }),
);
expect(res.status).toBe(207);
expect(res.body).toBe('UPSTREAM-BODY');
expect(res.headers!.get('Content-Type')).toBe('text/plain');
expect(res.headers!.get('X-WebDAV-Request-URI')).toBe(target);
});
it('PROPFIND forwards Depth and returns XML', async () => {
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'PROPFIND', 'X-WebDAV-Path': 'dir', Depth: '1' })));
expect(fetchSpy).toHaveBeenCalledWith(
'https://mail.example.com/dav/file/user%40example.com/dir',
expect.objectContaining({ method: 'PROPFIND', headers: expect.objectContaining({ Depth: '1' }) }),
);
expect(res.status).toBe(207);
expect(res.body).toBe('<xml/>');
expect(res.headers!.get('Content-Type')).toBe('application/xml; charset=utf-8');
});
it('MOVE rebuilds the Destination URL and forwards Overwrite', async () => {
await POST(makeReq({
'X-WebDAV-Method': 'MOVE',
'X-WebDAV-Path': 'old.txt',
'X-WebDAV-Destination': 'sub/new.txt',
Overwrite: 'F',
}));
expect(fetchSpy).toHaveBeenCalledWith(
'https://mail.example.com/dav/file/user%40example.com/old.txt',
expect.objectContaining({
method: 'MOVE',
headers: expect.objectContaining({
Destination: 'https://mail.example.com/dav/file/user%40example.com/sub/new.txt',
Overwrite: 'F',
}),
}),
);
});
});
+26 -13
View File
@@ -11,7 +11,6 @@ import { useFilterStore } from '@/stores/filter-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
import { useIdentityStore } from '@/stores/identity-store'; import { useIdentityStore } from '@/stores/identity-store';
import { useVacationStore } from '@/stores/vacation-store'; import { useVacationStore } from '@/stores/vacation-store';
import { useSmimeStore } from '@/stores/smime-store';
// Minimal snapshot shapes - we only capture what we need // Minimal snapshot shapes - we only capture what we need
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -37,33 +36,37 @@ export function snapshotAccount(accountId: string): void {
const identityState = useIdentityStore.getState(); const identityState = useIdentityStore.getState();
const vacationState = useVacationStore.getState(); const vacationState = useVacationStore.getState();
// Copy the captured collections so the snapshot is decoupled from the live
// store: a later in-place mutation (e.g. an array push/splice, or stamping
// fields onto a shared email object) must not retroactively corrupt a
// snapshot taken earlier.
cache.set(accountId, { cache.set(accountId, {
email: { email: {
emails: emailState.emails, emails: [...emailState.emails],
mailboxes: emailState.mailboxes, mailboxes: [...emailState.mailboxes],
selectedEmail: emailState.selectedEmail, selectedEmail: emailState.selectedEmail,
selectedMailbox: emailState.selectedMailbox, selectedMailbox: emailState.selectedMailbox,
searchQuery: emailState.searchQuery, searchQuery: emailState.searchQuery,
quota: emailState.quota, quota: emailState.quota ? { ...emailState.quota } : emailState.quota,
}, },
contact: { contact: {
contacts: contactState.contacts, contacts: [...contactState.contacts],
addressBooks: contactState.addressBooks, addressBooks: [...contactState.addressBooks],
supportsSync: contactState.supportsSync, supportsSync: contactState.supportsSync,
}, },
calendar: { calendar: {
calendars: calendarState.calendars, calendars: [...calendarState.calendars],
events: calendarState.events, events: [...calendarState.events],
selectedCalendarIds: calendarState.selectedCalendarIds, selectedCalendarIds: [...calendarState.selectedCalendarIds],
viewMode: calendarState.viewMode, viewMode: calendarState.viewMode,
supportsCalendar: calendarState.supportsCalendar, supportsCalendar: calendarState.supportsCalendar,
}, },
filter: { filter: {
rules: filterState.rules, rules: [...filterState.rules],
isSupported: filterState.isSupported, isSupported: filterState.isSupported,
}, },
identity: { identity: {
identities: identityState.identities, identities: [...identityState.identities],
preferredPrimaryId: identityState.preferredPrimaryId, preferredPrimaryId: identityState.preferredPrimaryId,
}, },
vacation: { vacation: {
@@ -73,11 +76,22 @@ export function snapshotAccount(accountId: string): void {
}); });
} }
/** Restore cached store states for the given account. Returns false if no cache exists. */ /**
* Restore cached store states for the given account. Returns false if no cache
* exists.
*
* The snapshot only captures a subset of each store's fields (the loaded data),
* so we reset every store to its baseline first. Without this, fields outside
* the captured subset (e.g. email selection, loading flags, tag counts) would
* carry over from whatever account was active, leaking state across accounts.
* `setState` merges, so the captured fields are then layered back on top.
*/
export function restoreAccount(accountId: string): boolean { export function restoreAccount(accountId: string): boolean {
const snapshot = cache.get(accountId); const snapshot = cache.get(accountId);
if (!snapshot) return false; if (!snapshot) return false;
clearAllStores();
useEmailStore.setState(snapshot.email); useEmailStore.setState(snapshot.email);
useContactStore.setState(snapshot.contact); useContactStore.setState(snapshot.contact);
useCalendarStore.setState(snapshot.calendar); useCalendarStore.setState(snapshot.calendar);
@@ -118,7 +132,6 @@ export function clearAllStores(): void {
useVacationStore.getState().clearState(); useVacationStore.getState().clearState();
useCalendarStore.getState().clearState(); useCalendarStore.getState().clearState();
useFilterStore.getState().clearState(); useFilterStore.getState().clearState();
useSmimeStore.getState().clearState();
} }
/** Evict cached state for one account */ /** Evict cached state for one account */
+3
View File
@@ -47,6 +47,9 @@ export interface ServerPlugin {
author: string; author: string;
description: string; description: string;
type: string; type: string;
/** Requested execution tier ('untrusted' | 'privileged'). Privileged plugins
* run in a same-origin sandbox and require admin approval + consent. */
tier?: string;
permissions: string[]; permissions: string[];
entrypoint: string; entrypoint: string;
enabled: boolean; enabled: boolean;
+13
View File
@@ -51,6 +51,7 @@ export interface FeatureGates {
settingsExportEnabled: boolean; settingsExportEnabled: boolean;
customKeywordsEnabled: boolean; customKeywordsEnabled: boolean;
templatesEnabled: boolean; templatesEnabled: boolean;
calendarEnabled: boolean;
calendarTasksEnabled: boolean; calendarTasksEnabled: boolean;
smimeEnabled: boolean; smimeEnabled: boolean;
externalContentEnabled: boolean; externalContentEnabled: boolean;
@@ -60,6 +61,9 @@ export interface FeatureGates {
filesEnabled: boolean; filesEnabled: boolean;
contactsEnabled: boolean; contactsEnabled: boolean;
allMailViewEnabled: boolean; allMailViewEnabled: boolean;
crossUnreadViewEnabled: boolean;
crossStarredViewEnabled: boolean;
crossAllViewEnabled: boolean;
} }
export const DEFAULT_FEATURE_GATES: FeatureGates = { export const DEFAULT_FEATURE_GATES: FeatureGates = {
@@ -72,6 +76,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
settingsExportEnabled: true, settingsExportEnabled: true,
customKeywordsEnabled: true, customKeywordsEnabled: true,
templatesEnabled: true, templatesEnabled: true,
calendarEnabled: true,
calendarTasksEnabled: true, calendarTasksEnabled: true,
smimeEnabled: true, smimeEnabled: true,
externalContentEnabled: true, externalContentEnabled: true,
@@ -81,6 +86,9 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
filesEnabled: true, filesEnabled: true,
contactsEnabled: true, contactsEnabled: true,
allMailViewEnabled: false, allMailViewEnabled: false,
crossUnreadViewEnabled: false,
crossStarredViewEnabled: false,
crossAllViewEnabled: false,
}; };
export interface ThemePolicy { export interface ThemePolicy {
@@ -163,6 +171,11 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' }, oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', fileEnvVar: 'OAUTH_CLIENT_SECRET_FILE', type: 'string', defaultValue: '' }, oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', fileEnvVar: 'OAUTH_CLIENT_SECRET_FILE', type: 'string', defaultValue: '' },
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' }, oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
// Overrides only the user-facing authorize endpoint. Discovery, token exchange
// and refresh continue to use the canonical OAUTH_ISSUER_URL. Lets a per-brand
// authorize host front a single canonical issuer. Empty = use the discovered
// authorization_endpoint.
oauthAuthorizeUrl: { envVar: 'OAUTH_AUTHORIZE_URL', type: 'url', defaultValue: '' },
oauthScopes: { envVar: 'OAUTH_SCOPES', type: 'string', defaultValue: '' }, oauthScopes: { envVar: 'OAUTH_SCOPES', type: 'string', defaultValue: '' },
oauthExtraScopes: { envVar: 'OAUTH_EXTRA_SCOPES', type: 'string', defaultValue: '' }, oauthExtraScopes: { envVar: 'OAUTH_EXTRA_SCOPES', type: 'string', defaultValue: '' },
oauthAllowPrivateEndpoints: { envVar: 'OAUTH_ALLOW_PRIVATE_ENDPOINTS', type: 'boolean', defaultValue: false }, oauthAllowPrivateEndpoints: { envVar: 'OAUTH_ALLOW_PRIVATE_ENDPOINTS', type: 'boolean', defaultValue: false },
+1 -1
View File
@@ -1,6 +1,6 @@
import type { ContactCard, CalendarEvent, Calendar, PartialDate, Timestamp } from '@/lib/jmap/types'; import type { ContactCard, CalendarEvent, Calendar, PartialDate, Timestamp } from '@/lib/jmap/types';
import { getContactDisplayName } from '@/stores/contact-store'; import { getContactDisplayName } from '@/stores/contact-store';
import { format, eachYearOfInterval, parseISO } from 'date-fns'; import { eachYearOfInterval, parseISO } from 'date-fns';
export const BIRTHDAY_CALENDAR_ID = '__birthday-calendar__'; export const BIRTHDAY_CALENDAR_ID = '__birthday-calendar__';
export const BIRTHDAY_CALENDAR_COLOR = '#eab308'; // Yellow export const BIRTHDAY_CALENDAR_COLOR = '#eab308'; // Yellow
-1
View File
@@ -67,7 +67,6 @@ export function getPathPrefix(locale?: string): string {
* // Browser at /webmail/en/inbox → /webmail/api/jmap * // Browser at /webmail/en/inbox → /webmail/api/jmap
* // Browser at /en/inbox → /api/jmap * // Browser at /en/inbox → /api/jmap
*/ */
// eslint-disable-next-line no-undef
export function apiFetch(input: string, init?: RequestInit): Promise<Response> { export function apiFetch(input: string, init?: RequestInit): Promise<Response> {
if (input.startsWith('/') && !input.startsWith('//')) { if (input.startsWith('/') && !input.startsWith('//')) {
return fetch(getPathPrefix() + input, init); return fetch(getPathPrefix() + input, init);
+4 -4
View File
@@ -121,7 +121,7 @@ export class DemoJMAPClient implements IJMAPClient {
async getMailboxes(_accountId?: string): Promise<Mailbox[]> { return [...this.data.mailboxes]; } async getMailboxes(_accountId?: string): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
async getAllMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; } async getAllMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
async createMailbox(name: string, parentId?: string): Promise<Mailbox> { async createMailbox(name: string, parentId?: string, _accountId?: string): Promise<Mailbox> {
const mb: Mailbox = { const mb: Mailbox = {
id: generateDemoId('mailbox'), id: generateDemoId('mailbox'),
name, name,
@@ -222,7 +222,7 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts(); this.recalcMailboxCounts();
} }
async batchMarkAsRead(emailIds: string[], read: boolean = true): Promise<void> { async batchMarkAsRead(emailIds: string[], read: boolean = true, _accountId?: string): Promise<void> {
for (const id of emailIds) { for (const id of emailIds) {
const email = this.data.emails.find(e => e.id === id); const email = this.data.emails.find(e => e.id === id);
if (email) { if (email) {
@@ -233,7 +233,7 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts(); this.recalcMailboxCounts();
} }
async toggleStar(emailId: string, starred: boolean): Promise<void> { async toggleStar(emailId: string, starred: boolean, _accountId?: string): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId); const email = this.data.emails.find(e => e.id === emailId);
if (!email) return; if (!email) return;
if (starred) email.keywords.$flagged = true; if (starred) email.keywords.$flagged = true;
@@ -275,7 +275,7 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts(); this.recalcMailboxCounts();
} }
async batchDeleteEmails(emailIds: string[]): Promise<void> { async batchDeleteEmails(emailIds: string[], _accountId?: string): Promise<void> {
const idSet = new Set(emailIds); const idSet = new Set(emailIds);
this.data.emails = this.data.emails.filter(e => !idSet.has(e.id)); this.data.emails = this.data.emails.filter(e => !idSet.has(e.id));
this.recalcMailboxCounts(); this.recalcMailboxCounts();
+22 -8
View File
@@ -64,6 +64,11 @@ export const BUNDLE_TOKENS: { token: string; description: string }[] = [
{ token: "day", description: "2-digit day" }, { token: "day", description: "2-digit day" },
]; ];
// Overall cap for a generated filename stem. Also used as the per-token cap so
// a single long token (e.g. {subject}) isn't truncated earlier than the final
// filename would be.
const FILENAME_MAX_LEN = 200;
function sanitizePart(input: string, maxLen = 80): string { function sanitizePart(input: string, maxLen = 80): string {
const cleaned = input const cleaned = input
.replace(SAFE_CHARS, "_") .replace(SAFE_CHARS, "_")
@@ -173,7 +178,7 @@ function renderRaw(template: string, vars: Record<string, string>): string {
return template.replace(/\{(\w+)\}/g, (_, key: string) => { return template.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = vars[key]; const value = vars[key];
if (value === undefined) return ""; if (value === undefined) return "";
return sanitizePart(value); return sanitizePart(value, FILENAME_MAX_LEN);
}); });
} }
@@ -184,9 +189,9 @@ export function emailExportFilename(
const opts = typeof options === "string" ? { template: options } : options; const opts = typeof options === "string" ? { template: options } : options;
const template = opts.template ?? DEFAULT_EMAIL_TEMPLATE; const template = opts.template ?? DEFAULT_EMAIL_TEMPLATE;
const rendered = renderRaw(template, emailVars(email)); const rendered = renderRaw(template, emailVars(email));
const cleaned = sanitizePart(rendered, 200); const cleaned = sanitizePart(rendered, FILENAME_MAX_LEN);
const transformed = applyTransforms(cleaned, opts); const transformed = applyTransforms(cleaned, opts);
const stem = transformed.slice(0, 200) || "email"; const stem = transformed.slice(0, FILENAME_MAX_LEN) || "email";
return `${stem}.eml`; return `${stem}.eml`;
} }
@@ -199,7 +204,7 @@ export function attachmentDownloadFilename(
const template = opts.template ?? DEFAULT_ATTACHMENT_TEMPLATE; const template = opts.template ?? DEFAULT_ATTACHMENT_TEMPLATE;
if (!email) { if (!email) {
const filename = (attachment.name || "attachment").trim(); const filename = (attachment.name || "attachment").trim();
const cleaned = sanitizePart(filename, 200) || "attachment"; const cleaned = sanitizePart(filename, FILENAME_MAX_LEN) || "attachment";
return applyTransforms(cleaned, opts) || cleaned; return applyTransforms(cleaned, opts) || cleaned;
} }
const vars = attachmentVars(email, attachment); const vars = attachmentVars(email, attachment);
@@ -208,10 +213,10 @@ export function attachmentDownloadFilename(
if (value === undefined) return ""; if (value === undefined) return "";
// Preserve dots in {filename} so the original extension survives the // Preserve dots in {filename} so the original extension survives the
// sanitiser (it strips trailing dots otherwise). // sanitiser (it strips trailing dots otherwise).
return key === "filename" ? value.replace(SAFE_CHARS, "_") : sanitizePart(value); return key === "filename" ? value.replace(SAFE_CHARS, "_") : sanitizePart(value, FILENAME_MAX_LEN);
}); });
const templateMentionsExt = /\{(ext|filename)\}/.test(template); const templateMentionsExt = /\{(ext|filename)\}/.test(template);
const cleaned = sanitizePart(rendered, 200) || "attachment"; const cleaned = sanitizePart(rendered, FILENAME_MAX_LEN) || "attachment";
if (templateMentionsExt) { if (templateMentionsExt) {
return applyTransforms(cleaned, opts) || cleaned; return applyTransforms(cleaned, opts) || cleaned;
} }
@@ -222,6 +227,15 @@ export function attachmentDownloadFilename(
return `${transformedStem}.${transformedExt}`; return `${transformedStem}.${transformedExt}`;
} }
// Name for a .zip bundling every attachment of a single email, e.g.
// `attachments_Invoice March.zip`. Falls back to `attachments.zip` when the
// subject is empty or sanitises away to nothing.
export function attachmentsBundleFilename(email: Email | null | undefined): string {
const subject = email?.subject?.trim();
const stem = subject ? sanitizePart(subject, FILENAME_MAX_LEN) : "";
return stem ? `attachments_${stem}.zip` : "attachments.zip";
}
export function bundleVars(count: number, iso?: string): Record<string, string> { export function bundleVars(count: number, iso?: string): Record<string, string> {
const dp = dateParts(iso ?? new Date().toISOString()); const dp = dateParts(iso ?? new Date().toISOString());
return { ...dp, count: String(count) }; return { ...dp, count: String(count) };
@@ -235,9 +249,9 @@ export function bundleExportFilename(
const opts = typeof options === "string" ? { template: options } : options; const opts = typeof options === "string" ? { template: options } : options;
const template = opts.template ?? DEFAULT_BUNDLE_TEMPLATE; const template = opts.template ?? DEFAULT_BUNDLE_TEMPLATE;
const rendered = renderRaw(template, bundleVars(count, iso)); const rendered = renderRaw(template, bundleVars(count, iso));
const cleaned = sanitizePart(rendered, 200); const cleaned = sanitizePart(rendered, FILENAME_MAX_LEN);
const transformed = applyTransforms(cleaned, opts); const transformed = applyTransforms(cleaned, opts);
const stem = transformed.slice(0, 200) || "emails"; const stem = transformed.slice(0, FILENAME_MAX_LEN) || "emails";
return `${stem}.zip`; return `${stem}.zip`;
} }
+77 -7
View File
@@ -1,3 +1,5 @@
import { isValidEmail } from "@/lib/validation";
const HTML_ESCAPE_MAP = { const HTML_ESCAPE_MAP = {
"&": "&amp;", "&": "&amp;",
"<": "&lt;", "<": "&lt;",
@@ -56,13 +58,16 @@ export function rewriteCidImagesForEditor(html: string): string {
export type Recipient = { name?: string; email: string }; export type Recipient = { name?: string; email: string };
/** /**
* Splits a comma-separated recipient string into individual entries. Commas * Splits a recipient string into individual entries on any character in
* inside a quoted display name (`"Doo, John" <john@doo.org>`) or angle brackets * `separators`, treating those characters as literal when they sit inside a
* (`<a,b@x>`) are treated as literal, not separators. Only used at the * quoted display name (`"Doo, John" <john@doo.org>`) or angle brackets
* (de)serialization boundary the live composer state is an array, so the UI * (`<a,b@x>`). Trims each part and drops empties.
* never round-trips through this. Trims each part and drops empties. *
* Defaults to comma-only, the (de)serialization boundary used by the composer
* state and mailto handling. Pasted lists pass a wider set (see
* {@link splitPasteEntries}) because they also use `;` and line breaks.
*/ */
export function splitRecipients(value: string): string[] { export function splitRecipients(value: string, separators = ','): string[] {
const result: string[] = []; const result: string[] = [];
let current = ''; let current = '';
let inQuotes = false; let inQuotes = false;
@@ -77,7 +82,7 @@ export function splitRecipients(value: string): string[] {
} else if (ch === '>' && !inQuotes) { } else if (ch === '>' && !inQuotes) {
inAngle = false; inAngle = false;
current += ch; current += ch;
} else if (ch === ',' && !inQuotes && !inAngle) { } else if (separators.includes(ch) && !inQuotes && !inAngle) {
const trimmed = current.trim(); const trimmed = current.trim();
if (trimmed) result.push(trimmed); if (trimmed) result.push(trimmed);
current = ''; current = '';
@@ -140,6 +145,71 @@ export function formatRecipientList(recipients: Recipient[]): string {
return recipients.map((r) => formatRecipient(r.name, r.email)).join(', '); return recipients.map((r) => formatRecipient(r.name, r.email)).join(', ');
} }
/**
* Top-level split of a pasted block into recipient entries on commas,
* semicolons and newlines (separators inside a quoted name or angle brackets
* stay literal). Broader than the comma-only default of {@link splitRecipients}
* because pasted lists also use `;` and line breaks as separators.
*/
function splitPasteEntries(value: string): string[] {
return splitRecipients(value, ',;\n\r');
}
/**
* Splits pasted text into recipient candidates and partitions them: valid email
* addresses become `Recipient`s (deduped case-insensitively against
* `existingEmails` and within the paste), and everything else is returned as
* `invalid` for the caller to drop back into the input field.
*
* Handles both structured and bare lists, preserving display names:
* - `"Name <email>"` (the whole recipient quoted), `Name <email>`, and
* `"Doe, John" <email>` entries are kept intact with their display name.
* - Bare-address dumps (`a@x.com b@y.com`, spreadsheet columns, comma/space/
* semicolon/newline separated) split into one chip per address.
* - A token wrapped in angle brackets (`<a@x.com>`) is unwrapped before
* validating, so an `a <a@x.com>` fragment still yields the address.
*/
export function splitPastedRecipients(
text: string,
existingEmails: string[] = [],
): { valid: Recipient[]; invalid: string[] } {
const seen = new Set(existingEmails.map((e) => e.toLowerCase()));
const valid: Recipient[] = [];
const invalid: string[] = [];
// Adds a recipient if its address is valid and unseen. Returns true when the
// entry is fully handled (valid or a known duplicate) so the caller can stop.
const tryAdd = (r: Recipient): boolean => {
if (!isValidEmail(r.email)) return false;
const key = r.email.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
valid.push(r.name ? { name: r.name, email: r.email } : { email: r.email });
}
return true;
};
// Split on commas, semicolons and newlines in a quote/angle-aware way so a
// `"Doe, John" <j@x.com>` or fully-quoted `"Name <email>"` entry stays a
// single recipient (separators inside the name or the address are literal).
for (const entry of splitPasteEntries(text)) {
// 1. Structured: `Name <email>`, a bare address, or the whole
// `Name <email>` wrapped in quotes (unwrap once and retry).
if (tryAdd(parseRecipient(entry))) continue;
const unwrapped = unquoteName(entry);
if (unwrapped !== entry && tryAdd(parseRecipient(unwrapped))) continue;
// 2. Fallback: a bare-address run (`a@x.com b@y.com`) or a
// `John Doe <j@x.com>` fragment where only the <addr> is valid.
// Whitespace/semicolon-tokenize; leftover tokens stay behind.
for (const token of entry.split(/[\s;]+/).map((t) => t.trim()).filter(Boolean)) {
if (!tryAdd({ email: token.replace(/^<|>$/g, '') })) invalid.push(token);
}
}
return { valid, invalid };
}
/** /**
* Replaces the placeholder src on `<img data-cid="...">` elements with the * Replaces the placeholder src on `<img data-cid="...">` elements with the
* resolved data URL once the inline blob has been fetched. Leaves images * resolved data URL once the inline blob has been fetched. Leaves images
+155
View File
@@ -140,6 +140,161 @@ export function sanitizePlainTextRenderedHtml(html: string): string {
return DOMPurify.sanitize(html, PLAIN_TEXT_RENDERED_CONFIG); return DOMPurify.sanitize(html, PLAIN_TEXT_RENDERED_CONFIG);
} }
/**
* 1x1 transparent SVG used to replace a blocked external <img> so the layout
* doesn't reflow to a broken-image icon. The real URL is stashed in
* `data-blocked-src` for restore.
*/
export const TRANSPARENT_BLOCKED_PIXEL =
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4=';
/**
* True if a resource URL would trigger an external (network) fetch once the
* browser normalizes it. The URL parser removes ASCII tab/newline characters
* anywhere in the string and trims leading/trailing C0-control + space before
* resolving, so `"\n\nhttps://t"` and `"h\ttps://t"` are both external even
* though they don't literally start with "https://" (the `imgNewlineSrc`
* tracking bypass). Protocol-relative `//host` is external too. data:, blob:,
* and cid: are inline/local and never count as external.
*/
export function isExternalResourceUrl(value: string | null | undefined): boolean {
if (!value) return false;
// Mirror the URL parser: drop every ASCII C0-control and space char it
// ignores (leading/trailing trim plus tab/newline/CR removed anywhere).
// eslint-disable-next-line no-control-regex
const normalized = value.replace(/[\u0000-\u0020]+/g, '');
return /^(?:https?:\/\/|\/\/)/i.test(normalized);
}
/**
* Decode CSS escape sequences so escaped tracking URLs can be recognised.
* `\68ttp://x` and `\000068ttp://x` both decode to `http://x` (the `cssEscape`
* bypass). Handles the two CSS escape forms: 1-6 hex digits (optionally
* followed by one whitespace) and a backslash before any other character.
*/
export function decodeCssEscapes(value: string): string {
return value.replace(/\\([0-9a-fA-F]{1,6})\s?|\\(.)/g, (_full, hex, char) => {
if (hex) {
const code = parseInt(hex, 16);
return code ? String.fromCodePoint(code) : '';
}
return char ?? '';
});
}
const CSS_URL_PATTERN = /url\(\s*(['"]?)([^)]*?)\1\s*\)/gi;
/** True if any `url(...)` in a CSS string resolves to an external resource. */
export function styleHasExternalUrl(style: string): boolean {
let found = false;
style.replace(CSS_URL_PATTERN, (full, _q, inner) => {
if (isExternalResourceUrl(decodeCssEscapes(inner))) found = true;
return full;
});
return found;
}
/** Replace every external `url(...)` in a CSS string with an empty `url()`. */
export function stripExternalCssUrls(style: string): string {
return style.replace(CSS_URL_PATTERN, (full, _q, inner) =>
isExternalResourceUrl(decodeCssEscapes(inner)) ? 'url()' : full
);
}
/** True if a srcset attribute lists at least one external candidate URL. */
function srcsetHasExternalUrl(srcset: string): boolean {
return srcset
.split(',')
.some((candidate) => isExternalResourceUrl(candidate.trim().split(/\s+/)[0]));
}
/**
* Neutralise every external-resource vector on a single sanitized element,
* stashing the original value in a `data-blocked-*` attribute for later
* restore. Covers the vectors Email Privacy Tester exercises beyond a bare
* `<img src>`: whitespace/newline in src, `<picture><source srcset>`,
* `<video poster>`/media src, the legacy `background` attribute, and inline
* `style` url() (including CSS-escaped URLs).
*
* This is the first line of defence (it drives the "external content blocked"
* banner and placeholder swap); the iframe's strict img-src/media-src/font-src
* CSP is the guaranteed network-level backstop for anything expressed in ways
* the DOM walk can't see (e.g. `<style>`-tag rules).
*
* @returns true if anything on the node was blocked.
*/
export function blockExternalResourcesOnNode(node: Element): boolean {
let blocked = false;
const tag = node.tagName;
if (tag === 'IMG') {
const src = node.getAttribute('src');
if (isExternalResourceUrl(src)) {
node.setAttribute('data-blocked-src', src!.trim());
node.setAttribute('src', TRANSPARENT_BLOCKED_PIXEL);
node.setAttribute('alt', '');
(node as HTMLElement).style.display = 'none';
blocked = true;
}
}
// Responsive images: <img srcset> and <picture><source srcset>.
if (tag === 'IMG' || tag === 'SOURCE') {
const srcset = node.getAttribute('srcset');
if (srcset && srcsetHasExternalUrl(srcset)) {
node.setAttribute('data-blocked-srcset', srcset);
node.removeAttribute('srcset');
blocked = true;
}
}
// <source src> for <video>/<audio> (and rare <picture> src).
if (tag === 'SOURCE') {
const src = node.getAttribute('src');
if (isExternalResourceUrl(src)) {
node.setAttribute('data-blocked-src', src!.trim());
node.removeAttribute('src');
blocked = true;
}
}
// <video poster> and direct <video>/<audio> src.
if (tag === 'VIDEO' || tag === 'AUDIO') {
const poster = node.getAttribute('poster');
if (isExternalResourceUrl(poster)) {
node.setAttribute('data-blocked-poster', poster!.trim());
node.removeAttribute('poster');
blocked = true;
}
const src = node.getAttribute('src');
if (isExternalResourceUrl(src)) {
node.setAttribute('data-blocked-src', src!.trim());
node.removeAttribute('src');
blocked = true;
}
}
// Legacy table/cell background attribute.
const bgAttr = node.getAttribute('background');
if (isExternalResourceUrl(bgAttr)) {
node.setAttribute('data-blocked-background', bgAttr!.trim());
node.removeAttribute('background');
blocked = true;
}
// Inline style url() — read the raw attribute so CSS escapes survive for
// decoding, then strip only the external urls.
const styleAttr = node.getAttribute('style');
if (styleAttr && styleHasExternalUrl(styleAttr)) {
node.setAttribute('data-blocked-style', styleAttr);
node.setAttribute('style', stripExternalCssUrls(styleAttr));
blocked = true;
}
return blocked;
}
/** /**
* Safe HTML parsing without execution * Safe HTML parsing without execution
* Use instead of innerHTML for detection/parsing * Use instead of innerHTML for detection/parsing
+145
View File
@@ -0,0 +1,145 @@
// Shared "View source" renderer. Builds a human-readable dump of a message's
// headers, metadata and body from its JMAP Email object. Used both by the
// email viewer's source modal and by the plugin projection so plugins see the
// exact same text the UI shows. Pure: depends only on the passed `email`.
import type { Email } from '@/lib/jmap/types';
import { formatFileSize } from '@/lib/utils';
export function generateEmailSource(email: Email): string {
let source = '';
// Headers
source += '=== EMAIL HEADERS ===\n\n';
if (email.messageId) source += `Message-ID: ${email.messageId}\n`;
if (email.from) source += `From: ${email.from.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`;
if (email.to) source += `To: ${email.to.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`;
if (email.cc) source += `Cc: ${email.cc.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`;
if (email.bcc) source += `Bcc: ${email.bcc.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`;
if (email.replyTo) source += `Reply-To: ${email.replyTo.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`;
if (email.subject) source += `Subject: ${email.subject}\n`;
if (email.sentAt) source += `Date: ${new Date(email.sentAt).toUTCString()}\n`;
if (email.receivedAt) source += `Received-At: ${new Date(email.receivedAt).toUTCString()}\n`;
if (email.inReplyTo) source += `In-Reply-To: ${email.inReplyTo.join(', ')}\n`;
if (email.references) source += `References: ${email.references.join(', ')}\n`;
// Additional headers
if (email.headers) {
source += '\n--- Additional Headers ---\n';
// Headers should now always be a Record after client processing
Object.entries(email.headers).forEach(([key, value]) => {
const val = Array.isArray(value) ? value.join('\n ') : String(value);
source += `${key}: ${val}\n`;
});
}
// Authentication results
if (email.authenticationResults) {
source += '\n--- Authentication Results ---\n';
if (email.authenticationResults.spf) {
source += `SPF: ${email.authenticationResults.spf.result}`;
if (email.authenticationResults.spf.domain) source += ` (${email.authenticationResults.spf.domain})`;
source += '\n';
}
if (email.authenticationResults.dkim) {
source += `DKIM: ${email.authenticationResults.dkim.result}`;
if (email.authenticationResults.dkim.domain) source += ` (${email.authenticationResults.dkim.domain})`;
source += '\n';
}
if (email.authenticationResults.dmarc) {
source += `DMARC: ${email.authenticationResults.dmarc.result}`;
if (email.authenticationResults.dmarc.policy) source += ` policy=${email.authenticationResults.dmarc.policy}`;
source += '\n';
}
}
if (email.spamScore !== undefined) {
source += `Spam Score: ${email.spamScore}`;
if (email.spamStatus) source += ` (${email.spamStatus})`;
source += '\n';
}
// Metadata
source += '\n=== EMAIL METADATA ===\n\n';
source += `Email ID: ${email.id}\n`;
source += `Thread ID: ${email.threadId}\n`;
source += `Size: ${formatFileSize(email.size)}\n`;
source += `Has Attachment: ${email.hasAttachment ? 'Yes' : 'No'}\n`;
if (email.keywords) {
const keywords = Object.entries(email.keywords)
.filter(([_, v]) => v)
.map(([k]) => k)
.join(', ');
if (keywords) source += `Keywords: ${keywords}\n`;
}
// Attachments
if (email.attachments && email.attachments.length > 0) {
source += '\n=== ATTACHMENTS ===\n\n';
email.attachments.forEach((att, i) => {
source += `[${i + 1}] ${att.name || 'Unnamed'}\n`;
source += ` Type: ${att.type}\n`;
source += ` Size: ${formatFileSize(att.size)}\n`;
source += ` Blob ID: ${att.blobId}\n`;
if (att.cid) source += ` Content-ID: ${att.cid}\n`;
source += '\n';
});
}
// Body content
source += '\n=== EMAIL BODY ===\n\n';
let hasBodyContent = false;
// Text version
if (email.textBody?.[0]?.partId && email.bodyValues?.[email.textBody[0].partId]) {
const textValue = email.bodyValues[email.textBody[0].partId].value;
if (textValue && textValue.trim()) {
source += '--- Plain Text Version ---\n\n';
source += textValue;
source += '\n\n';
hasBodyContent = true;
}
}
// HTML version
if (email.htmlBody?.[0]?.partId && email.bodyValues?.[email.htmlBody[0].partId]) {
const htmlValue = email.bodyValues[email.htmlBody[0].partId].value;
if (htmlValue && htmlValue.trim()) {
source += '--- HTML Version ---\n\n';
source += htmlValue;
source += '\n\n';
hasBodyContent = true;
}
}
// All body values if we haven't found content yet
if (!hasBodyContent && email.bodyValues) {
const bodyKeys = Object.keys(email.bodyValues);
if (bodyKeys.length > 0) {
source += '--- Body Parts ---\n\n';
bodyKeys.forEach((key, index) => {
const bodyValue = email.bodyValues![key].value;
if (bodyValue && bodyValue.trim()) {
source += `Part ${index + 1} (${key}):\n`;
source += bodyValue;
source += '\n\n';
hasBodyContent = true;
}
});
}
}
// Preview if no body
if (!hasBodyContent && email.preview) {
source += '--- Preview Only ---\n\n';
source += email.preview;
source += '\n';
}
if (!hasBodyContent && !email.preview) {
source += '(No body content available)\n';
}
return source;
}
+4 -4
View File
@@ -73,7 +73,7 @@ export interface IJMAPClient {
// ── Mailboxes ───────────────────────────────────────────────── // ── Mailboxes ─────────────────────────────────────────────────
getMailboxes(accountId?: string): Promise<Mailbox[]>; getMailboxes(accountId?: string): Promise<Mailbox[]>;
getAllMailboxes(): Promise<Mailbox[]>; getAllMailboxes(): Promise<Mailbox[]>;
createMailbox(name: string, parentId?: string): Promise<Mailbox>; createMailbox(name: string, parentId?: string, accountId?: string): Promise<Mailbox>;
updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void>; updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void>;
deleteMailbox(mailboxId: string): Promise<void>; deleteMailbox(mailboxId: string): Promise<void>;
@@ -92,14 +92,14 @@ export interface IJMAPClient {
// ── Email mutations ─────────────────────────────────────────── // ── Email mutations ───────────────────────────────────────────
markAsRead(emailId: string, read?: boolean, accountId?: string): Promise<void>; markAsRead(emailId: string, read?: boolean, accountId?: string): Promise<void>;
batchMarkAsRead(emailIds: string[], read?: boolean): Promise<void>; batchMarkAsRead(emailIds: string[], read?: boolean, accountId?: string): Promise<void>;
toggleStar(emailId: string, starred: boolean): Promise<void>; toggleStar(emailId: string, starred: boolean, accountId?: string): Promise<void>;
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>; updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>;
setKeyword(emailId: string, keyword: string): Promise<void>; setKeyword(emailId: string, keyword: string): Promise<void>;
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>; migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
deleteEmail(emailId: string, accountId?: string): Promise<void>; deleteEmail(emailId: string, accountId?: string): Promise<void>;
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>; moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
batchDeleteEmails(emailIds: string[]): Promise<void>; batchDeleteEmails(emailIds: string[], accountId?: string): Promise<void>;
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>; batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
batchArchiveEmails( batchArchiveEmails(
emails: Array<{ id: string; receivedAt: string }>, emails: Array<{ id: string; receivedAt: string }>,
+36 -18
View File
@@ -459,8 +459,11 @@ function sanitizeIdentityDisplayName(name: string | undefined | null): string {
} }
function normalizeEnvelopeRecipients(recipients?: Array<string | EmailAddress>): Array<{ email: string }> { function normalizeEnvelopeRecipients(recipients?: Array<string | EmailAddress>): Array<{ email: string }> {
// The JMAP envelope rcptTo/mailFrom take a bare addr-spec, not an RFC 5322
// mailbox. `to`/`cc`/`bcc` may arrive as "Name <addr>"; strip the display
// name or the submission validator rejects the whole envelope (#…).
return (recipients || []) return (recipients || [])
.map((recipient) => typeof recipient === 'string' ? recipient : recipient.email) .map((recipient) => typeof recipient === 'string' ? parseRecipientString(recipient).email : recipient.email)
.map((email) => email.trim()) .map((email) => email.trim())
.filter(Boolean) .filter(Boolean)
.map((email) => ({ email })); .map((email) => ({ email }));
@@ -699,7 +702,10 @@ export class JMAPClient implements IJMAPClient {
if (sessionResponse.status === 402) { if (sessionResponse.status === 402) {
try { try {
const body = await sessionResponse.json(); const body = await sessionResponse.json();
if (body?.title?.toLowerCase().includes('totp')) { // Older Stalwart titled this "TOTP code required"; 0.16+ uses the
// generic "MFA code required" - accept either to trigger the prompt.
const title = body?.title?.toLowerCase() ?? '';
if (title.includes('totp') || title.includes('mfa')) {
throw new Error('TOTP_REQUIRED'); throw new Error('TOTP_REQUIRED');
} }
} catch (e) { } catch (e) {
@@ -1273,19 +1279,19 @@ export class JMAPClient implements IJMAPClient {
]); ]);
} }
async batchMarkAsRead(emailIds: string[], read: boolean = true): Promise<void> { async batchMarkAsRead(emailIds: string[], read: boolean = true, accountId?: string): Promise<void> {
if (emailIds.length === 0) return; if (emailIds.length === 0) return;
const updates = Object.fromEntries(emailIds.map(id => [id, { "keywords/$seen": read }])); const updates = Object.fromEntries(emailIds.map(id => [id, { "keywords/$seen": read }]));
await this.request([ await this.request([
["Email/set", { accountId: this.accountId, update: updates }, "0"], ["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
]); ]);
} }
async toggleStar(emailId: string, starred: boolean): Promise<void> { async toggleStar(emailId: string, starred: boolean, accountId?: string): Promise<void> {
await this.request([ await this.request([
["Email/set", { ["Email/set", {
accountId: this.accountId, accountId: accountId || this.accountId,
update: { update: {
[emailId]: { [emailId]: {
"keywords/$flagged": starred, "keywords/$flagged": starred,
@@ -1391,12 +1397,12 @@ export class JMAPClient implements IJMAPClient {
]); ]);
} }
async batchDeleteEmails(emailIds: string[]): Promise<void> { async batchDeleteEmails(emailIds: string[], accountId?: string): Promise<void> {
if (emailIds.length === 0) return; if (emailIds.length === 0) return;
await this.request([ await this.request([
["Email/set", { ["Email/set", {
accountId: this.accountId, accountId: accountId || this.accountId,
destroy: emailIds, destroy: emailIds,
}, "0"], }, "0"],
]); ]);
@@ -1667,7 +1673,7 @@ export class JMAPClient implements IJMAPClient {
async markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void> { async markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
const targetAccountId = accountId || this.accountId; const targetAccountId = accountId || this.accountId;
const mailboxes = await this.getMailboxes(); const mailboxes = await this.getMailboxes(accountId);
const junkMailbox = mailboxes.find(m => { const junkMailbox = mailboxes.find(m => {
if (accountId) { if (accountId) {
return m.role === 'junk' && m.accountId === accountId; return m.role === 'junk' && m.accountId === accountId;
@@ -1709,7 +1715,7 @@ export class JMAPClient implements IJMAPClient {
]); ]);
} }
async createMailbox(name: string, parentId?: string): Promise<Mailbox> { async createMailbox(name: string, parentId?: string, accountId?: string): Promise<Mailbox> {
const createId = `new-${Date.now()}`; const createId = `new-${Date.now()}`;
const createData: Record<string, unknown> = { name }; const createData: Record<string, unknown> = { name };
if (parentId) { if (parentId) {
@@ -1718,7 +1724,7 @@ export class JMAPClient implements IJMAPClient {
const response = await this.request([ const response = await this.request([
["Mailbox/set", { ["Mailbox/set", {
accountId: this.accountId, accountId: accountId || this.accountId,
create: { [createId]: createData }, create: { [createId]: createData },
}, "0"], }, "0"],
]); ]);
@@ -2388,13 +2394,10 @@ export class JMAPClient implements IJMAPClient {
const buildSubmissionCreate = (submissionId: string): Record<string, unknown> => { const buildSubmissionCreate = (submissionId: string): Record<string, unknown> => {
const create: Record<string, unknown> = { emailId: `#${emailId}`, identityId: finalIdentityId }; const create: Record<string, unknown> = { emailId: `#${emailId}`, identityId: finalIdentityId };
if (holdForSeconds || envelopeMailFrom) { if (holdForSeconds || envelopeMailFrom) {
const envelopeRecipients = [...to, ...(cc || []), ...(bcc || [])] const envelopeRecipients = normalizeEnvelopeRecipients([...to, ...(cc || []), ...(bcc || [])]);
.map((email) => email.trim())
.filter(Boolean)
.map((email) => ({ email }));
create.envelope = { create.envelope = {
mailFrom: { mailFrom: {
email: envelopeMailFrom || fromEmail || this.username, email: parseRecipientString(envelopeMailFrom || fromEmail || this.username).email,
...(holdForSeconds ? { parameters: { HOLDFOR: String(holdForSeconds) } } : {}), ...(holdForSeconds ? { parameters: { HOLDFOR: String(holdForSeconds) } } : {}),
}, },
rcptTo: envelopeRecipients, rcptTo: envelopeRecipients,
@@ -3367,7 +3370,16 @@ export class JMAPClient implements IJMAPClient {
} }
private getSubmissionAccountId(accountId?: string): string { private getSubmissionAccountId(accountId?: string): string {
return accountId || this.session?.primaryAccounts?.['urn:ietf:params:jmap:submission'] || this.accountId; // The requested (mail) account may not host EmailSubmission objects — JMAP
// allows submission to live in a separate account (session
// primaryAccounts['…:submission']). Only honour the requested account when
// it actually advertises the submission capability; otherwise fall back to
// the account JMAP designates for submission.
const submissionPrimary = this.session?.primaryAccounts?.['urn:ietf:params:jmap:submission'];
if (accountId && this.session?.accounts?.[accountId]?.accountCapabilities?.['urn:ietf:params:jmap:submission']) {
return accountId;
}
return submissionPrimary || accountId || this.accountId;
} }
private getSubmissionCapability(accountId?: string): SubmissionCapability | undefined { private getSubmissionCapability(accountId?: string): SubmissionCapability | undefined {
@@ -4729,8 +4741,14 @@ export class JMAPClient implements IJMAPClient {
debug.log('calendar', 'CalendarEvent/batchCreate', { count: events.length, accountId }); debug.log('calendar', 'CalendarEvent/batchCreate', { count: events.length, accountId });
// Never emit iMIP scheduling messages when importing. Imported events often
// carry an organizer/participants where the current user is the organizer;
// without this, Stalwart tries to send invitation emails to every attendee
// synchronously during CalendarEvent/set, which is both wrong (importing a
// calendar should not spam invites) and can block the request indefinitely,
// leaving the import spinner spinning forever (#411).
const response = await this.request([ const response = await this.request([
["CalendarEvent/set", { accountId, create: createMap }, "0"] ["CalendarEvent/set", { accountId, sendSchedulingMessages: false, create: createMap }, "0"]
], this.calendarUsing()); ], this.calendarUsing());
const createdIds: string[] = []; const createdIds: string[] = [];
+54 -1
View File
@@ -58,9 +58,27 @@ export interface Email {
// S/MIME support // S/MIME support
blobId?: string; blobId?: string;
bodyStructure?: EmailBodyPart; bodyStructure?: EmailBodyPart;
// Unified mailbox support - set when displaying emails from multiple accounts // Unified mailbox support - set when displaying emails from multiple accounts.
// `accountId` is a DISPLAY-only reference (avatar color / label / badge) and may
// hold either an AccountEntry.id (personal) or the JMAP owner id (shared). For
// resolving the client + JMAP routing use the two dedicated fields below, which
// are always set on aggregated emails and unambiguous.
accountId?: string; accountId?: string;
accountLabel?: string; accountLabel?: string;
// AccountEntry.id of the logged-in client through which this email is reachable.
// Always a real login key → `useAuthStore.getClientForAccount(...)` resolves it.
// For personal sources this equals the account itself; for shared/group sources
// it is the delegating login (the shared account has no own login).
sourceClientAccountId?: string;
// JMAP account id of the email's owning account (personal: the client's primary;
// shared/group: the owner account). Always safe to pass as the JMAP `accountId`
// argument — equal to the client's primary for personal sources, so it is a no-op
// there, and triggers owner-scoped routing + mailbox-id namespacing for shared.
sourceAccountId?: string;
// Name of the email's originating folder, stamped for the aggregate "All …"
// views (All Mail, unified, cross-account) so the list can show where each
// message lives. Transient/client-only, not part of the JMAP object.
sourceFolder?: string;
// Client-only scheduled-send metadata, populated from EmailSubmission/query. // Client-only scheduled-send metadata, populated from EmailSubmission/query.
scheduledSendAt?: string; scheduledSendAt?: string;
emailSubmissionId?: string; emailSubmissionId?: string;
@@ -879,3 +897,38 @@ export function isUnifiedMailboxId(id: string): boolean {
* included is a per-user setting (see `allMailFolderIds`). * included is a per-user setting (see `allMailFolderIds`).
*/ */
export const ALL_MAIL_MAILBOX_ID = '__all_mail__'; export const ALL_MAIL_MAILBOX_ID = '__all_mail__';
/**
* Cross-account "All …" views shown in the unified ("All accounts") section.
* Each merges messages across EVERY account (including shared/group folders),
* spanning all folders except junk/spam, sent, archive, trash and drafts, in
* one date-sorted list. Distinct from the per-role unified ids (one role across
* accounts) and from ALL_MAIL_MAILBOX_ID (all folders of a single account).
*/
export const CROSS_UNREAD = '__cross_unread__';
export const CROSS_STARRED = '__cross_starred__';
export const CROSS_ALL = '__cross_all__';
export type CrossView = 'unread' | 'starred' | 'all';
export const CROSS_VIEW_IDS: Record<CrossView, string> = {
unread: CROSS_UNREAD,
starred: CROSS_STARRED,
all: CROSS_ALL,
};
export const CROSS_VIEW_BY_ID: Record<string, CrossView> = Object.fromEntries(
Object.entries(CROSS_VIEW_IDS).map(([view, id]) => [id, view as CrossView])
) as Record<string, CrossView>;
export function isCrossViewId(id: string): boolean {
return id in CROSS_VIEW_BY_ID;
}
/**
* Mailbox roles excluded from the cross-account views. Everything else (inbox
* and custom/no-role folders) is included.
*/
export const CROSS_EXCLUDED_ROLES: ReadonlySet<string> = new Set([
'junk', 'sent', 'archive', 'trash', 'drafts',
]);

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