Compare commits

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

fa in turn carried four keys that do not exist in en and are not
referenced anywhere in the code (the email_composer.text_direction
block and settings.templates.image_too_large) - removed, as the
suite's no-extra-keys check demands.
2026-07-08 22:43:49 +02:00
Hamed FallahandGitHub e10fced28a feat: add Jalali (Persian/Shamsi) calendar support with Saturday as week start (#490)
* feat: add Jalali (Persian/Shamsi) calendar support with Saturday as week start

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

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

* Add PR template for Jalali calendar feature

* chore: remove accidentally added PR template

* fix: add image_too_large key to fa locale for PR #462 compatibility
2026-07-08 15:42:42 +02:00
dealerwebandLinus Rath 3d36492518 i18n: restore key parity after the Hebrew locale
The translation parity test fails on main since the Hebrew locale
landed. Three gaps, all from catalogs drifting past each other:

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

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

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

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

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

- Neutralise height:100% on any element so the body grows to its content.
- Measure max(documentElement, body).scrollHeight, and re-measure on a
  fixed cadence over a short settle window so a late reflow (height:100%
  wrapper, or images that resize after onload) is caught even when no
  ResizeObserver/image event fires.
2026-07-07 23:40:18 +02:00
honzupandLinus Rath 7db6fd7e24 feat: add setting to disable tag-color row tint in message list
Message-list rows are tinted with the first tag's color, which becomes
overwhelming when a label applies to most messages (for example
per-account labels). Add a `tintListRowsByTag` setting (default true, so
current behavior is unchanged) with a toggle in Settings beside
"Colorful Sidebar Icons". When off, rows are not tinted; tag dots and
chips still show the color. Gated in both list renderers
(email-list-item and thread-list-item).
2026-07-07 23:39:05 +02:00
Linus Rath e066698938 fix: strike through cancelled events and mute their reminders #572 2026-07-07 23:26:36 +02:00
Linus Rath c3a97de62f fix: use calendarAddress/organizerCalendarAddress for scheduling, drop retired sendTo/replyTo #500 2026-07-07 23:18:05 +02:00
Linus Rath 29283282d5 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-07-07 20:36:05 +02:00
Linus Rath db2c642d74 fix: storage quota not shown with Stalwart #577 2026-07-07 20:35:37 +02:00
dealerwebandLinus Rath 9110bc388f Fix: keep the session when the auth server is briefly unreachable
Any transient failure used to end the session: the token route deleted
the refresh cookies on every non-OK answer from the OAuth endpoint,
refreshAccessToken logged out on any non-OK status or network error,
and the startup restore evicted the account and deleted its session
cookie. A server restart, a proxy hiccup, a Wi-Fi switch or a laptop
waking before the network is back all kicked the user out despite
"stay signed in".

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

Token validity stays entirely server-enforced: the first definitive
401 after an outage still logs out as before.
2026-07-06 15:52:05 +02:00
dealerwebandLinus Rath a4dc0b7b4e i18n: restore key parity for the Slovak locale
The Slovak translation was based on a slightly older en catalog, so the
translation parity test currently fails on main.

sk was missing ten keys that landed around the same time: the composer
attachment-upload validation strings, the recipient autocomplete
strings (translated to Slovak here) and the settings date_locale block
(kept as the same English values every other locale currently has).

The other twenty locales were missing language.sk in return - added as
the endonym "Slovenčina" (matching how the other entries are written),
and as "Slovacă" in ro, which translates its language names.
2026-07-06 13:36:51 +02:00
dealerwebandLinus Rath d384c3b553 Feature: pin emails to the top of the folder list
Outlook-Web-style pinning: a context-menu Pin/Unpin action stores a
$pinned keyword on the message (plain IMAP-compatible flag, survives
other clients), and pinned mails stay at the top of the folder list
regardless of age, marked with a pin icon.

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

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

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

Pin/Unpin strings are added to all 21 locales.
2026-07-06 13:36:25 +02:00
dealerwebandLinus Rath d7a64fd9d6 Fix: hide the spam action in Sent, Drafts and Scheduled
Marking your own outgoing mail as spam makes no sense, but the action
was offered in every non-junk folder: context menu, hover quick-actions,
viewer toolbar and its overflow menu, plus the "!" shortcut.

All surfaces now skip the action when the folder role is sent, drafts or
scheduled, and the shortcut is a no-op there. Scheduled messages were
already covered per-email via isScheduled; the role check additionally
covers the server-side Scheduled folder before that annotation loads.

The hover quick-actions bar gets a spamApplicable prop for this, since
it renders its buttons without knowing the folder.
2026-07-06 11:04:37 +02:00
dealerwebandLinus Rath 06ddda688d Fix: spam actions left folder counters and the open message stale
Marking mail as spam (or not spam) updated the email list but nothing
else, unlike delete/move which patch the folder counters optimistically.
Without a connected JMAP push the sidebar badges simply never moved, and
"not spam" additionally left the reading pane stuck on the message that
had just left the folder.

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

undoSpam advances the selection to the next message like markAsSpam
already did, and refreshes the mailbox list instead of patching counts:
in the undo-toast path the email is no longer in the list, so its unread
state is unknown and an optimistic patch is not possible.
2026-07-06 11:04:20 +02:00
Shuki VakninandLinus Rath 6b74615969 fix(email-viewer): hide images that fail to load
Instead of leaving the browser's broken-image placeholder + alt text (which
reads as stray label text — e.g. a 'logo' alt — in an otherwise image-only
email), hide any image that fails to load. Sanitizer-blocked images already use
a 1x1 transparent pixel with display:none, so they're unaffected.
2026-07-05 21:29:19 +02:00
Shuki VakninandLinus Rath 0d73cb5dfb fix(csp): allow external/data fonts so email webfonts render
The page CSP set font-src 'self', which blocked fonts referenced by
rendered email CSS (brand webfonts loaded over https). Allow https:/data:
for font-src. Email bodies render inside the sandboxed iframe, which keeps
its own stricter blocking-mode font-src for privacy.
2026-07-05 21:28:15 +02:00
b1b09d54c8 feat(pwa): add Apple Touch icons for iOS home screen
- Add apple-touch-icon.png (180x180) - default iOS icon
- Add apple-touch-icon-120x120.png - iPhone retina
- Add apple-touch-icon-152x152.png - iPad retina
- Add apple-touch-icon-167x167.png - iPad Pro
- Add apple-touch-icon-180x180.png - iPhone Plus/Pro

iOS automatically detects these icons when users add the PWA to their home screen.
No code changes required - icons are discovered by convention.

Fixes: PWA icon not appearing on iOS home screen
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 19:51:06 +02:00
Linus Rath 9930d19ba4 fix: keep advanced search filters applied when switching folders #553 2026-07-04 15:21:11 +02:00
Shuki VakninandLinus Rath 902774eae1 feat(list): click sender avatar to select message/thread (Thunderbird-style)
Wrap the message-list avatar in a SelectableAvatar control: clicking the
avatar toggles the row into the current selection instead of opening it,
matching Thunderbird's correspondent-avatar selection affordance. A check
overlay appears on hover (hinting it is clickable) and stays while selected.

- email-list-item + thread single-email: toggle that message's id
- thread header: toggle the whole thread (reuses existing thread-select logic)
- focused-mail and extra-compact layouts render no avatar, so unaffected
2026-07-04 15:00:15 +02:00
1429a6fe1e feat(login): configurable logo size + hideable heading/subtitle
Login header customization for white-label deployments, all defaults
preserve current behaviour:

- LOGIN_LOGO_MAX_HEIGHT / LOGIN_LOGO_MAX_WIDTH (any CSS length): the logo
  box is otherwise a fixed 64x64 (w-16/h-16), which fits a wide wordmark to
  ~13px tall. When either is set, the fixed box is dropped and the logo
  renders at the configured size.
- LOGIN_SHOW_HEADING / LOGIN_SHOW_SUBTITLE (default true): hide the
  {appName} heading and/or the subtitle when the logo already reads as the
  brand (e.g. a wordmark) and they'd be redundant.

Applied to the standard login header; wired through the existing config
registry (CONFIG_ENV_MAP) -> /api/config -> useConfig.

Refs #519.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:59:49 +02:00
Kristofer PettijohnandLinus Rath d1da83a5d6 fix: keep email list scrollable when bottom reading-pane is enabled with no conversation selected 2026-07-04 14:59:32 +02:00
Kristofer PettijohnandLinus Rath babacca482 fix(contacts): clear photo on server by sending media: null when removed 2026-07-04 14:59:06 +02:00
dealerwebandLinus Rath e6aa79ed94 Feature: recipient autocomplete from Sent, with on-demand server search
Compose recipient fields only suggested existing contacts and directory
users, so people you had emailed before but never saved as a contact
never came up. This adds an Outlook-Web-style suggestion flow.

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

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

New strings are added to all 20 locales.
2026-07-04 14:56:49 +02:00
dealerwebandLinus Rath 4d6b4b5b8e Fix: brand push notifications with the configured PWA icon
The service worker hard-coded the notification icon and badge to the bundled /icon-192x192.png, so push notifications always showed the default Bulwark logo even when an admin had configured a custom PWA/favicon icon (which the manifest already honors via /api/pwa-icon).

Point both notifications at /api/pwa-icon/192, and make that endpoint fall back to the bundled default icon instead of returning 404 when no custom icon is set - so it always returns an app icon and the service worker (which can't run the custom-vs-default check itself) has a single stable URL.
2026-07-04 14:56:19 +02:00
DaniilandLinus Rath d259168bd7 fix(shortcuts): also map /, ?, #, ! by physical position
Extend the layout-agnostic handling to the symbol shortcuts. On non-Latin
layouts the characters '/', '?', '#', '!' are often unreachable or on different
keys, so map them from their US-QWERTY physical codes (Slash, shifted Digit1 /
Digit3). Fixes e.g. '?' (open shortcuts help) on a Cyrillic layout.
2026-07-04 14:54:58 +02:00
DaniilandLinus Rath 97d87c44d0 fix(shortcuts): make keyboard shortcuts layout-agnostic
Shortcuts were matched against event.key, which returns a layout-dependent
character. On non-Latin layouts (Cyrillic, Greek, ...) the physical letter keys
produce non-Latin characters, so single-letter shortcuts (c, j, k, r, e, ...)
never fire and users must switch layout to use them.

Derive the letter from event.code (KeyA..KeyZ) instead, which is
layout-independent. Non-letter keys keep event.key (arrows/Enter are already
layout-independent; #, !, ?, / stay symbol-based).
2026-07-04 14:54:58 +02:00
KazNIISA ITandLinus Rath 396f5f7d60 fix(compose): wait for in-flight attachment uploads before sending
Clicking Send while attachments were still uploading silently dropped
them from the outgoing message: every place that builds the outgoing
attachment list filters on att.blobId && !att.uploading, and the Send
button never accounted for uploads still in flight. Attach a few files,
hit Send right away, and the email could go out missing some of them
with no warning.

handleSend now detects pending uploads before validating:

- Send is disabled with an explanatory tooltip
  (validation.attachments_uploading) while it waits.
- Once uploads finish cleanly, the send proceeds automatically - no
  second click needed.
- If an upload FAILS while waiting, the send is aborted with an error
  toast (validation.attachment_upload_failed) instead of silently
  shipping the email without the failed attachment - the user may not
  be looking at the composer to notice the red error chip.
- If the draft is closed or discarded while waiting, the pending send
  is cancelled cleanly.

The wait/decision logic lives in waitForPendingUploads() in
lib/email-composer-utils.ts (returns completed | cancelled | failed)
with unit tests covering all three outcomes. Outgoing-attachment
call sites read the freshest state via attachmentsRef since the
render closure captured at click time won't reflect uploads that
finished during the wait.

Both new i18n keys added to all 20 locales under
email_composer.validation.
2026-07-04 14:54:41 +02:00
dealerwebandLinus Rath 9aef8bc393 i18n: localize the 'Return to list after delete or mark unread' setting
This reading setting shipped with its English label/description as a placeholder in every locale except fa. Translate it into the remaining 18 locales (de, cs, da, es, fr, hu, it, ja, ko, lv, nl, pl, pt, ro, ru, tr, uk, zh), reusing each locale's existing 'mark as unread' wording for consistency.
2026-07-04 14:54:26 +02:00
Peter GondaandLinus Rath e05ab48a45 feat: add Slovak translation 2026-07-04 14:54:07 +02:00
honzupandLinus Rath 14c2807f0a feat: add user-selectable regional date format 2026-07-04 14:53:37 +02:00
Patrick RotterandLinus Rath a099ab442a fix: route keyword writes to the email's own account in unified view
Tags applied to a shared/group-mailbox message did not persist. Custom
keywords (:*),  and  were written via Email/set
against the reaching client's primary account instead of the email's
owning account, so the server returned notUpdated without an error and
the change was lost on the next reload.

toggleStar already threaded an accountId through (#281); the keyword
methods did not. Add an optional accountId to updateEmailKeywords and
setKeyword and resolve it at the call sites from the email's source
account (sourceClientAccountId / sourceAccountId), matching the existing
delete/archive routing. Personal sources resolve to the account itself,
so behavior there is unchanged.
2026-07-04 14:53:15 +02:00
dealerwebandLinus Rath e9ac2de6cb Fix: notification sound preview - base-path prefix + longer default beep
The sound picker's preview always played the default beep, even for the other choices, on a subpath deployment.

playFile() used a raw '/notification/x.mp3' path, which 404s under a deployment base path (e.g. /webmail); audio.play() then rejected and fell back to the beep for every non-default choice. Prefix the file with withBasePath().

The default beep was a 150 ms tone with no envelope - easy to miss on Bluetooth outputs, whose audio path can take 100-200 ms to wake up and route. Lengthen it to ~0.45 s with a fade in/out (also removes click artifacts).
2026-07-04 14:52:26 +02:00
Shuki VakninandLinus Rath c4a7f575c5 feat(accounts): pin default account on top + drag-to-reorder switcher
The account switcher now always renders the default (starred) account first
and lets you drag the remaining accounts into any order. Default stays
pinned; only non-default rows are draggable (shown via a grip handle on
hover). Wraps each row in a draggable container and persists the new order
through the existing reorderAccounts store action.

Ordering logic extracted to pure helpers (sortDefaultFirst,
reorderNonDefaultIds) in account-utils with unit tests.
2026-07-04 14:51:46 +02:00
dealerwebandLinus Rath 51c3a69be7 Fix: don't toggle mailbox subfolders on Arrow keys while typing
The sidebar registers a global window keydown listener that expands/collapses the selected mailbox's subfolders on ArrowLeft/ArrowRight. It didn't check where focus was, so pressing Left/Right while typing in a new email (the contentEditable composer, the subject field, search, etc.) toggled the inbox's subfolders open/closed.

Bail out of the handler when the event target is an editable element (INPUT/TEXTAREA/SELECT/contentEditable). Folder-tree arrow navigation still works when focus isn't in an input.
2026-07-04 14:50:32 +02:00
Harry YoudandLinus Rath 717b1d8397 feat(headers): add parsing for Stalwart spam headers
Example of headers created by Stalwart below

X-Spam-Result: TRUSTED_DOMAIN (-7.00),
	PROB_HAM_LOW (-2.00),
	RBL_SENDERSCORE_REPUT_9 (-1.00),
	DMARC_POLICY_ALLOW (-0.50),
	RCVD_DKIM_ARC_DNSWL_MED (-0.50)
X-Spam-Score: ham, score=-5.60, avg_confidence=0.26
X-Spam-Status: No

Only need to add small tweak by detecting spam|ham as well as Yes|No
The most useful header is X-Spam-Score, so we make sure to parse that
before X-Spam-Status
2026-07-04 14:50:07 +02:00
Shuki VakninandLinus Rath f291480565 feat(email): send quick reply with Ctrl/Cmd+Enter
The message-viewer quick-reply box only sent via the Send button. Add a
keyboard shortcut (Ctrl+Enter / Cmd+Enter) mirroring the composer (#344),
so a reply can be fired without reaching for the mouse. preventDefault stops
the newline; the shortcut and button share one handleSendQuickReply().
2026-07-04 14:49:45 +02:00
Chris RowlandandLinus Rath 95b5a81924 fix(plugins): preserve settings slot and privileged tier 2026-07-04 14:49:26 +02:00
Linus RathandGitHub 9da27df249 Update README.md 2026-06-30 18:41:58 +02:00
Patrick RotterandLinus Rath b716f95a73 feat(compose): preselect identity of the active mailbox for new messages
Starting a new message while viewing a specific mailbox/account now defaults
the From identity to that mailbox instead of the global primary identity, so
composing from info@ sends as info@. Mirrors the existing reply-time identity
match and rides the same autoSelectReplyIdentity setting; reply/replyAll/forward
keep resolving from the original recipients. Matches exact then +tag-stripped.

Extracts findComposeIdentityId into lib/reply-identity.ts with unit tests.
2026-06-30 16:31:34 +02:00
Shuki VakninandLinus Rath 6c49427c7c fix(list): shift-click on the checkbox extends the selection (range)
selectRangeEmails was only wired to shift-clicking the row, but the
checkbox handler called stopPropagation and a plain toggle — so shift-
clicking checkboxes (the obvious affordance in selection mode) selected
single messages instead of the range. Make all three checkbox handlers
(email-list-item, thread single-email, thread header) shift-aware:
shift -> selectRangeEmails, otherwise toggle. Adds a regression test.
2026-06-30 09:35:20 +02:00
Kristofer PettijohnandLinus Rath 2a41e73bf9 fix(pro): prompt to save or discard draft when closing compose tab via tab-bar X 2026-06-30 06:51:24 +02:00
Kristofer PettijohnandLinus Rath 16daf6ea03 fix(pro): show Edit button on draft emails opened in a new tab 2026-06-30 06:51:12 +02:00
Linus Rath 2704d5dc23 fix: clone source when setup.sh runs detached from a checkout #518 2026-06-28 23:31:32 +02:00
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
Linus Rath 0872d3dc8d chore: update version to 1.7.4 2026-06-15 23:28:20 +02:00
Linus Rath 1f889b0965 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-15 23:16:10 +02:00
Linus Rath 0b9fe5451f fix: preserve line breaks in generated text/plain alternative #421 2026-06-15 23:15:04 +02:00
Max HaoandLinus Rath fd700f412e fix: fix inconsistent behavior with threading email messages in the inbox/folders 2026-06-15 23:00:30 +02:00
Max HaoandLinus Rath f7d4f9d53c fix: prevent draft emails from being marked as unread 2026-06-15 23:00:00 +02:00
Stefan HildebrandtandLinus Rath 84aced7b4e test(dev-mock): use comma display names in a mock email
Give email-002 ("Project Update - Q1 Review") a sender and CC with
"Lastname, Firstname" display names so Reply/Reply-All in dev mode
exercises the comma-in-name recipient case end to end.
2026-06-15 22:59:46 +02:00
Stefan HildebrandtandLinus Rath 94b1f5aa48 refactor: model composer recipients as arrays instead of delimited strings
Alternative to the quote-aware string fix: represent committed To/Cc/Bcc
recipients as Recipient[] ({name?, email}) with a separate input-text
string per field, instead of a single comma-joined string parsed with
split(','). Structured recipients can never be torn apart on a delimiter,
so a display name containing a comma ("Doo, John <john@doo.org>", as
produced on Reply-All) stays a single chip.

- email-composer-utils: add Recipient type, parseRecipient/formatRecipient,
  and parseRecipientList/formatRecipientList for the (de)serialization
  boundary (ComposerDraftData stays a string; quoting keeps it lossless).
  Remove the now-unused string-chip helpers.
- email-composer: to/cc/bcc are Recipient[]; toInput/ccInput/bccInput hold
  the in-progress text. Reply/forward init, autocomplete, chip edit, drag &
  drop (payload now carries the structured recipient), send/draft/validation
  and template paths all operate on arrays. withInput() folds uncommitted
  typed text into the send/validation set.
- Tests updated for the array contract; add comma-in-name chip coverage.
2026-06-15 22:59:46 +02:00
Linus Rath c51c3655d5 feat: add "All Mail" view 2026-06-15 18:42:23 +02:00
Linus Rath 404a1e847c fix: move "Plain Text Only" setting from Reading to Composing #422 2026-06-15 17:39:18 +02:00
Linus Rath dcea4fdd5e feat: show recipient address in chip drag preview 2026-06-15 15:28:03 +02:00
Linus Rath 701f96adb3 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-15 15:23:09 +02:00
Stefan HildebrandtandLinus Rath 1ebfb286ad feat: drag and drop recipient chips between To/CC/BCC fields
Adds native HTML5 drag-and-drop so users can move recipient email
address chips between the To, CC, and BCC fields in the composer.
Chips dragged onto the Cc/Bcc toggle buttons auto-reveal the hidden
field and place the chip there.
2026-06-15 15:23:00 +02:00
Linus Rath b5f15dfdb7 i18n: add missing translation keys across 17 locales 2026-06-15 15:18:47 +02:00
Stefan HildebrandtandLinus Rath aee4bd78db feat: add Edit contact button to email viewer contact sidebar
Clicking an email address in the viewer already shows a contact detail
sidebar. An "Edit" button now appears there (for known contacts) that
navigates directly to the contact edit form via the existing URL-param
intent system (?contactId=…&view=edit), removing the need to open the
Contacts page manually and search for the contact.
2026-06-15 15:06:35 +02:00
Linus Rath 798a33495e refactor: remove JMAP status from admin dashboard 2026-06-14 17:15:52 +02:00
Linus Rath 4c6c1aab60 feat: manage shared/group account settings from Accounts page 2026-06-14 17:05:29 +02:00
Linus Rath 848ed9774d feat: show avatars in recipient autocomplete suggestions 2026-06-14 14:54:27 +02:00
Linus Rath b32e102ff9 feat: include directory users in recipient autocomplete 2026-06-14 14:52:13 +02:00
Linus Rath e8feb11983 feat: add telemetry to web setup wizard 2026-06-14 14:35:48 +02:00
Linus Rath 3e12fca517 fix: make telemetry opt-in 2026-06-14 14:31:26 +02:00
Linus Rath 8df483d8c7 fix: don't send connected-account key as JMAP accountId when sharing files #408 2026-06-12 00:36:45 +02:00
Linus Rath 1e63e2469a fix: strip build-time basePath from router.push redirects after login #390 2026-06-12 00:23:00 +02:00
Linus Rath e1c28e767a fix: context menu invisible on first right-click after page load 2026-06-12 00:21:09 +02:00
Linus Rath fe5645c818 refactor: redesign custom recurrence editor to match modal UI 2026-06-12 00:15:54 +02:00
Linus Rath 38570b1723 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-12 00:03:52 +02:00
Linus Rath a4f476945d feat: JMAP file/folder sharing in Files app #408 2026-06-12 00:02:45 +02:00
Linus Rath 20fd9ff4de fix: prevent wide email tables from rendering with rotated headers #409 2026-06-11 19:24:05 +02:00
Linus Rath 569dde9985 fix: preserve folder list when mailbox refetch hits concurrent-request limit 2026-06-11 19:10:06 +02:00
Linus Rath 08f344403b feat: recurrence editor, set-default calendar, and timezone-aware calendar queries 2026-06-11 19:05:11 +02:00
Linus Rath be58cee989 fix: dedupe scheduling emails, Stalwart-compatible calendar filters 2026-06-11 18:24:43 +02:00
Max HaoandLinus Rath 7c11e2b3c9 add localized translation placeholders 2026-06-10 19:06:44 +02:00
Max HaoandLinus Rath f2913a7c7d feat: add email display name support to the composer. 2026-06-10 19:06:44 +02:00
Linus Rath a27a5be3dd fix: correct dark-mode background-image inversion and height clipping in email viewer 2026-06-08 15:53:44 +02:00
Linus Rath 964136b540 feat: require re-authentication for device pairing and SSO 2026-06-05 19:23:06 +02:00
Linus Rath 569f688fbf feat: QR-code SSO login between webmail and mobile app 2026-06-05 18:33:20 +02:00
Linus Rath 1d050f8469 feat: add QR code device pairing for mobile app login 2026-06-05 17:41:55 +02:00
Linus Rath 2e4c0f9eea Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-05 16:09:12 +02:00
Linus Rath fd0b866339 fix: open recent contact emails at "/" instead of 404ing on "/mail" 2026-06-05 16:07:38 +02:00
MaartenandLinus Rath 1518ba04dd fix(nav): hide Add App button when sidebarAppsEnabled is false 2026-06-05 14:32:56 +02:00
Norbert Balák-HorváthandLinus Rath c5672c64fb res lock file 2026-06-04 19:52:42 +02:00
Norbert Balák-HorváthandLinus Rath 5997579f54 rem lock file 2026-06-04 19:52:42 +02:00
Norbert Balák-HorváthandLinus Rath f77d2e9103 Fix HU i18n 2026-06-04 19:52:42 +02:00
Linus Rath 6350e9dabc chore: update version to 1.7.4 2026-06-04 12:59:28 +02:00
Linus Rath 7723c134ff chore: update version to 1.7.4 2026-06-04 12:39:50 +02:00
Linus Rath d11ed904a9 feat: calendar agenda plugin sidecar + persist email detail sidebar state 2026-06-04 12:35:56 +02:00
Linus Rath 9db7b6b55f docs: clarify signature byte cap comment is Stalwart-specific 2026-06-04 10:40:42 +02:00
Linus Rath 1460706e60 docs: update README 2026-06-04 10:31:42 +02:00
Linus Rath 27d624758a chore: update version to 1.7.3 2026-06-04 10:15:50 +02:00
Linus Rath 5288821605 i18n: register Hungarian locale and backfill files.migration_* keys 2026-06-04 00:56:50 +02:00
Norbert Balák-HorváthandLinus Rath 58e3ecc97a fix struct 2026-06-04 00:48:18 +02:00
Norbert Balák-HorváthandLinus Rath 38c0694a08 Audit HU translation 2026-06-04 00:48:18 +02:00
Norbert Balák-HorváthandLinus Rath 71d25bb62f Add Hungarian lang support 2026-06-04 00:48:18 +02:00
Linus Rath ef825b801a fix: list Files via FileNode/get ids:null so folders are visible 2026-06-04 00:45:08 +02:00
Linus Rath 50cbd66bfd fix: treat blob-less FileNode as the only folder signal; migrate legacy dir-markers 2026-06-04 00:30:38 +02:00
Linus Rath d564c874a3 feat: migrate legacy flat-named Files into real hierarchy on load #379 2026-06-03 21:20:25 +02:00
Linus Rath 568abb0e33 fix: remove flatname workaround, store Files as real FileNode #379 2026-06-03 20:53:03 +02:00
Linus Rath 44781f002c fix: empty Trash for shared and group folders #387 2026-06-03 20:18:02 +02:00
dealerwebandLinus Rath 941fa15251 Fix: dark-mode borders invisible (border token collided with secondary)
In the dark theme --color-border was #262626, identical to --color-secondary/--color-muted. The global `* { border-color: var(--color-border) }` rule therefore rendered borders invisible on those surfaces - e.g. the folder sidebar's right border and the account header's bottom border vanished in dark mode.

Set --color-border to rgba(128, 128, 128, 0.3) (the same neutral the navigation rail already uses inline) so borders stay visible and consistent across all dark surfaces (background, secondary, card, popover).
2026-06-03 20:12:51 +02:00
dealerwebandLinus Rath 60c7bd713e Fix: remove the 16px empty strip beside the collapsed sidebar
The collapsed sidebar wrapper was hard-coded to 64px while the sidebar itself is w-12 (48px), leaving a 16px empty strip on its right edge. Match the wrapper to the sidebar's own width.
2026-06-03 20:12:51 +02:00
Linus Rath 6f193e0c24 fix: make clicking the active theme a no-op 2026-06-03 19:42:36 +02:00
Linus Rath 6bb85d746c fix: show light/dark variant chips on Default theme card 2026-06-03 19:42:01 +02:00
Linus Rath bbd43b5948 feat: render theme cards as a mini mailbox mockup from theme colors 2026-06-03 19:40:54 +02:00
Linus Rath 180331805f Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-03 19:34:20 +02:00
Linus Rath 9e96b1c24e fix: distinguish Themes tab icon from Appearance 2026-06-03 19:33:50 +02:00
Linus Rath 40b4b26074 fix: move Themes settings into Appearance category 2026-06-03 19:33:00 +02:00
Linus Rath 9863b9d88e feat: add Aurora Glass built-in theme 2026-06-03 19:30:51 +02:00
dealerwebandLinus Rath 75602b6a00 Fix: Settings section gears permanently hijacked the active tab
The folder and tag section gears in the sidebar deep-linked into Settings by writing the persisted `settings-active-tab` localStorage key, so the chosen section became the permanent default the main Settings button opened on - indefinitely.

Compounding it, the desktop Settings tab list called setActiveTab directly without persisting, so normal navigation never updated the default and the hijacked value could never self-correct.

Fix: section gears now write a one-shot sessionStorage key that is consumed on mount (transient deep-link, no persistence); desktop tab clicks go through handleTabSelect like the mobile list, so the last-used tab is saved consistently. Stale/removed tab IDs are still caught by the existing effectiveActiveTab fallback.
2026-06-03 15:01:13 +02:00
Pascal DietrichandLinus Rath 22da11514b feat: add passwordHashFile to admin.json 2026-06-02 23:48:03 +02:00
Linus Rath 9953557af0 fix: align account selector header height with search/reply toolbars 2026-06-02 01:19:37 +02:00
Linus Rath 8f7066d194 fix: close pane gaps by centering resize handle on the seam 2026-06-02 01:17:09 +02:00
Linus Rath 75c02f443f fix: align top bars to uniform h-14 height 2026-06-02 01:08:09 +02:00
Linus Rath 31100b8f87 fix: discover OIDC metadata server-side to avoid CORS failures #382 2026-06-02 00:15:50 +02:00
Linus Rath 152ec99262 feat: add Elastic built-in theme 2026-06-01 23:28:15 +02:00
Linus Rath ce401c0f59 test: cover withBasePath base-path fallback prefixing 2026-06-01 17:59:19 +02:00
Linus Rath 3035fb046f feat: surface most severe SPF result and hide "via" badge on spoofed mail 2026-06-01 17:46:57 +02:00
Linus Rath 4659b81538 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-01 17:42:37 +02:00
Linus Rath c78dbee60b fix: move mail from shared group inbox to personal inbox #375 2026-06-01 17:39:28 +02:00
dealerwebandLinus Rath 4b4c801148 Feature: preview composer attachments inline (click to open)
Clicking an attachment chip in the composer now opens the same FilePreviewModal
the message viewer uses, instead of offering only download/remove. The chip
becomes clickable once the attachment has content (a local File, or an uploaded
blob for forwarded attachments) and the type is previewable.

- getFileContent prefers the in-memory File (no network round-trip) and falls
  back to composerClient.fetchBlob for forwarded attachments (blobId only).
- Previewability (isFilePreviewable) and the open-in-new-tab safety gate are
  handled inside the modal, so this adds no new egress/attack surface; the local
  download path uses an <a download> (forces a save, never executes).
- No new dependencies and no new locale keys.
2026-06-01 17:30:50 +02:00
dealerwebandLinus Rath 2e4d3d4bc6 Feature: preview .eml (message/rfc822) attachments like an email
Clicking an embedded email attachment (bounce/DSN, forward-as-attachment, ...)
opened only a download. Add an 'eml' preview kind: FilePreviewModal parses the
blob with postal-mime (dynamic-imported, off the bundle) and renders it via a
new EmlPreview component - header (from/to/subject/date) + body + the message's
own attachments.

The body is sanitized with DOMPurify (sanitizeEmailHtmlForIframe) AND rendered
in a fully-locked sandbox iframe (sandbox="" - no scripts, no same-origin), so a
script-bearing .eml can never execute in-origin. Reuses the email_viewer locale
namespace (no new keys).
2026-06-01 17:25:14 +02:00
dealerwebandLinus Rath 26ccf9e3b4 Fix: email body clipped under the fold when it sets html/body height:100%
Some emails (Outlook / templated HTML) set `html, body { height: 100% }` in
their own <style>. Combined with the viewer srcDoc's `overflow: hidden` and the
scrollHeight-based iframe auto-resize, the measured height collapses to the
iframe's initial size, so everything below the first screenful (often just the
header/logo) is clipped and the rest of the message is invisible.

Force `height: auto !important` on html/body in the rendered srcDoc so the
document grows to its real content height before scrollHeight is measured.
2026-06-01 17:24:56 +02:00
Linus Rath bc322a1e69 feat: add /api/translate proxy and expose email body to plugins 2026-05-31 18:01:00 +02:00
Linus Rath 6ee3849463 fix: theme plugin slot iframes with host font + color tokens 2026-05-31 16:28:22 +02:00
Linus RathandLinus Rath abb249e5df Fix: gate preview "open in new tab" on inline-safe MIME types
The header open-in-new-tab button opened the blob: URL as a top-level
navigation for any preview that produced an objectUrl, including HTML and
SVG attachments. Blob URLs inherit our origin, so a script-bearing
attachment (text/html, image/svg+xml, ...) would execute in-origin when
opened that way - the exact case isMimeTypeSafeForInlinePreview() already
guards. Gate the button on that helper so it only appears for inert types
(images except SVG, audio, video, PDF, text/plain).
2026-05-31 15:58:53 +02:00
dealerwebandLinus Rath 0352312f25 Feature: attachment preview - reliable MIME + inline PDF on desktop and mobile
- MIME: Stalwart's download endpoint often returns application/octet-stream, so
  blob: previews silently downloaded (UUID filename) instead of rendering.
  Resolve the most specific MIME (attachment type -> filename ext -> blob type)
  and re-wrap the blob; also fixes inline preview for images and video.
- Desktop PDF: render via <iframe> (reliable for blob: PDFs) instead of <object>.
- Mobile PDF: no usable inline viewer (Android shows a blank frame / silent
  download; iOS Safari renders only the first page of a PDF in an <iframe>), so
  render with pdf.js (canvas, dynamic-imported so it stays off the desktop
  bundle; iOS-safe canvas cap). Double-tap zoom (fit -> 2x -> 3x -> fit) and
  2-finger pinch zoom (to 4x), both centred on the gesture and pannable via
  native scrolling.
- Route to pdf.js when navigator.pdfViewerEnabled is false (Android) and on iOS
  (incl. iPadOS, which reports true yet shows only the first page in a frame).
- Modal header gains an open-in-new-tab icon (next to download/close); the
  Android/browser Back button closes the preview instead of navigating the page.
- On a pdf.js render failure, offer an open-in-new-tab action as fallback.
2026-05-31 15:58:53 +02:00
Linus Rath ae66f8d89d Feature: per-viewer colors for shared calendars (#345) 2026-05-31 15:52:27 +02:00
dealerwebandLinus Rath 55be19ede7 Feature: admin toggle for search-engine indexing (robots)
Add a 'Search Engine Indexing' toggle under Settings -> General. Off (the
default) emits robots noindex/nofollow in the document head - the safe default
for a private webmail; on lets an admin opt the deployment into indexing.
Backed by the existing admin config-manager (SEARCH_ENGINE_INDEXING env var /
admin override / revert), read server-side in the root generateMetadata().
2026-05-31 14:45:32 +02:00
Pascal DietrichandLinus Rath b508551d02 feat: add sessionSecretFile and oauthClientSecretFile for JSON config 2026-05-31 00:14:38 +02:00
dealerwebandLinus Rath 7ee329e046 Fix: no more 404 console spam for missing sender favicons
/api/favicon returned 404 in three paths (negative cache hit, non-200
upstream, sub-10-byte body), and since the avatar loads it as <img src>,
the browser logged a red 404 for every sender domain without a public
favicon - dozens per inbox view. Now it returns HTTP 200 with a 1x1
transparent PNG and an X-Bulwark-Favicon: missing header. Avatar.tsx detects
the sentinel via naturalWidth <= 1 in onLoad and falls back to initials, so
behaviour is visually identical without the console noise.
2026-05-30 16:58:46 +02:00
dealerwebandLinus Rath 1512b9afc0 Feature: editable layout-preserving quote island
Replying to / forwarding a layout-heavy HTML email (nested tables, MJML,
Outlook divs) destroyed its layout: ProseMirror re-parsed the quoted body
through its strict schema and discarded anything that didn't fit. The quoted
original is now held verbatim in a new atomic QuotedHtml node and never parsed
into the schema; its NodeView renders inside a shadow root so app CSS can't
cascade in and the in-editor view matches the sent mail 1:1.

- quoted-html.ts (new): QuotedHtml atom node + shadow-DOM NodeView (inner
  contentEditable for redaction), serializeEditorContent(), buildQuotedHtmlBlock().
- rich-text-editor: register the node; emit via serializeEditorContent (not
  getHTML) so the verbatim island survives.
- composer: both HTML reply/forward paths embed the original as an island
  (sanitize -> cid-rewrite -> buildQuotedHtmlBlock); the signature-swap effect
  serializes via serializeEditorContent and treats the island as a quote
  boundary so the splice never cuts into the quoted body.

atom:true means Backspace at the boundary / Ctrl+A+Delete removes the whole
quote in one go.
2026-05-30 16:43:50 +02:00
Linus Rath ad48f4394a Merge branch 'main' into HEAD
# Conflicts:
#	app/(main)/layout.tsx
#	locales/cs/common.json
#	locales/da/common.json
#	locales/de/common.json
#	locales/en/common.json
#	locales/es/common.json
#	locales/fr/common.json
#	locales/it/common.json
#	locales/ja/common.json
#	locales/ko/common.json
#	locales/lv/common.json
#	locales/nl/common.json
#	locales/pl/common.json
#	locales/pt/common.json
#	locales/ru/common.json
#	locales/tr/common.json
#	locales/uk/common.json
#	locales/zh/common.json
2026-05-30 16:23:37 +02:00
Linus Rath 8d79145dba Merge remote-tracking branch 'origin/main' into pr/quote-header-i18n 2026-05-30 16:15:18 +02:00
Linus Rath b821f8cc27 Fix: drop single-letter R:/I: subject prefix tokens 2026-05-30 16:04:44 +02:00
dealerwebandLinus Rath bebb394f54 Feature: read receipts (MDN, RFC 8098)
Bulwark had no read-receipt support (JMAP/Stalwart have no native MDN).
End-to-end, client-side, in three parts:

- Request (compose): a toolbar toggle (MailCheck, green when on) sets
  Disposition-Notification-To on the outgoing message via the JMAP
  "header:<name>:asText" create property. Threaded composer -> page ->
  email-store -> client.sendEmail. Default from requestReadReceiptDefault.

- Detect (viewer): reads Disposition-Notification-To case-insensitively from
  the parsed headers and shows a banner (green Send / red Ignore) in the
  unified notification bar. Hidden in Sent/Drafts/Trash/Junk and once handled.
  message/disposition-notification + message/delivery-status report parts are
  filtered out of the attachment list.

- Respond (MDN): lib/mdn.ts builds an RFC 8098 multipart/report (text/plain +
  message/disposition-notification, UTF-8/base64, localized subject + body).
  client.sendReadReceipt uploads the blob, imports it into Sent via
  Email/import, then submits with an explicit envelope. Both Send and Ignore
  set the $MDNSent keyword (RFC 3503) so no client re-prompts. Behaviour
  configurable: ask / always / never.

New: lib/mdn.ts, read-receipt-banner.tsx. Settings (requestReadReceiptDefault,
readReceiptResponse) + UI. All 17 locales.
2026-05-30 15:58:39 +02:00
dealerwebandLinus Rath 2ba0003e16 Feature: localizable sandboxed plugins (manifest locales + api.i18n.t)
The plugin runtime received the active locale (init payload + 'locale-change')
and plugins could declare a `locales` map, but none of it was usable: the
locales never reached the runtime, and buildPluginApi exposed no i18n. So
plugin code calling pluginApi.i18n.t(...) (as the External Link Warning plugin
does) always got undefined and fell back to English.

Thread plugin locales end to end and surface an i18n API:
- ServerPlugin gains `locales`; the upload route persists manifest.locales
  (alongside configSchema/settingsSchema), and /api/plugins surfaces it to the
  client so it flows registry -> client -> sandbox host-bridge -> runtime.
- runtime sets __PLUGIN_LOCALE__ at init (not only on later 'locale-change')
  and buildPluginApi exposes `i18n.locale` + `i18n.t(key, vars)` resolving
  against the plugin's declared locales (manifest.locales) with English/key
  fallback and {placeholder} interpolation.

Lets any sandboxed plugin localize its strings from its manifest.
2026-05-30 15:56:55 +02:00
dealerwebandLinus Rath 4c1d0931a1 Fix: deduplicate localized reply/forward subject prefixes
Replying to a reply produced "Re: Re: foo" (and German used the English
"Re:"/"Fwd:" instead of "AW:"/"WG:"). Four code paths built reply/forward
subjects and only one deduplicated - and only for the English prefix, so
cross-locale threads accumulated chains.

New lib/subject-prefix.ts strips any leading run of reply/forward markers
across ~35 tokens from all supported languages (plus Outlook Re[2]: and
Eudora Re*2: counters), then prepends the locale-appropriate prefix. All four
call sites (composer getInitialSubject, the two page.tsx sites, and the three
pro-tab handlers) now use buildReplySubject/buildForwardSubject. German prefix
corrected to AW:/WG:.
2026-05-30 15:47:53 +02:00
dealerwebandLinus Rath 5a70cf95e0 Fix: add missing settings.folders.role_memos translation
settings.folders.role_memos (Stalwart's "memos" mailbox role) was missing in
all 17 locales, so the folder list showed the raw key "role_memos" and logged
a MISSING_MESSAGE warning. Add the translation to every locale.
2026-05-30 15:46:13 +02:00
dealerwebandLinus Rath 66c5f0f52c Feature: configurable PWA install screenshots (per-domain)
Admins can upload custom mobile/desktop screenshots shown in the browser's
PWA install dialog, replacing the hardcoded Bulwark ones. Two new config keys
(pwaScreenshotMobileUrl/DesktopUrl), upload widgets in the admin Branding tab,
a sharp-based /api/pwa-screenshot/[variant] resize route, and manifest.ts picks
the custom screenshots when configured.

Like the other branding fields, screenshots are per-domain: they are
BRANDING_OVERRIDE_KEYS, the manifest and the /api/pwa-screenshot route resolve
them from the request host (domain override -> global -> Bulwark default), and
the admin Branding tab + upload/delete route handle them in a per-domain scope,
mirroring pwaIconUrl/faviconUrl.
2026-05-30 15:45:59 +02:00
dealerwebandLinus Rath 8353b28b33 Feature: extended filter rules — attachment field + multi-value conditions
Adds an "Attachment" condition field (is present / of type <ext>) backed by
the RFC 5703 Sieve mime extension, matching the filename in both
Content-Disposition and Content-Type headers so real-world senders that only
put the name in Content-Type (Microsoft SMTPSVC, etc.) are caught. Users type
extensions (pdf, doc) not MIME types.

Also makes each text condition accept comma-separated multiple values emitted
as a Sieve string list (OR within the condition), so "(domain1 OR domain2)
AND attachment pdf/xml" is expressible in one rule. value is now string |
string[] (single-value rules stay strings -> backward compatible). New filter
locale keys in all 17 locales.
2026-05-30 15:45:33 +02:00
dealerwebandLinus Rath 229992853b Fix: localize the PWA install prompt
The PWA install prompt was hardcoded English regardless of the selected UI
language (and the large English block tripped Chrome's translate popup on
Android). Add a pwa_install namespace to all 17 locales, switch the component
to useTranslations, and move <PWAInstallPrompt /> from (main)/layout into
(main)/[locale]/layout so it renders inside the IntlProvider. The title keeps
the dynamic {appName}, so per-domain branding still applies.
2026-05-30 15:35:05 +02:00
dealerwebandLinus Rath f0d87d594a Fix: honour basePath in plugin sandbox, http.post proxy, and branding
Upstream 1.7.2 prefixes most hand-written URLs with basePath via apiFetch /
withBasePath, but four subpath-relevant spots were missed:

- host-bridge: the sandbox iframe src was a bare "/plugin-sandbox" -> 404
  under NEXT_PUBLIC_BASE_PATH, breaking all plugins. Wrap in withBasePath.
- host-api doHttpPost: the same-origin /api/* plugin proxy used raw fetch on
  url.pathname -> 404 under a subpath. Route it through apiFetch.
- admin branding preview <img>: unprefixed src -> broken thumbnail.
- (sandbox) layout: drop the Geist font + globals.css imports. The sandbox
  runs with an opaque origin, so those assets are CORS-blocked; the plugin
  bundle and all host API calls travel over the postMessage bridge, so no
  same-origin asset fetch happens there.
2026-05-30 15:31:08 +02:00
196e51e91b fix: preserve HTML signature when sending a quick reply
The quick-reply box built its body with appendPlainTextSignature, which runs
the identity's HTML signature through htmlToPlainText, and sent a text-only
message (htmlBody was undefined). A formatted signature (e.g. <strong>…) was
therefore flattened to plain text in the sent mail, even though it previewed
correctly in the identity editor. The full composer already builds an HTML
signature block; quick reply did not.

Add an appendHtmlSignature helper (mirrors the composer's send-time block) and,
when the sending identity has an HTML signature, send a matching HTML body from
handleQuickReply so the markup is preserved. Text-only identities keep the
plain-text-only behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:12:30 +02:00
0879030dc8 feat(dev-jmap): persist identity create/update/destroy in mock server
The dev mock's Identity/set discarded its payload and Identity/get always
returned a static list, so saved identities never round-tripped in local
development. Persist create (with mayDelete: true), update, and destroy in
place, mirroring handleMailboxSet, so signature edits stick when testing
without a real JMAP server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:12:30 +02:00
dealerweb 05e2837f6b Fix: localize reply/forward quote header incl. sender address
The reply/forward quote header was always emitted in English ("On {date},
{from} wrote:", "---------- Forwarded message ----------", From/Date/Subject)
regardless of UI language, in both the main path (lib/quote-header.ts) and the
composer's inline fallback. quote-header.ts now takes an optional localized
QuoteHeaderLabels set (English defaults preserved for back-compat); page.tsx
builds it from a new quote_header message namespace, and the composer fallback
uses the same keys. Added the quote_header namespace to all 17 locales.

Also folds in the forward-sender-address fix: the forward "From:" line now
shows the full "Name <email>" like every mail client (the reply line keeps the
bare name, which reads naturally in "On … wrote:").
2026-05-30 14:09:23 +02:00
dealerweb 241544cd08 Fix: correct <html lang> and localize the <head> description per locale
The root (main)/layout renders <html> ABOVE the [locale] segment, so next-intl's
getLocale() returns the default locale there - emitting <html lang="en"> on every
page regardless of UI language (e.g. /de) and a hardcoded English <head>
description. Both are strong "translate this page" triggers in Chrome.

proxy.ts already exposes the nonce to server components via the
x-middleware-request-* mechanism; expose the request pathname the same way as
x-pathname, and have the root layout derive the locale from it (falling back to
getLocale() when the path has no locale segment) for both <html lang> and the
localized meta_description (new key in all 17 locales; the English value is
unchanged).
2026-05-30 13:51:21 +02:00
Roman OswaldandLinus Rath 7341e47a1b feat: route Sent copy to shared-mailbox account on per-identity send 2026-05-29 23:44:12 +02:00
Linus Rath f238ca5898 chore: update package-lock.json 2026-05-28 21:09:50 +02:00
Linus Rath 00b40fc48a chore: update version to 1.7.2 2026-05-28 20:31:03 +02:00
Linus Rath 856496715b i18n: add missing translation keys across 16 locales 2026-05-28 20:27:40 +02:00
Linus Rath eb7eeae1ac feat: per-domain branding editor in admin panel #332 2026-05-28 20:21:25 +02:00
Linus Rath 6b1a99a70b docs: document DOMAIN_BRANDING env var in examples and README #332 2026-05-28 20:13:11 +02:00
Linus Rath 1da04c254b feat: per-domain branding overrides on /api/config, manifest, pwa-icon #332 2026-05-28 20:06:47 +02:00
Linus Rath 8ae5ecba41 feat: policy-controlled push relay URL with optional user lock 2026-05-28 19:45:14 +02:00
Linus Rath 4ff05bd4ec fix: editable HTML signature in new mail; clean state on every compose entry #329 2026-05-28 19:21:44 +02:00
31e96d6a46 Feat: Scheduled send and send delay #322
* ADD DOC

* Scheduld Send

* add new shortcuts

* fix

* fix

* fix bugs

* rework

* fix draft duplicating

* fix err

* some fixes

* fixes from review

* fixes from review

* fixes from review

* disable password managers for recipients

* fix email store lazy load

* add translations

* fix styling

* fixes

---------

Co-authored-by: Linus Rath <139418639+rathlinus@users.noreply.github.com>
2026-05-28 18:46:49 +02:00
370 changed files with 42636 additions and 10916 deletions
+5
View File
@@ -47,3 +47,8 @@ LOG_LEVEL=debug
# LOGIN_IMPRINT_URL=https://example.com/imprint
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
# LOGIN_WEBSITE_URL=https://example.com
# Per-domain branding overrides. Each entry must have "host" (exact or
# "*.subdomain" wildcard) plus any subset of branding fields to override.
# Unset fields fall through to the global values above.
# DOMAIN_BRANDING=[{"host":"localhost","loginCompanyName":"Local Dev"}]
+36 -4
View File
@@ -49,6 +49,11 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# OpenID Connect issuer URL for discovery
# OAUTH_ISSUER_URL=https://your-idp.example.com
# Overrides only the user-facing authorize endpoint (e.g. a per-brand login
# host). Discovery, token exchange and refresh keep using OAUTH_ISSUER_URL.
# Leave unset to use the authorization_endpoint from discovery.
# OAUTH_AUTHORIZE_URL=https://login.your-brand.example.com/application/o/authorize/
# Allow OAuth discovery to resolve to private (RFC-1918 / loopback) addresses.
# Off by default as an SSRF guard. Enable for split-DNS deployments where the
# OAuth issuer's public hostname resolves to an internal IP from this server.
@@ -110,12 +115,16 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Anonymous Telemetry
# =============================================================================
# Anonymous instance telemetry is enabled by default. Heartbeats contain no PII:
# version, platform, bucketed account counts, and feature toggles only. See
# Anonymous instance telemetry is OPT-IN and disabled by default. Enabling it
# helps us understand how Bulwark is used so we can make the product better.
# Heartbeats contain no PII: version, platform, bucketed account counts, and
# feature toggles only - never email addresses, hostnames, or IPs. See
# https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema.
#
# Disable telemetry entirely (overrides the admin UI):
# BULWARK_TELEMETRY=off
# Enable telemetry (also toggleable in the admin UI):
# BULWARK_TELEMETRY=on
#
# Setting this (on or off) locks the choice and disables the admin UI toggle.
# Directory for telemetry state: instance id, consent, login HMACs
# (default: ./data/telemetry). For Docker, the default resolves to
@@ -225,6 +234,29 @@ LOGIN_COMPANY_NAME=Bulwark Webmail
# URL for the company website link on the login page.
LOGIN_WEBSITE_URL=https://bulwarkmail.org
# ---------------------------------------------------------------------------
# Per-domain branding overrides (optional)
# ---------------------------------------------------------------------------
#
# When you serve the webmail on multiple hostnames, each hostname can override
# a subset of branding fields. Unset fields fall back to the global values
# above. Match is on the request's Host (or X-Forwarded-Host) header.
#
# Use the leftmost label "*." to match any subdomain (e.g. "*.example.com"
# matches mail.example.com and any deeper subdomain, but NOT example.com).
# Exact matches always win over wildcards; the longest wildcard suffix wins
# among multiple wildcard matches.
#
# Overridable keys: appName, appShortName, appDescription, faviconUrl,
# pwaIconUrl, pwaThemeColor, pwaBackgroundColor, appLogoLightUrl,
# appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName,
# loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl.
#
# Prefer setting this from the admin dashboard (PATCH /api/admin/config).
# The env-var form is provided for stateless deployments.
#
# DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one-color.svg","loginLogoDarkUrl":"/branding/one-white.svg","loginWebsiteUrl":"https://one.example"},{"host":"maildomain2.com","loginCompanyName":"Company Two","faviconUrl":"/branding/two-favicon.svg"},{"host":"*.intranet.example.com","loginCompanyName":"Internal"}]
# =============================================================================
# Extension Directory / Marketplace
# =============================================================================
+252
View File
@@ -1,5 +1,257 @@
# Changelog
## 1.7.7 (2026-07-09)
### Features
- **Plugins**: `ui.rerenderEmail` API and restyled read-receipt banner
- **Plugins**: New hooks — `onBeforeBlobUpload`, `onBeforeDraftAutoSave`, `onBeforeEditDraft` (#586)
- **Plugins**: `ui.prompt` dialog and first-class settings-section tabs
- **Calendar**: Jalali (Persian/Shamsi) calendar support with Saturday as week start (#490)
- **i18n**: Hebrew locale with full RTL support
- **i18n**: Slovak translation
- **i18n**: User-selectable regional date format
- **Contacts**: Enable trusted-senders address book sync by default when contacts are available
- **Mail**: Pin emails to the top of the folder list
- **Mail**: Setting to disable the tag-color row tint in the message list
- **Mail**: Click the sender avatar to select a message/thread (Thunderbird-style)
- **Accounts**: Pin the default account on top and drag-to-reorder the account switcher
- **Composer**: Recipient autocomplete from Sent, with on-demand server search
- **Composer**: Preselect the identity of the active mailbox for new messages
- **Email**: Send a quick reply with Ctrl/Cmd+Enter
- **Headers**: Parse Stalwart spam headers
- **Login**: Configurable logo size and hideable heading/subtitle
- **PWA**: Apple Touch icons for the iOS home screen
### Fixes
- **Mail**: Hide Files when the account lacks the filenode capability (#563)
- **Mail**: Keep advanced search filters applied when switching folders (#553)
- **Mail**: Keep the email list scrollable when the bottom reading pane is enabled with no conversation selected
- **Mail**: Route keyword writes to the email's own account in unified view
- **Mail**: Render emails that set `height:100%` on a wrapper element
- **Mail**: Hide images that fail to load
- **Mail**: Storage quota not shown with Stalwart (#577)
- **Spam**: Hide the spam action in Sent, Drafts and Scheduled
- **Spam**: Fix stale folder counters and open message after spam actions
- **Composer**: Wait for in-flight attachment uploads before sending
- **Composer**: Only commit a recipient on Space when the input is a valid email (#571)
- **Composer**: Attachment reminder now ignores quoted text on reply/forward (#570)
- **Calendar**: Store the event organizer as owner-only to prevent duplicate ORGANIZER/ATTENDEE
- **Calendar**: Strike through cancelled events and mute their reminders (#572)
- **Calendar**: Use `calendarAddress`/`organizerCalendarAddress` for scheduling, drop retired `sendTo`/`replyTo` (#500)
- **Auth**: Keep the session when the auth server is briefly unreachable
- **Shortcuts**: Make keyboard shortcuts layout-agnostic and map by physical position
- **Shortcuts**: Don't toggle mailbox subfolders on Arrow keys while typing
- **Contacts**: Clear the photo on the server by sending `media: null` when removed
- **Plugins**: Preserve the settings slot and privileged tier
- **Pro**: Prompt to save or discard a draft when closing a compose tab via the tab-bar X
- **Pro**: Show the Edit button on draft emails opened in a new tab
- **List**: Shift-click on the checkbox extends the selection (range)
- **CSP**: Allow external/data fonts so email webfonts render
- **Notifications**: Brand push notifications with the configured PWA icon
- **Notifications**: Notification sound preview — base-path prefix and longer default beep
- **Unsubscribe**: Send `mailto:` unsubscribe ourselves instead of via the OS handler
- **Branding**: Apply per-domain favicon override in root metadata (#585)
- **Settings**: Load the trusted-senders address book on the settings page so the count isn't 0
- **Setup**: Clone source when `setup.sh` runs detached from a checkout (#518)
- **Server**: Use a callable `.get` to detect `Headers` in `pickRequestHost`
## 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)
### Features
- **Mail**: New "All Mail" view across folders and accounts
- **Mail**: Edit contact directly from the email viewer contact sidebar
- **Calendar**: Recurrence editor, set-default calendar, and timezone-aware calendar queries
- **Calendar**: Agenda plugin sidecar
- **Composer**: Email display name support
- **Composer**: Drag-and-drop recipient chips between To/CC/BCC fields, with the address shown in the drag preview
- **Composer**: Avatars in recipient autocomplete suggestions, including directory users
- **Files**: JMAP file/folder sharing in the Files app (#408)
- **Auth**: QR-code SSO login and device pairing between webmail and the mobile app
- **Auth**: Require re-authentication for device pairing and SSO
- **Accounts**: Manage shared/group account settings from the Accounts page
- **Setup**: Opt-in telemetry in the web setup wizard
- **Mail**: Persist the email detail sidebar state
### Fixes
- **Mail**: Preserve line breaks in the generated `text/plain` alternative (#421)
- **Mail**: Fix inconsistent threading of email messages in the inbox and folders
- **Mail**: Stop draft emails from being marked as unread
- **Mail**: Prevent wide email tables from rendering with rotated headers (#409)
- **Mail**: Preserve the folder list when a mailbox refetch hits the concurrent-request limit
- **Mail**: Correct dark-mode background-image inversion and height clipping in the email viewer
- **Calendar**: Dedupe scheduling emails and use Stalwart-compatible calendar filters
- **Calendar**: Redesign the custom recurrence editor to match the modal UI
- **Files**: Don't send the connected-account key as the JMAP `accountId` when sharing files (#408)
- **Routing**: Strip the build-time `basePath` from `router.push` redirects after login (#390)
- **Nav**: Open recent contact emails at `/` instead of 404ing on `/mail`
- **Nav**: Hide the Add App button when `sidebarAppsEnabled` is false
- **Settings**: Move the "Plain Text Only" setting from Reading to Composing (#422)
- **Privacy**: Make telemetry opt-in
- **UI**: Fix the context menu being invisible on first right-click after page load
- **Admin**: Remove the JMAP status from the admin dashboard
- **i18n**: Add missing translation keys across 17 locales
## 1.7.3 (2026-06-04)
### Features
- **Mail**: Inline attachment preview — reliable MIME detection with inline PDF on desktop and mobile
- **Mail**: Preview composer attachments inline (click to open)
- **Mail**: Preview `.eml` (`message/rfc822`) attachments like an email
- **Mail**: Read receipts (MDN, RFC 8098)
- **Mail**: Editable, layout-preserving quote island when replying
- **Mail**: Surface the most severe SPF result and hide the "via" badge on spoofed mail
- **Calendar**: Per-viewer colors for shared calendars (#345)
- **Filters**: Extended filter rules — attachment field and multi-value conditions
- **Settings**: New built-in themes — Aurora Glass and Elastic
- **Settings**: Theme cards render as a mini mailbox mockup from theme colors, with light/dark variant chips
- **Plugins**: Localizable sandboxed plugins (manifest locales + `api.i18n.t`)
- **Plugins**: `/api/translate` proxy and email body exposed to plugins
- **Admin**: Toggle for search-engine indexing (robots)
- **Admin**: `passwordHashFile` in `admin.json`
- **Admin**: `sessionSecretFile` and `oauthClientSecretFile` for file-based secrets in JSON config
- **PWA**: Configurable install screenshots (per-domain)
- **i18n**: Hungarian locale support
### Fixes
- **Files**: Store Files as real `FileNode` hierarchy, migrate legacy flat-named files on load, and list folders via `FileNode/get` so they are visible (#379)
- **Files**: Treat a blob-less `FileNode` as the only folder signal and migrate legacy dir-markers
- **Mail**: Empty Trash for shared and group folders (#387)
- **Mail**: Move mail from a shared group inbox to a personal inbox (#375)
- **Mail**: Preserve the HTML signature when sending a quick reply
- **Mail**: Stop body clipping under the fold when the email sets `html`/`body` `height: 100%`
- **Mail**: Drop single-letter `R:`/`I:` subject prefix tokens and deduplicate localized reply/forward prefixes
- **Mail**: No more 404 console spam for missing sender favicons
- **Auth**: Discover OIDC metadata server-side to avoid CORS failures (#382)
- **Send**: Route the Sent copy to the shared-mailbox account on per-identity send
- **Routing**: Honour `basePath` in the plugin sandbox, `http.post` proxy, and branding
- **i18n**: Localize the PWA install prompt, reply/forward quote header (incl. sender address), `<html lang>`, and per-locale `<head>` description; add missing `settings.folders.role_memos` key
- **Themes**: Plugin slot iframes inherit host font and color tokens
- **Theme**: Gate preview "open in new tab" on inline-safe MIME types
- **Appearance**: Move Themes settings into the Appearance category with a distinct tab icon; clicking the active theme is a no-op
- **UI**: Fix invisible dark-mode borders (border token collided with secondary)
- **UI**: Remove the 16px empty strip beside the collapsed sidebar
- **UI**: Align top bars to a uniform `h-14` height and the account selector header to the search/reply toolbars
- **UI**: Close pane gaps by centering the resize handle on the seam
- **Settings**: Fix section gears permanently hijacking the active tab
## 1.7.2 (2026-05-28)
### Features
- **Mail**: Scheduled send and send delay (#322)
- **Mail**: Drag emails out to the file explorer as `.eml`
- **Mail**: Import emails from `.zip` archives
- **Mail**: "Move to Trash and mark as read" delete action (#323)
- **Mail**: Include group inboxes in the unified mailbox view (#328)
- **Mail**: Locale-aware date format in the email list with a preset picker (#331)
- **Mail**: Allow drag-and-drop into shared mailboxes
- **Composer**: Ctrl/Cmd+Enter sends the open draft
- **Settings**: New Downloads tab with template editor for `.eml` and attachment filenames
- **Settings**: Filename transform settings and an ASCII-only "date (from-to) subject" template
- **Settings**: Post-export action (keep / archive / trash)
- **Settings**: Template for multi-email `.zip` filenames
- **Admin**: Per-domain branding editor with overrides on `/api/config`, manifest, and PWA icon (#332)
- **Admin**: Policy-controlled push relay URL with optional user lock
- **i18n**: `NEXT_PUBLIC_DEFAULT_LOCALE` for fallback UI locale (#243)
### Fixes
- **Mail**: Editable HTML signature in new mail; clean state on every compose entry (#329)
- **Mail**: Report real upload progress with XHR progress events (#333)
- **Mail**: Restore `blob:` in `object-src` and `frame-src` CSP for PDF/HTML previews
- **Mail**: Match user-avatar treatment on quick reply
- **Email viewer**: Stop shattering table cells with `word-break: break-word`
- **Composer**: Scope Ctrl/Cmd+Enter send to the focused composer
- **Composer**: Stop closing the form when editing any field
- **Pro**: Keep the empty viewer pane visible in the split layout
- **Pro**: Prevent an empty main pane when reordering tabs across panes
- **Mobile**: Collapse focus mail layout to multi-line
- **Mobile**: Keep a gutter on bare-HTML and plain-text emails
- **Calendar**: Align continued multi-week events with the week's left edge
- **Calendar**: Show the end date in the event popover for multi-day events (#318)
- **Calendar**: Convert `recurrenceRules` to singular in batch create
- **Calendar**: Handle malformed event dates (#316)
- **Files**: Stop URL-encoding drag-out filenames and preserve Unicode letters
- **Routing**: Prefix remaining `<img>`, favicon, and WebDAV URLs with `basePath` (#319)
- **Routing**: Prefix hand-written URLs with `basePath` for subpath deployments
- **Auth**: `OAUTH_ALLOW_PRIVATE_ENDPOINTS` for split-DNS setups
### i18n
- Add missing translation keys across 16 locales
## 1.7.1 (2026-05-22)
### Features
+21 -10
View File
@@ -4,10 +4,15 @@
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
- Gmail-style threading with inline expansion and an optional conversation toggle
- Unified mailbox view across all connected accounts
- Unified mailbox view across all connected accounts combined Inbox, Sent, Drafts, Junk, Archive, and Trash, with group/shared accounts optionally merged in
- Cross-account "All accounts" views All unread, All starred, and All mail spanning every account (including shared/group folders); each aggregate list labels the source folder of every message
- "All Mail" view that merges an account's folders (with a configurable folder selection) into a single list
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
- Attachment upload, download, drag-out to local file system, and inline preview; 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
- Scheduled send and configurable send delay
- Read receipts (MDN, RFC 8098)
- Editable, layout-preserving quote island when replying
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
- Batch operations multi-select, archive, delete, move, tag
- Archive modes direct, by year, or by month
@@ -36,7 +41,7 @@
- Auto-generated birthday calendar from contacts
- Virtual locations (video conference URLs) as first-class event fields
- Task management with due dates, priority, and completion status
- Shared calendars with CalDAV discovery and multi-account home resolution
- Shared calendars with CalDAV discovery, multi-account home resolution, and per-viewer colors
- Week numbers, event hover preview, notifications with sound picker
- Real-time sync via JMAP push
@@ -52,7 +57,7 @@
## Filters & Templates
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body, Attachment…) with multi-value matching and actions (Move, Forward, Star, Discard…)
- Preserves rules authored in other clients
- Raw Sieve editor with syntax validation
- Vacation responder with date range scheduling
@@ -60,19 +65,20 @@
## Files
- JMAP FileNode browser (Stalwart native cloud storage)
- JMAP FileNode browser (Stalwart native cloud storage) with a real folder hierarchy; legacy flat-named files are migrated into nested `FileNode` folders automatically on load
- Streamed WebDAV PUT upload and folder upload with progress tracking
- Dynamic upload limits based on server configuration
- Grid and list views with sorting by name, size, or date
- Previews for images, text, audio, and video
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
- JMAP sharing (RFC 9670) for files and folders share with users or groups at read, read/write, or manager levels via a principal picker, with share indicators and a "Shared with me" sidebar section for folders other principals have shared with you
## Security & Privacy
- External content blocked by default, with a trusted senders list
- HTML sanitization via DOMPurify
- S/MIME manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
- SPF / DKIM / DMARC status indicators
- SPF / DKIM / DMARC status indicators surfaces the most severe SPF result and hides the "via" badge on spoofed mail
- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
- TOTP two-factor authentication
- Account security panel for password and 2FA management via the Stalwart admin API
@@ -85,6 +91,7 @@
- Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns
- Dark and light themes with intelligent email color transformation
- Bundled color themes including Aurora Glass and Elastic; theme cards render as a mini mailbox mockup built from the theme's own colors, with light/dark variant chips
- Responsive desktop, tablet, and mobile layouts
- Full keyboard navigation
- Drag-and-drop email organization and tag assignment
@@ -98,7 +105,7 @@
## Internationalization
17 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · 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`.
@@ -110,6 +117,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
- Configurable signature position (above or below quoted text)
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
- Shared folders across accounts
- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the unified and "All accounts" views ("Include group inboxes"); their messages are fully actionable there open, mark read, spam / not-spam, move, delete, and archive with folder unread counts kept in sync
- Multiple JMAP servers per deployment with optional auto-pick by email domain
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
@@ -117,8 +125,11 @@ Automatic browser detection with persistent preference. Configurable locale URL
- Web setup wizard for first launch guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
- Admin policy gates for the aggregate mail views enable or disable the "All Mail" and the cross-account "All unread / starred / all" entries org-wide; each gated view still respects the user's own toggle
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
- Plugin system schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs, and managed policy enforcement
- 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`)
- Plugin system schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs (localizable sandboxed plugins via manifest locales and `api.i18n.t`), an `/api/translate` proxy, email-body access, and managed policy enforcement
- Plugin hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins`
- Themes upload, enforce, and manage admin-controlled themes as ZIP bundles
- Extension marketplace browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard
@@ -126,10 +137,10 @@ Automatic browser detection with persistent preference. Configurable locale URL
## Operations
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, and dynamic manifest
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, dynamic manifest, and configurable (per-domain) install screenshots
- Automatic update check with server-side logging of new releases and a non-dismissible update notice
- Structured logging (`text` or `json`) with category-based levels
- Anonymous instance telemetry (opt-out via admin UI or `BULWARK_TELEMETRY=off`) version, platform, bucketed account counts, feature toggles only
- Anonymous instance telemetry (opt-in via admin UI, the installer, or `BULWARK_TELEMETRY=on`; off by default) version, platform, bucketed account counts, feature toggles only
- Release (`main`) and development (`dev`) Docker images on GHCR
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy
- Demo mode with fixture data no mail server required
+9 -5
View File
@@ -12,10 +12,8 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.7.1-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.7-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
[![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/)
</div>
---
@@ -81,12 +79,12 @@ The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JM
Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
- **Mail** threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
- **Mail** threading, unified inbox, cross-account "All accounts" views, full-text search, Sieve filters, S/MIME, templates
- **Calendar** month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
- **Contacts** multiple address books, groups, vCard import/export
- **Files** Stalwart's JMAP FileNode storage with previews and folder upload
Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard.
Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 18 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard.
Full feature list: **[FEATURES.md](FEATURES.md)**.
@@ -211,6 +209,12 @@ LOGIN_COMPANY_NAME=My Company
LOGIN_WEBSITE_URL=https://example.com
LOGIN_IMPRINT_URL=https://example.com/imprint
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
# Per-domain overrides (optional). When the webmail is served on multiple
# hostnames, each host can override any subset of the branding fields above.
# Match is on the request Host (or X-Forwarded-Host). Use "*.example.com" to
# match any subdomain. Unset fields fall back to the global values.
DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one.svg"},{"host":"maildomain2.com","loginCompanyName":"Company Two"}]
```
</details>
+1 -1
View File
@@ -1 +1 @@
1.7.1
1.7.7
+40 -4
View File
@@ -4,7 +4,7 @@ import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useAuthStore } from "@/stores/auth-store";
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
import { apiFetch, getPathPrefix, toRouterPath } from "@/lib/browser-navigation";
import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useParams } from "next/navigation";
@@ -32,6 +32,42 @@ function OAuthCallbackInner() {
return;
}
// Step-up re-auth for device pairing: the QR generator sent the user here
// via prompt=login. Don't create a login session — just confirm the fresh
// auth (sets the short-lived pairing proof cookie) and bounce back to the
// Security settings, where the QR generation auto-resumes.
let pairReauthResume = false;
try {
pairReauthResume = sessionStorage.getItem("pair_reauth_resume") === "1";
} catch { /* sessionStorage unavailable */ }
if (pairReauthResume && state) {
try { sessionStorage.removeItem("pair_reauth_resume"); } catch { /* ignore */ }
(async () => {
try {
const res = await apiFetch("/api/auth/reauth/sso/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ code, state }),
});
if (!res.ok) {
setError("token_exchange_failed");
return;
}
try {
sessionStorage.setItem("pair_reauth_done", "1");
// Land back on the Security tab (readPersistedTab reads this key).
sessionStorage.setItem("settings-deep-link-tab", "security");
} catch { /* ignore */ }
const prefix = getPathPrefix(params.locale as string);
router.push(toRouterPath(`${prefix}/${params.locale}/settings`));
} catch {
setError("token_exchange_failed");
}
})();
return;
}
const savedState = sessionStorage.getItem("oauth_state");
if (savedState) {
@@ -69,7 +105,7 @@ function OAuthCallbackInner() {
redirectTo = saved;
}
} catch { /* sessionStorage may be unavailable */ }
router.push(redirectTo);
router.push(toRouterPath(redirectTo));
} else {
setError("token_exchange_failed");
}
@@ -153,7 +189,7 @@ function OAuthCallbackInner() {
redirectTo = saved;
}
} catch { /* sessionStorage may be unavailable */ }
router.push(redirectTo);
router.push(toRouterPath(redirectTo));
} else {
setError("token_exchange_failed");
}
@@ -181,7 +217,7 @@ function OAuthCallbackInner() {
</p>
<Button
variant="outline"
onClick={() => router.push(`${getPathPrefix(params.locale as string)}/${params.locale}/login`)}
onClick={() => router.push(toRouterPath(`${getPathPrefix(params.locale as string)}/${params.locale}/login`))}
>
{t("oauth_error.back_to_login")}
</Button>
+76 -16
View File
@@ -16,6 +16,7 @@ import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store";
import { usePolicyStore } from "@/stores/policy-store";
import { toast } from "@/stores/toast-store";
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
@@ -60,6 +61,7 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
import { getUserParticipantId } from "@/lib/calendar-participants";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-calendar-colors";
import { debug } from "@/lib/debug";
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
@@ -96,7 +98,11 @@ export default function CalendarPage() {
removeCalendar, clearCalendarEvents,
refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore();
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
const taskStore = useTaskStore();
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
const { identities } = useIdentityStore();
@@ -175,10 +181,13 @@ export default function CalendarPage() {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
redirectToLogin();
} else if (client && !calendarEnabled) {
// Calendar disabled by admin policy - send the user back to mail.
router.push("/");
} else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) {
router.push("/");
}
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
}, [initialCheckDone, isAuthenticated, authLoading, client, calendarEnabled, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
useEffect(() => {
if (error) {
@@ -1030,10 +1039,47 @@ export default function CalendarPage() {
try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
})();
// Apply each shared calendar's local color override (per-viewer recolor,
// #345). The override replaces the calendar's color and wins over per-event
// colors via the `colorIsLocalOverride` flag (see getEventColor). Personal
// calendars are passed through untouched.
const displayCalendars = useMemo(() => {
return calendars.map((cal) => {
if (!cal.isShared) return cal;
const override = sharedCalendarColors[sharedCalendarColorKey(cal)];
if (!override) return cal;
return { ...cal, color: override, colorIsLocalOverride: true };
});
}, [calendars, sharedCalendarColors]);
// Auto-assign a random, not-yet-used palette color to any freshly shared
// calendar so multiple shared calendars don't collide on one color. Runs
// once per calendar (guarded by the presence of an existing key), and the
// user can still overwrite it from the sidebar.
useEffect(() => {
const shared = calendars.filter((c) => c.isShared);
const missing = shared.filter((c) => !sharedCalendarColors[sharedCalendarColorKey(c)]);
if (missing.length === 0) return;
// Seed "used" with personal calendar colors plus already-assigned shared
// overrides so the picks stay distinct from what's already on screen.
const used = new Set<string>();
for (const c of calendars) {
if (!c.isShared && c.color) used.add(c.color.toLowerCase());
}
for (const color of Object.values(sharedCalendarColors)) {
if (color) used.add(color.toLowerCase());
}
for (const cal of missing) {
const color = pickUnusedCalendarColor(used);
used.add(color.toLowerCase());
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
}
}, [calendars, sharedCalendarColors, setSharedCalendarColor]);
const allCalendars = useMemo(() => {
if (!showBirthdayCalendar) return calendars;
return [...calendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
}, [calendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
if (!showBirthdayCalendar) return displayCalendars;
return [...displayCalendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
}, [displayCalendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
const visibleEvents = useMemo(() => {
const filtered = events.filter((e) => {
@@ -1103,13 +1149,13 @@ export default function CalendarPage() {
</div>
<div className="px-6 py-4 space-y-3">
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
<span className="text-left">
<span className="text-start">
<span className="block font-medium">{tWebcalAction("import_title")}</span>
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
</span>
</Button>
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleSubscribeWebcal}>
<span className="text-left">
<span className="text-start">
<span className="block font-medium">{tWebcalAction("subscribe_title")}</span>
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
</span>
@@ -1123,6 +1169,7 @@ export default function CalendarPage() {
) : null;
if (!isAuthenticated) return null;
if (!calendarEnabled) return null;
if (!supportsCalendar) return renderWebcalAccountPicker();
const renderView = () => {
@@ -1219,7 +1266,7 @@ export default function CalendarPage() {
/>
<TaskListView
tasks={taskStore.tasks}
calendars={calendars}
calendars={displayCalendars}
selectedCalendarIds={selectedCalendarIds}
filter={taskStore.filter}
showCompleted={taskStore.showCompleted}
@@ -1289,7 +1336,7 @@ export default function CalendarPage() {
<>
<div
className={cn(
"border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
"border-e border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
!isResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
@@ -1317,8 +1364,21 @@ export default function CalendarPage() {
updateSetting('birthdayCalendarColor', color);
return;
}
// Shared calendars: recolor locally only (the viewer usually
// can't write the owner's calendar, and it'd recolor it for
// everyone). Personal calendars write through to the server.
const cal = allCalendars.find((c) => c.id === calendarId);
if (cal?.isShared) {
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
return;
}
updateCalendar(client, calendarId, { color });
} : undefined}
onResetColor={(cal) => {
// Drop the local override; the auto-assign effect picks a
// fresh unused color (so it never reverts to a collision).
removeSharedCalendarColor(sharedCalendarColorKey(cal));
}}
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
onCreateEvent={(cal: Calendar) => {
setDefaultCalendarIdForCreate(cal.id);
@@ -1389,7 +1449,7 @@ export default function CalendarPage() {
onSubscribe={() => setShowSubscriptionModal(true)}
isMobile={isMobile}
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
calendars={calendars}
calendars={displayCalendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
enableCalendarTasks={enableCalendarTasks}
@@ -1415,11 +1475,11 @@ export default function CalendarPage() {
{/* Desktop event panel */}
{!isMobile && showEventModal && (
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
<EventModal
key={editEvent?.id ?? 'new'}
event={editEvent}
calendars={calendars}
calendars={displayCalendars}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
defaultAllDay={defaultModalAllDay}
@@ -1438,11 +1498,11 @@ export default function CalendarPage() {
{/* Desktop task panel */}
{!isMobile && showTaskModal && (
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
<TaskModal
key={editTask?.id ?? 'new-task'}
task={editTask}
calendars={calendars}
calendars={displayCalendars}
onSave={handleSaveTask}
onDelete={handleDeleteTask}
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
@@ -1529,7 +1589,7 @@ export default function CalendarPage() {
{detailEvent && detailAnchorRect && (
<EventDetailPopover
event={detailEvent}
calendar={calendars.find(c => detailEvent.calendarIds[c.id])}
calendar={displayCalendars.find(c => detailEvent.calendarIds[c.id])}
anchorRect={detailAnchorRect}
onEdit={handleEditFromDetail}
onDelete={handleDeleteFromDetail}
@@ -1549,7 +1609,7 @@ export default function CalendarPage() {
<EventModal
key={editEvent?.id ?? 'new'}
event={editEvent}
calendars={calendars}
calendars={displayCalendars}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
defaultAllDay={defaultModalAllDay}
@@ -1566,7 +1626,7 @@ export default function CalendarPage() {
{showImportModal && client && (
<ICalImportModal
calendars={calendars}
calendars={displayCalendars}
client={client}
initialUrl={pendingSubscription?.url}
onClose={() => {
+101 -6
View File
@@ -18,7 +18,9 @@ import { ContactImportDialog } from "@/components/contacts/contact-import-dialog
import { RenameDialog } from "@/components/files/rename-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { savePendingMailto } from "@/lib/protocol-handlers/session";
import { formatRecipient } from "@/lib/email-composer-utils";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { usePolicyStore } from "@/stores/policy-store";
@@ -82,6 +84,7 @@ export default function ContactsPage() {
bulkDeleteContacts,
bulkAddToGroup,
moveContactToAddressBook,
createAddressBook,
renameAddressBook,
removeAddressBook,
shareAddressBook,
@@ -93,6 +96,7 @@ export default function ContactsPage() {
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
const [showImportDialog, setShowImportDialog] = useState(false);
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
const [creatingAddressBook, setCreatingAddressBook] = useState(false);
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
@@ -173,12 +177,13 @@ export default function ContactsPage() {
const addEmail = searchParams.get('addEmail');
const addName = searchParams.get('addName');
const from = searchParams.get('from');
const viewParam = searchParams.get('view');
if (!contactId && !addEmail && !from) return;
intentAppliedRef.current = true;
if (from === 'email') setReturnToEmail(true);
if (contactId) {
setSelectedContact(contactId);
setView('detail');
setView(viewParam === 'edit' ? 'edit' : 'detail');
} else if (addEmail) {
setCreatePrefill({ email: addEmail, name: addName ?? undefined });
setSelectedContact(null);
@@ -297,6 +302,34 @@ export default function ContactsPage() {
}
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
// Refresh address books (and contacts) after a structural change, staying
// multi-account aware so a freshly created book lands in the sidebar.
const refreshAddressBooks = useCallback(async () => {
if (!client) return;
if (multiAccountEnabled && accountClients.length > 0) {
const activeId = useAuthStore.getState().activeAccountId;
if (activeId) {
const { fetchAllAccountsAddressBooks } = useContactStore.getState();
await fetchAllAccountsAddressBooks(accountClients, activeId);
return;
}
}
await useContactStore.getState().fetchAddressBooks(client);
}, [client, multiAccountEnabled, accountClients]);
const handleCreateAddressBook = useCallback(async (name: string) => {
if (!client) return;
try {
await createAddressBook(client, name);
await refreshAddressBooks();
toast.success(t("address_books.created"));
setCreatingAddressBook(false);
} catch (error) {
console.error('Failed to create address book:', error);
toast.error(t("address_books.create_failed"));
}
}, [client, createAddressBook, refreshAddressBooks, t]);
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
return importContacts(
supportsSync && client ? client : null,
@@ -460,6 +493,51 @@ export default function ContactsPage() {
setView("group-edit");
}, []);
// Open the in-app composer in the current session rather than routing through
// a mailto: URL. `window.location='mailto:'` hands off to the OS handler
// (which may open a different mail app), and the mailto protocol round-trip
// reloads the app - dropping the in-memory per-account JMAP clients of a
// multi-account session, which reads as a logout. Stashing the recipients and
// doing a client-side router.push keeps the session and the active account
// intact; the main route consumes the pending compose and opens the composer
// (see consumePendingMailto in page.tsx).
const openComposeInApp = useCallback((recipients: string[], field: "to" | "cc" | "bcc") => {
savePendingMailto({
to: field === "to" ? recipients : [],
cc: field === "cc" ? recipients : [],
bcc: field === "bcc" ? recipients : [],
subject: "",
body: "",
});
router.push("/");
}, [router]);
const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => {
// Format each member as "Name <email>" so the composer keeps the display
// name (round-trips via formatRecipient -> parseRecipientList). Dedupe by
// email, case-insensitively; members without an email are skipped.
const seen = new Set<string>();
const recipients: string[] = [];
for (const member of getGroupMembers(groupId)) {
const email = getContactPrimaryEmail(member).trim();
const key = email.toLowerCase();
if (!email || seen.has(key)) continue;
seen.add(key);
recipients.push(formatRecipient(getContactDisplayName(member), email));
}
if (recipients.length === 0) {
toast.error(t("groups.no_member_emails"));
return;
}
openComposeInApp(recipients, field);
}, [getGroupMembers, t, openComposeInApp]);
const handleComposeContact = useCallback((contact: ContactCard) => {
const email = getContactPrimaryEmail(contact).trim();
if (!email) return;
openComposeInApp([formatRecipient(getContactDisplayName(contact), email)], "to");
}, [openComposeInApp]);
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
const confirmed = await confirmDialog({
title: t("groups.delete_confirm_title"),
@@ -624,6 +702,7 @@ export default function ContactsPage() {
onEdit={handleEditGroup}
onDelete={handleDeleteGroup}
onRemoveMember={handleRemoveGroupMember}
onComposeGroup={(field) => handleComposeGroupFromSidebar(selectedGroup.id, field)}
isMobile={isMobile}
onSelectMember={(id) => {
setSelectedContact(id);
@@ -672,7 +751,7 @@ export default function ContactsPage() {
<button
key={group.id}
onClick={() => handleBulkAddToGroupConfirm(group.id)}
className="w-full flex items-center gap-3 px-6 py-3 text-left hover:bg-muted transition-colors"
className="w-full flex items-center gap-3 px-6 py-3 text-start hover:bg-muted transition-colors"
>
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<Users className="w-4 h-4 text-primary" />
@@ -701,6 +780,11 @@ export default function ContactsPage() {
contact={selectedContact}
onEdit={handleEdit}
onDelete={handleDelete}
onCompose={
selectedContact
? () => handleComposeContact(selectedContact)
: undefined
}
onAddToGroup={
selectedContact
? () => handleAddContactToGroup(selectedContact.id)
@@ -785,7 +869,7 @@ export default function ContactsPage() {
<>
<div
className={cn(
"border-r border-border flex flex-col flex-shrink-0 bg-background",
"border-e border-border flex flex-col flex-shrink-0 bg-background",
!isSidebarResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
@@ -803,9 +887,11 @@ export default function ContactsPage() {
onSelectCategory={handleSelectCategory}
onCreateGroup={handleCreateGroup}
onCreateContact={handleCreateNew}
onCreateAddressBook={client ? () => setCreatingAddressBook(true) : undefined}
onImport={() => setShowImportDialog(true)}
onEditGroup={handleEditGroupFromSidebar}
onDeleteGroup={handleDeleteGroupFromSidebar}
onComposeGroup={handleComposeGroupFromSidebar}
onDropContacts={handleDropContacts}
onDropContactsToCategory={handleDropContactsToCategory}
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
@@ -851,7 +937,7 @@ export default function ContactsPage() {
<div
data-tour="contacts-list"
className={cn(
"border-r border-border bg-background flex flex-col flex-shrink-0",
"border-e border-border bg-background flex flex-col flex-shrink-0",
isMobile ? "w-full" : "",
!isListResizing && !isMobile && "transition-[width] duration-300"
)}
@@ -905,7 +991,7 @@ export default function ContactsPage() {
onClick={mobileBackToList}
className="touch-manipulation"
>
<ArrowLeft className="w-4 h-4 mr-2" />
<ArrowLeft className="w-4 h-4 me-2" />
{returnToEmail ? t("back_to_email") : t("back_to_contacts")}
</Button>
</div>
@@ -951,6 +1037,15 @@ export default function ContactsPage() {
}}
/>
)}
{creatingAddressBook && (
<RenameDialog
currentName=""
title={t("address_books.create")}
label={t("address_books.name_label")}
onCancel={() => setCreatingAddressBook(false)}
onConfirm={handleCreateAddressBook}
/>
)}
{renamingAddressBook && (
<RenameDialog
currentName={renamingAddressBook.name}
+2 -2
View File
@@ -38,11 +38,11 @@ export default function LocaleError({
</p>
<div className="flex gap-3 justify-center">
<Button variant="outline" onClick={() => router.push('/')}>
<Home className="w-4 h-4 mr-2" />
<Home className="w-4 h-4 me-2" />
{t("go_home")}
</Button>
<Button onClick={reset}>
<RefreshCw className="w-4 h-4 mr-2" />
<RefreshCw className="w-4 h-4 me-2" />
{t("try_again")}
</Button>
</div>
+49 -4
View File
@@ -22,12 +22,13 @@ import { useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { usePolicyStore } from "@/stores/policy-store";
import { FileBrowser } from "@/components/files/file-browser";
import type { FileNodeRights } from "@/lib/jmap/types";
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { AlertTriangle } from "lucide-react";
import { AlertTriangle, Loader2 } from "lucide-react";
export default function FilesPage() {
const router = useRouter();
@@ -48,9 +49,11 @@ export default function FilesPage() {
supportsFiles,
selectedResources,
uploadProgress,
migrationProgress,
clipboard,
initClient,
checkSupport,
migrateLegacyFlatNodes,
navigate,
navigateByPath,
refresh,
@@ -86,6 +89,7 @@ export default function FilesPage() {
cancelUpload,
undoLastAction,
lastAction,
shareResource,
} = useFileStore();
const isMobile = useIsMobile();
@@ -160,13 +164,16 @@ export default function FilesPage() {
const storeClient = useFileStore(s => s.client);
useEffect(() => {
if (storeClient && supportsFiles === null) {
checkSupport().then((supported) => {
checkSupport().then(async (supported) => {
if (supported) {
// Upgrade any files created by older builds (flat path-encoded names)
// into the real FileNode hierarchy before the first listing.
await migrateLegacyFlatNodes();
navigate(null);
}
});
}
}, [storeClient, supportsFiles, checkSupport, navigate]);
}, [storeClient, supportsFiles, checkSupport, migrateLegacyFlatNodes, navigate]);
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
// Pro shell only: the Account breadcrumb segment signals "go to this
@@ -396,6 +403,14 @@ export default function FilesPage() {
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
// Sharing: the browsing client (store-attached) drives the principal picker
// and share mutations. supportsPrincipals() gates the whole Share affordance.
const sharingEnabled = !!storeClient?.supportsPrincipals();
const filesAccountId = storeClient?.getFilesAccountId() ?? null;
const handleShare = useCallback(async (id: string, principalId: string, rights: FileNodeRights | null) => {
await shareResource(id, principalId, rights);
}, [shareResource]);
// Pro shell only: all connected accounts are equal top-level entries at
// the root. The root path "/" itself is a cross-account picker - no
// account's files are shown until the user enters one.
@@ -462,7 +477,7 @@ export default function FilesPage() {
onClick={() => router.push("/")}
className="justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
<ArrowLeft className="w-4 h-4 me-2" />
{t("title")}
</Button>
</div>
@@ -535,6 +550,10 @@ export default function FilesPage() {
onSelectAccount={handleSelectAccount}
accountPickerMode={isAccountPicker}
accountLabel={currentAccountLabel}
client={storeClient}
ownAccountId={filesAccountId}
sharingEnabled={sharingEnabled}
onShare={handleShare}
/>
</div>
)}
@@ -573,6 +592,32 @@ export default function FilesPage() {
/>
)}
{/* Legacy file migration progress (issue #379) */}
{migrationProgress && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-[22rem] max-w-[90vw] rounded-lg border border-border bg-background p-6 shadow-xl">
<div className="flex items-center gap-3">
<Loader2 className="w-5 h-5 text-primary animate-spin shrink-0" />
<div>
<p className="text-sm font-medium">{t("migration_title")}</p>
<p className="text-xs text-muted-foreground">{t("migration_description")}</p>
</div>
</div>
<div className="mt-4 h-1.5 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-300"
style={{ width: migrationProgress.total > 0
? `${(migrationProgress.current / migrationProgress.total) * 100}%`
: '0%' }}
/>
</div>
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-end">
{migrationProgress.current} / {migrationProgress.total}
</p>
</div>
</div>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} />
</div>
+2
View File
@@ -9,6 +9,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { locales } from "@/i18n/routing";
export default async function LocaleLayout({
@@ -41,6 +42,7 @@ export default async function LocaleLayout({
{children}
<PluginDialogHost />
<PluginConsentDialog />
<PWAInstallPrompt />
</ProtocolLaunchHandlerProvider>
</TourProvider>
</EmbeddedBridgeProvider>
+44 -21
View File
@@ -11,10 +11,10 @@ import { useAccountStore } from "@/stores/account-store";
import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config";
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
import { apiFetch, getPathPrefix, toRouterPath, withBasePath } from "@/lib/browser-navigation";
import { cn } from "@/lib/utils";
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { useUpdateStore, selectBanner } from "@/stores/update-store";
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
@@ -133,9 +133,16 @@ export default function LoginPage() {
const isMobileHandoff = Boolean(mobileRedirectUri);
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
// Login logo sizing: when a max height/width is configured, drop the fixed
// 64×64 box so the logo (e.g. a wide wordmark) can render at its true size.
const hasLogoSize = Boolean(loginLogoMaxHeight || loginLogoMaxWidth);
const loginLogoStyle = hasLogoSize
? { maxHeight: loginLogoMaxHeight || undefined, maxWidth: loginLogoMaxWidth || undefined }
: undefined;
const [formData, setFormData] = useState({
username: "",
password: "",
@@ -279,7 +286,7 @@ export default function LoginPage() {
redirectTo = saved;
}
} catch { /* ignore */ }
router.push(redirectTo);
router.push(toRouterPath(redirectTo));
}
}, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
@@ -329,16 +336,27 @@ export default function LoginPage() {
if (!oauthEnabled || !serverUrl) return;
setOauthDiscoveryDone(false);
setOauthMetadata(null);
discoverOAuth(effectiveOauthIssuerUrl || serverUrl)
const controller = new AbortController();
// Discover via our own origin rather than fetching the IdP's /.well-known/*
// documents directly from the browser. A direct cross-origin discovery
// fetch is subject to CORS, and providers like Authentik serve those
// documents without Access-Control-Allow-Origin, so the browser blocks the
// response and login breaks (issue #382). The proxy runs discovery server
// side where CORS does not apply.
const query = selectedServer?.id ? `?server_id=${encodeURIComponent(selectedServer.id)}` : "";
apiFetch(`/api/auth/oauth/metadata${query}`, { signal: controller.signal })
.then(async (res) => (res.ok ? ((await res.json()) as OAuthMetadata) : null))
.then((metadata) => {
setOauthMetadata(metadata);
setOauthDiscoveryDone(true);
})
.catch(() => {
.catch((err) => {
if (err?.name === "AbortError") return;
setOauthMetadata(null);
setOauthDiscoveryDone(true);
});
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl]);
return () => controller.abort();
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl, selectedServer?.id]);
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
const ssoError = searchParams.get("sso_error");
@@ -644,7 +662,7 @@ export default function LoginPage() {
redirectTo = saved;
}
} catch { /* ignore */ }
router.push(redirectTo);
router.push(toRouterPath(redirectTo));
}
};
@@ -707,7 +725,7 @@ export default function LoginPage() {
)}
>
<Icon className="w-4 h-4" />
<span className="flex-1 text-left">{option.label}</span>
<span className="flex-1 text-start">{option.label}</span>
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
</button>
);
@@ -856,7 +874,7 @@ export default function LoginPage() {
)}
>
<Icon className="w-4 h-4" />
<span className="flex-1 text-left">{option.label}</span>
<span className="flex-1 text-start">{option.label}</span>
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
</button>
);
@@ -870,19 +888,24 @@ export default function LoginPage() {
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
{/* Header section with logo */}
<div className="px-8 pt-10 pb-6 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
<div className={cn("inline-flex items-center justify-center mb-5", !hasLogoSize && "w-16 h-16")}>
<img
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
alt={appName}
className="max-w-16 max-h-16 object-contain"
className={cn("object-contain", !hasLogoSize && "max-w-16 max-h-16")}
style={loginLogoStyle}
/>
</div>
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
{isAddAccountMode ? t("add_account_title") : appName}
</h1>
<p className="text-sm text-muted-foreground mt-1.5">
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
</p>
{loginShowHeading && (
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
{isAddAccountMode ? t("add_account_title") : appName}
</h1>
)}
{loginShowSubtitle && (
<p className="text-sm text-muted-foreground mt-1.5">
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
</p>
)}
</div>
{/* Form section */}
@@ -1110,7 +1133,7 @@ export default function LoginPage() {
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="h-11 px-3.5 pr-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
className="h-11 px-3.5 pe-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
placeholder={t("password_placeholder")}
required
autoComplete="current-password"
@@ -1230,9 +1253,9 @@ export default function LoginPage() {
disabled={oauthLoading || isLoading}
>
{oauthLoading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
<Loader2 className="w-4 h-4 animate-spin me-2" />
) : (
<LogIn className="w-4 h-4 mr-2" />
<LogIn className="w-4 h-4 me-2" />
)}
{t("sign_in_sso")}
</Button>
+430 -95
View File
@@ -11,8 +11,9 @@ import type { ComposerDraftData } from "@/components/email/email-composer";
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, ALL_MAIL_MAILBOX_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types";
import { useAccountStore } from "@/stores/account-store";
import { usePolicyStore } from "@/stores/policy-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore, buildUnifiedAccountClients } from "@/stores/email-store";
@@ -31,6 +32,7 @@ import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navi
import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound";
import { cn } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import {
ErrorBoundary,
SidebarErrorFallback,
@@ -55,7 +57,7 @@ import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailb
import { Input } from "@/components/ui/input";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview";
import { appendPlainTextSignature } from "@/lib/signature-utils";
import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { resolveReplyFrom } from "@/lib/reply-identity";
@@ -72,7 +74,8 @@ import { plainTextToComposerBody } from "@/lib/email-composer-utils";
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
import { emailToReadView } from "@/lib/plugin-projection";
import { buildQuoteHeader } from "@/lib/quote-header";
import { useLocaleStore } from "@/stores/locale-store";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { getEffectiveLocale } from '@/i18n/detect-locale';
import type { QuoteHeader } from "@/lib/plugin-types";
const SCHEDULED_MAILBOX_ID = '__scheduled__';
@@ -81,6 +84,7 @@ const SCHEDULED_MAILBOX_ID = '__scheduled__';
export default function Home() {
const t = useTranslations();
const tCommon = useTranslations('common');
const tQuote = useTranslations('quote_header');
const { appName } = useConfig();
const mailLayout = useSettingsStore((state) => state.mailLayout);
const [showComposer, setShowComposer] = useState(false);
@@ -117,7 +121,7 @@ export default function Home() {
useIdentitySync();
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore();
const { loadTrustedSendersBook, trustedSendersLoaded, loadRecentRecipients } = useContactStore();
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
const value = window.prompt(t('email_viewer.reschedule_prompt'));
@@ -299,6 +303,7 @@ export default function Home() {
fetchTagCounts,
fetchEmailContent,
isUnifiedView,
unifiedRole,
scheduledEmails,
scheduledTotal,
scheduledHasMore,
@@ -315,7 +320,10 @@ export default function Home() {
clearPendingUndoSend,
pendingUndoSend,
fetchUnifiedEmails: fetchUnifiedEmailsAction,
fetchCrossView: fetchCrossViewAction,
refreshUnifiedCounts,
refreshCrossCounts,
crossUnreadCount,
exitUnifiedView,
emptyMailbox,
markMailboxAsRead,
@@ -331,14 +339,40 @@ export default function Home() {
viewingAccountId,
selectAccountMailbox,
setViewingAccount,
refreshCurrentMailbox,
} = useEmailStore();
// Load recent recipients (from the Sent folder) for compose autocomplete.
// Runs once when the Sent mailbox is known; the store guards against reloads.
useEffect(() => {
const sent = mailboxes.find((m) => m.role === 'sent');
if (client && sent) {
loadRecentRecipients(client, sent.originalId || sent.id);
}
}, [client, mailboxes, loadRecentRecipients]);
// Pro shell: populate per-account mailbox cache so the sidebar can render
// every connected account Thunderbird-style.
useProMultiAccountMailboxes();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
const enableAllMailView = useSettingsStore((s) => s.enableAllMailView);
const delayedSendSupported = client?.hasDelayedSend() ?? true;
const allMailViewEnabled = usePolicyStore((s) => s.isFeatureEnabled('allMailViewEnabled'));
const showAllMailMailbox = allMailViewEnabled && enableAllMailView;
// Cross-account "All accounts" views: a sub-feature of the unified mailbox, so
// they require Unified Mailbox to be enabled, plus the admin gate and the
// per-user setting. Hooks are called unconditionally.
const crossUnreadGate = usePolicyStore((s) => s.isFeatureEnabled('crossUnreadViewEnabled'));
const crossStarredGate = usePolicyStore((s) => s.isFeatureEnabled('crossStarredViewEnabled'));
const crossAllGate = usePolicyStore((s) => s.isFeatureEnabled('crossAllViewEnabled'));
const enableCrossUnreadView = useSettingsStore((s) => s.enableCrossUnreadView);
const enableCrossStarredView = useSettingsStore((s) => s.enableCrossStarredView);
const enableCrossAllView = useSettingsStore((s) => s.enableCrossAllView);
const showCrossUnread = enableUnifiedMailbox && crossUnreadGate && enableCrossUnreadView;
const showCrossStarred = enableUnifiedMailbox && crossStarredGate && enableCrossStarredView;
const showCrossAll = enableUnifiedMailbox && crossAllGate && enableCrossAllView;
const activeEmails = isScheduledView ? scheduledEmails : emails;
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
@@ -587,6 +621,10 @@ export default function Home() {
onToggleSpam: async () => {
if (isScheduledView) return;
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
// Marking your own outgoing mail as spam makes no sense - the toolbar
// and menus hide the action in Sent/Drafts/Scheduled, so the shortcut
// is a no-op there too.
if (['sent', 'drafts', 'scheduled'].includes(currentMailbox?.role || '')) return;
const isInJunk = currentMailbox?.role === 'junk';
if (selectedEmailIds.size > 0 && client) {
const ids = Array.from(selectedEmailIds);
@@ -608,6 +646,7 @@ export default function Home() {
}
},
onCompose: () => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -709,7 +748,7 @@ export default function Home() {
// Mailbox view
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
if (mailbox) {
const mailboxName = mailbox.name;
const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`));
const unreadCount = mailbox.unreadEmails || 0;
title = unreadCount > 0
? `${mailboxName} (${unreadCount}) - ${appName}`
@@ -750,9 +789,9 @@ export default function Home() {
let title = t('email_composer.new_message');
if (baseSubject) {
if (effectiveMode === 'reply' || effectiveMode === 'replyAll') {
title = baseSubject.startsWith('Re:') ? baseSubject : `Re: ${baseSubject}`;
title = buildReplySubject(baseSubject, t('email_composer.prefix.reply'));
} else if (effectiveMode === 'forward') {
title = baseSubject.startsWith('Fwd:') ? baseSubject : `Fwd: ${baseSubject}`;
title = buildForwardSubject(baseSubject, t('email_composer.prefix.forward'));
} else {
title = baseSubject;
}
@@ -977,14 +1016,16 @@ export default function Home() {
// recounting happened"). The Pro shell always renders the unified mailbox
// regardless of the user setting, so refresh when embedded too.
useEffect(() => {
if (!enableUnifiedMailbox && !isEmbedded) return;
const anyCross = showCrossUnread || showCrossStarred || showCrossAll;
if (!enableUnifiedMailbox && !isEmbedded && !anyCross) return;
if (!isAuthenticated || !client) return;
buildPopulatedUnifiedAccounts().then((built) => {
const hasGroupEntry = built.some((b) => b.isShared);
if (anyCross) refreshCrossCounts(built);
if (built.length < 2 && !hasGroupEntry && !isEmbedded) return;
refreshUnifiedCounts(built);
});
}, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts]);
}, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts, refreshCrossCounts, showCrossUnread, showCrossStarred, showCrossAll]);
// System-notification click handler. The push SW navigates the user back
// here with `?email=<id>` (specific email it built the toast from) or
@@ -1030,8 +1071,8 @@ export default function Home() {
// Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before
// calling selectEmail on the stub), to avoid a duplicate request.
if (!selectedEmail.bodyValues && !isLoadingEmail) {
const perAccountClient = isUnifiedView && selectedEmail.accountId
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
const perAccountClient = isUnifiedView && selectedEmail.sourceClientAccountId
? useAuthStore.getState().getClientForAccount(selectedEmail.sourceClientAccountId)
: undefined;
const fetchClient = perAccountClient ?? client;
setLoadingEmail(true);
@@ -1128,6 +1169,7 @@ export default function Home() {
inReplyTo?: string[];
references?: string[];
delayedUntil?: string;
requestReadReceipt?: boolean;
}) => {
if (!client) return;
@@ -1135,7 +1177,7 @@ export default function Home() {
const effectiveMode = pendingDraft?.mode ?? composerMode;
const originalEmailId = selectedEmail?.id;
const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom);
const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt });
setShowComposer(false);
if (result.scheduled) {
await refreshScheduledMetadata(client);
@@ -1143,23 +1185,53 @@ export default function Home() {
return;
}
// Mark the original email with $answered or $forwarded keyword
if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) {
// Mark the original email with $answered or $forwarded keyword. Route the
// write to the email's own account so the flag lands on shared/group-mailbox
// messages instead of being dropped against the reaching account. (#281)
if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll' || effectiveMode === 'forward')) {
const s = useEmailStore.getState();
const orig = s.emails.find(e => e.id === originalEmailId);
const kwClientId = s.isUnifiedView ? orig?.sourceClientAccountId : undefined;
const kwAccountId = s.isUnifiedView ? orig?.sourceAccountId : undefined;
const kwClient = kwClientId
? (useAuthStore.getState().getClientForAccount(kwClientId) ?? client)
: client;
const keyword = effectiveMode === 'forward' ? '$forwarded' : '$answered';
try {
await client.setKeyword(originalEmailId, '$answered');
await kwClient.setKeyword(originalEmailId, keyword, kwAccountId);
} catch (e) {
debug.error('Failed to set $answered keyword:', e);
}
} else if (originalEmailId && effectiveMode === 'forward') {
try {
await client.setKeyword(originalEmailId, '$forwarded');
} catch (e) {
debug.error('Failed to set $forwarded keyword:', e);
debug.error(`Failed to set ${keyword} keyword:`, e);
}
}
// Refresh the current mailbox to update the UI
if (!isScheduledView) await fetchEmails(client, selectedMailbox);
if (!isScheduledView) {
await refreshCurrentMailbox(client);
// Re-fetch the replied thread's cross-folder data so the expanded
// view shows the newly sent reply without collapsing.
if (originalEmailId) {
const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
// Route to the email's own account so shared/group threads refresh
// from the right server, not the active one. (#281)
const threadClient = emailState.isUnifiedView && repliedEmail.sourceClientAccountId
? (useAuthStore.getState().getClientForAccount(repliedEmail.sourceClientAccountId) ?? client)
: client;
const accountId = emailState.isUnifiedView && repliedEmail.sourceAccountId
? repliedEmail.sourceAccountId
: client.getAccountId();
const fullEmails = await threadClient.getThreadEmails(repliedEmail.threadId, accountId);
if (fullEmails.length > 0) {
useEmailStore.setState((state) => {
const c = new Map(state.threadEmailsCache);
c.set(repliedEmail.threadId!, fullEmails);
return { threadEmailsCache: c };
});
}
}
}
}
} catch (error) {
console.error("Failed to send email:", error);
}
@@ -1208,16 +1280,32 @@ export default function Home() {
},
newTo,
newCc,
locale: useLocaleStore.getState().locale,
locale: getEffectiveLocale(),
timeFormat: useSettingsStore.getState().timeFormat,
unknownLabel: tCommon('unknown'),
labels: {
formatReplyLine: (vars) => tQuote('reply_line', vars),
forwardedSeparator: tQuote('forwarded_separator'),
fromLabel: tQuote('from_label'),
dateLabel: tQuote('date_label'),
subjectLabel: tQuote('subject_label'),
},
});
setComposerQuoteHeader(header);
} catch (err) {
console.warn('[quote-header] plugin transform failed; using default', err);
setComposerQuoteHeader(null);
}
}, [tCommon]);
}, [tCommon, tQuote]);
// Force a clean composer remount on every fresh entry point so prior
// compose state can't bleed into the new session (#329 C). The composer is
// keyed on composerSessionId, and pendingDraft would otherwise pin the
// composer to a stale draft from a discarded reply.
const startFreshComposerSession = useCallback(() => {
setComposerSessionId(id => id + 1);
setPendingDraft(null);
}, []);
const handleReply = async (draftText?: string) => {
if (selectedEmail) {
@@ -1231,6 +1319,7 @@ export default function Home() {
} else {
setComposerQuoteHeader(null);
}
startFreshComposerSession();
setComposerDraftText(draftText || "");
setComposerMode('reply');
setShowComposer(true);
@@ -1255,6 +1344,8 @@ export default function Home() {
draft = fullDraft;
}
draft = await emailHooks.onBeforeEditDraft.transform(draft);
const bodyText = draft.bodyValues
? Object.values(draft.bodyValues).map(v => v.value).join('\n')
: '';
@@ -1340,6 +1431,7 @@ export default function Home() {
} else {
setComposerQuoteHeader(null);
}
startFreshComposerSession();
setComposerMode('replyAll');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -1357,6 +1449,7 @@ export default function Home() {
} else {
setComposerQuoteHeader(null);
}
startFreshComposerSession();
setComposerMode('forward');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -1365,8 +1458,20 @@ export default function Home() {
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
if (!client || !emailToDelete) return;
// Check if we're currently in the trash or junk folder
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
// In unified view the trash destination and current-folder check must come
// from the email's own account, not the active one. The owning account's
// mailbox list is cached under its JMAP id (`sourceAccountId`). (#281)
const actionMailboxes =
isUnifiedView && emailToDelete.sourceAccountId
? (accountMailboxes[emailToDelete.sourceAccountId] ?? mailboxes)
: mailboxes;
// Check if we're currently in the trash or junk folder. In unified view the
// "current folder" is the unified role within the email's account.
const currentMailbox = isUnifiedView
? (actionMailboxes.find(m => m.role === unifiedRole && emailToDelete.mailboxIds?.[m.id])
?? actionMailboxes.find(m => m.role === unifiedRole))
: mailboxes.find(m => m.id === selectedMailbox);
const isInTrash = currentMailbox?.role === 'trash';
const isInJunk = currentMailbox?.role === 'junk';
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
@@ -1387,11 +1492,17 @@ export default function Home() {
console.error("Failed to permanently delete email:", error);
}
} else {
// Not in trash: always move to trash
// 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 =
mailboxes.find(m => m.role === 'trash' && !m.isShared) ??
mailboxes.find(m => {
if (m.isShared) return false;
actionMailboxes.find(m => m.role === 'trash' && matchesScope(m)) ??
actionMailboxes.find(m => {
if (!matchesScope(m)) return false;
const lower = m.name.toLowerCase();
return lower.includes('trash') || lower.includes('deleted');
});
@@ -1413,9 +1524,30 @@ export default function Home() {
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
if (!client || !emailToArchive) return;
// In unified view the archive folder (and any year/month subfolders we
// create) must live in the email's own account, reached through the login it
// is reachable via (`sourceClientAccountId`) and routed to its owning JMAP
// account (`sourceAccountId`). For personal sources these resolve to the
// account itself, so behavior is unchanged. (#281)
const archiveClientId = isUnifiedView ? emailToArchive.sourceClientAccountId : undefined;
const archiveAccountId = isUnifiedView ? emailToArchive.sourceAccountId : undefined;
const archiveClient = archiveClientId
? (useAuthStore.getState().getClientForAccount(archiveClientId) ?? client)
: client;
// Read fresh mailboxes from the store batch archive calls this in a loop,
// and each iteration needs to see folders created by prior iterations.
const currentMailboxes = useEmailStore.getState().mailboxes;
const readMailboxes = () => {
const s = useEmailStore.getState();
return archiveAccountId
? (s.accountMailboxes[archiveAccountId] ?? s.mailboxes)
: s.mailboxes;
};
const refreshMailboxes = () =>
archiveAccountId
? useEmailStore.getState().fetchAccountMailboxes(archiveClient, archiveAccountId)
: fetchMailboxes(client);
const currentMailboxes = readMailboxes();
const archiveMailbox = currentMailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
if (!archiveMailbox) return;
@@ -1435,21 +1567,21 @@ export default function Home() {
m => m.name === year && m.parentId === archiveId
);
if (!yearMailbox) {
yearMailbox = await client.createMailbox(year, archiveId);
await fetchMailboxes(client);
yearMailbox = await archiveClient.createMailbox(year, archiveId, archiveAccountId);
await refreshMailboxes();
}
if (archiveMode === 'year') {
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
} else {
const yearId = yearMailbox.originalId || yearMailbox.id;
const afterYear = useEmailStore.getState().mailboxes;
const afterYear = readMailboxes();
let monthMailbox = afterYear.find(
m => m.name === month && m.parentId === yearId
);
if (!monthMailbox) {
monthMailbox = await client.createMailbox(month, yearId);
await fetchMailboxes(client);
monthMailbox = await archiveClient.createMailbox(month, yearId, archiveAccountId);
await refreshMailboxes();
}
await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id);
}
@@ -1460,7 +1592,7 @@ export default function Home() {
setConversationEmails([]);
}
void fetchMailboxes(client);
void refreshMailboxes();
} catch (error) {
console.error("Failed to archive email:", error);
}
@@ -1522,6 +1654,45 @@ export default function Home() {
}
};
const handleTogglePinned = async (emailToPin: Email) => {
if (!client) return;
try {
const email = emails.find(e => e.id === emailToPin.id) ?? emailToPin;
const isPinned = email.keywords?.['$pinned'] === true;
// JMAP keywords are a set of present keys - drop the key to unpin
// rather than writing a false value.
const keywords = { ...email.keywords };
if (isPinned) {
delete keywords['$pinned'];
} else {
keywords['$pinned'] = true;
}
// Same unified-view routing as color tags: write to the email's own
// account via the login it is reachable through. (#281)
const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined;
const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined;
const pinClient = pinClientId
? (useAuthStore.getState().getClientForAccount(pinClientId) ?? client)
: client;
await pinClient.updateEmailKeywords(email.id, keywords, pinAccountId);
// Patch in place so the icon flips immediately, then refetch the first
// page so the mail floats/sinks per the server's pinned-first sort.
// Skip the refetch where that sort does not apply (unified views) or
// where it would replace a tag-filtered list (refreshCurrentMailbox
// fetches by folder only).
setEmailKeywordsLocal(email.id, keywords);
if (!isUnifiedView && !useEmailStore.getState().selectedKeyword) {
void refreshCurrentMailbox(client);
}
} catch (error) {
console.error("Failed to toggle pin:", error);
}
};
const handleSetColorTag = async (emailId: string, color: string | null) => {
if (!client) return;
@@ -1541,7 +1712,7 @@ export default function Home() {
});
} else {
const jmapKey = `$label:${color}`;
if (keywords[jmapKey] === true) {
if (keywords[jmapKey]) {
// Toggle off if already active
keywords[jmapKey] = false;
} else {
@@ -1550,8 +1721,20 @@ export default function Home() {
}
}
// In unified view route the write to the email's own account, reached
// through the login it is reachable via (`sourceClientAccountId`) and
// applied to its owning JMAP account (`sourceAccountId`). For personal
// sources these resolve to the account itself, so behavior is unchanged.
// Without this, tags on shared/group-mailbox messages are written to the
// reaching account and silently dropped by the server. (#281)
const tagClientId = isUnifiedView ? email.sourceClientAccountId : undefined;
const tagAccountId = isUnifiedView ? email.sourceAccountId : undefined;
const tagClient = tagClientId
? (useAuthStore.getState().getClientForAccount(tagClientId) ?? client)
: client;
// Update email keywords via JMAP
await client.updateEmailKeywords(emailId, keywords);
await tagClient.updateEmailKeywords(emailId, keywords, tagAccountId);
// Patch the email in place so the list keeps its scroll/pagination state
// instead of being reset to the first page by a full refetch.
@@ -1589,7 +1772,15 @@ export default function Home() {
setTabletListVisible(true);
}
if (viewingClient) {
await fetchEmails(viewingClient, mailboxId);
// Keep an active search applied when switching folders (#553); the
// store actions resolve the viewing account's client internally.
if (!isFilterEmpty(searchFilters)) {
await advancedSearch(viewingClient);
} else if (searchQuery) {
await searchEmails(viewingClient, searchQuery);
} else {
await fetchEmails(viewingClient, mailboxId);
}
}
};
@@ -1637,6 +1828,28 @@ export default function Home() {
return;
}
if (isCrossViewId(mailboxId)) {
setScheduledView(false);
const view = CROSS_VIEW_BY_ID[mailboxId];
if (!view) return;
selectMailbox(mailboxId);
selectEmail(null);
if (isMobile) {
setSidebarOpen(false);
setActiveView("list");
}
if (isTablet) {
setTabletListVisible(true);
}
const populated = await buildPopulatedUnifiedAccounts();
await fetchCrossViewAction(populated, view);
refreshCrossCounts(populated);
return;
}
if (isUnifiedView) {
exitUnifiedView();
}
@@ -1657,8 +1870,13 @@ export default function Home() {
}
if (client) {
// If there's an active search, re-run it in the new mailbox
if (searchQuery) {
// If there's an active search, re-run it in the new mailbox. Advanced
// filters must go through advancedSearch (which also includes the text
// query) — falling back to fetchEmails would silently drop them while
// the UI still shows them as active (#553).
if (!isFilterEmpty(searchFilters)) {
await advancedSearch(client);
} else if (searchQuery) {
await searchEmails(client, searchQuery);
} else {
await fetchEmails(client, mailboxId);
@@ -2077,9 +2295,21 @@ export default function Home() {
// Append signature from the sending identity (fall back to primary
// when the reply-from lives on the same identity but a different alias).
const finalBody = appendPlainTextSignature(body, sendingIdentity, {
separator: useSettingsStore.getState().signatureSeparatorEnabled,
});
const separator = useSettingsStore.getState().signatureSeparatorEnabled;
const finalBody = appendPlainTextSignature(body, sendingIdentity, { separator });
// When the identity has an HTML signature, send a matching HTML body so the
// signature keeps its formatting; appendPlainTextSignature would otherwise
// flatten it to plain text. Text-only identities keep the plain-text-only
// behavior (htmlBody stays undefined).
const escapedBody = body
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
const finalHtmlBody = sendingIdentity?.htmlSignature?.trim()
? appendHtmlSignature(`<div>${escapedBody}</div>`, sendingIdentity, { separator })
: undefined;
const originalEmailId = selectedEmail.id;
const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds;
@@ -2103,7 +2333,7 @@ export default function Home() {
const result = await sendEmail(
client,
[sender.email],
`Re: ${selectedEmail.subject || "(no subject)"}`,
buildReplySubject(selectedEmail.subject || "(no subject)", t('email_composer.prefix.reply')),
finalBody,
undefined,
undefined,
@@ -2111,7 +2341,7 @@ export default function Home() {
headerFromEmail,
undefined,
headerFromName,
undefined,
finalHtmlBody,
undefined,
threading?.inReplyTo,
threading?.references,
@@ -2124,15 +2354,48 @@ export default function Home() {
return;
}
// Mark the original email as answered
try {
await client.setKeyword(originalEmailId, '$answered');
} catch (e) {
debug.error('Failed to set $answered keyword:', e);
// Mark the original email as answered. Route the write to the email's own
// account so the flag lands on shared/group-mailbox messages instead of
// being dropped against the reaching account. (#281)
{
const s = useEmailStore.getState();
const orig = s.emails.find(e => e.id === originalEmailId);
const kwClientId = s.isUnifiedView ? orig?.sourceClientAccountId : undefined;
const kwAccountId = s.isUnifiedView ? orig?.sourceAccountId : undefined;
const kwClient = kwClientId
? (useAuthStore.getState().getClientForAccount(kwClientId) ?? client)
: client;
try {
await kwClient.setKeyword(originalEmailId, '$answered', kwAccountId);
} catch (e) {
debug.error('Failed to set $answered keyword:', e);
}
}
// Refresh emails to show the sent reply
await fetchEmails(client, selectedMailbox);
await refreshCurrentMailbox(client);
// Re-fetch the replied thread's cross-folder data so the expanded
// view shows the newly sent reply without collapsing.
const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
// Route to the email's own account so shared/group threads refresh from
// the right server, not the active one. (#281)
const threadClient = emailState.isUnifiedView && repliedEmail.sourceClientAccountId
? (useAuthStore.getState().getClientForAccount(repliedEmail.sourceClientAccountId) ?? client)
: client;
const accountId = emailState.isUnifiedView && repliedEmail.sourceAccountId
? repliedEmail.sourceAccountId
: client.getAccountId();
const fullEmails = await threadClient.getThreadEmails(repliedEmail.threadId, accountId);
if (fullEmails.length > 0) {
useEmailStore.setState((state) => {
const c = new Map(state.threadEmailsCache);
c.set(repliedEmail.threadId!, fullEmails);
return { threadEmailsCache: c };
});
}
}
};
// Show loading state while checking auth
@@ -2148,7 +2411,16 @@ export default function Home() {
}
// Get current mailbox name for mobile header
const currentMailboxName = isScheduledView ? t('sidebar.scheduled') : mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
const currentMailboxName = isScheduledView
? t('sidebar.scheduled')
: selectedMailbox === ALL_MAIL_MAILBOX_ID
? t('sidebar.mailboxes.all_mail')
: (() => {
const mb = mailboxes.find(m => m.id === selectedMailbox);
return mb
? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`))
: "Inbox";
})();
const isFocusedMailLayout = mailLayout === 'focus';
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
@@ -2165,11 +2437,10 @@ export default function Home() {
setShowComposer(false);
}
// waiting for the body fetch - avoids the loading flicker.
// Find the list-level email for metadata (accountId, scheduled flags, etc.)
// but don't select it yet — wait for the full fetch to avoid a toolbar flash
// caused by rendering with the stub (no bodyValues) then re-rendering with the full email.
const listEmail = activeEmails.find(e => e.id === email.id);
if (listEmail) {
selectEmail(listEmail);
}
setLoadingEmail(true);
@@ -2185,21 +2456,25 @@ export default function Home() {
// Fetch the full content
try {
// In unified view each email carries its own accountId. Use that
// account's client so we fetch from the server that actually owns it.
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
const perAccountClient = emailAccountId
? useAuthStore.getState().getClientForAccount(emailAccountId)
// In unified view each email carries its source reference: the login it is
// reachable through (`sourceClientAccountId`) and its owning JMAP account
// (`sourceAccountId`). Resolve both so we fetch from the server that actually
// owns it — works uniformly for personal and shared/group sources, since for
// personal the owning account equals the client's primary (no-op). (#281)
const sourceClientId = isUnifiedView ? listEmail?.sourceClientAccountId : undefined;
const perAccountClient = sourceClientId
? useAuthStore.getState().getClientForAccount(sourceClientId)
: undefined;
const fetchClient = perAccountClient ?? client;
// For shared folders on the primary client, we still need to pass the
// shared account's id. In unified view we use the per-account client
// directly, so no explicit accountId is needed.
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = perAccountClient
? undefined
: mailbox?.isShared ? mailbox.accountId : undefined;
const accountId = isUnifiedView
? listEmail?.sourceAccountId
: (() => {
// Non-unified: shared folders on the active client still need their
// owner accountId passed explicitly.
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
return mailbox?.isShared ? mailbox.accountId : undefined;
})();
const fullEmail = await fetchClient.getEmail(email.id, accountId);
if (fullEmail) {
@@ -2211,9 +2486,13 @@ export default function Home() {
fullEmail.isScheduled = true;
fullEmail.isSmimeScheduled = listEmail.isSmimeScheduled;
}
if (emailAccountId) {
fullEmail.accountId = emailAccountId;
fullEmail.accountLabel = listEmail?.accountLabel;
// Re-stamp the source reference so later actions on the open email
// resolve to the right account (the fetched object lacks these).
if (isUnifiedView && listEmail) {
fullEmail.accountId = listEmail.accountId;
fullEmail.accountLabel = listEmail.accountLabel;
fullEmail.sourceClientAccountId = listEmail.sourceClientAccountId;
fullEmail.sourceAccountId = listEmail.sourceAccountId;
}
selectEmail(fullEmail);
// Mark-as-read logic is now handled by useEffect
@@ -2262,8 +2541,27 @@ export default function Home() {
setActiveView("viewer");
try {
// Fetch complete thread emails
const emails = await client.getThreadEmails(thread.threadId);
// In unified/aggregate views the thread may belong to another (possibly
// shared/group) account. Route the fetch to the login it's reachable
// through (`sourceClientAccountId`) and pass its owning JMAP account
// (`sourceAccountId`) so the thread loads from the right server instead of
// the active one (which doesn't have it → empty/body-less). (#281)
const ref = thread.emails?.[0];
const threadClient = isUnifiedView && ref?.sourceClientAccountId
? (useAuthStore.getState().getClientForAccount(ref.sourceClientAccountId) ?? client)
: client;
const threadAccountId = isUnifiedView ? ref?.sourceAccountId : undefined;
const emails = await threadClient.getThreadEmails(thread.threadId, threadAccountId);
// Re-stamp the source reference so conversation actions (reply/move/…)
// resolve to the right account; the fetched objects don't carry it.
if (isUnifiedView && ref) {
for (const e of emails) {
e.accountId = ref.accountId;
e.accountLabel = ref.accountLabel;
e.sourceClientAccountId = ref.sourceClientAccountId;
e.sourceAccountId = ref.sourceAccountId;
}
}
setConversationEmails(emails);
} catch (error) {
console.error('Failed to fetch thread emails:', error);
@@ -2278,6 +2576,7 @@ export default function Home() {
const handleConversationReply = async (email: Email) => {
selectEmail(email);
await prepareComposerQuoteHeader(email, 'reply');
startFreshComposerSession();
setComposerMode('reply');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -2286,6 +2585,7 @@ export default function Home() {
const handleConversationReplyAll = async (email: Email) => {
selectEmail(email);
await prepareComposerQuoteHeader(email, 'replyAll');
startFreshComposerSession();
setComposerMode('replyAll');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -2294,6 +2594,7 @@ export default function Home() {
const handleConversationForward = async (email: Email) => {
selectEmail(email);
await prepareComposerQuoteHeader(email, 'forward');
startFreshComposerSession();
setComposerMode('forward');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -2398,7 +2699,7 @@ export default function Home() {
),
inlineApp && "hidden"
)}
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 64 : sidebarWidth } : undefined}
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 48 : sidebarWidth } : undefined}
>
<ErrorBoundary fallback={SidebarErrorFallback}>
<Sidebar
@@ -2407,6 +2708,11 @@ export default function Home() {
selectedKeyword={selectedKeyword}
scheduledTotal={scheduledTotal}
showScheduledMailbox={delayedSendSupported}
showAllMailMailbox={showAllMailMailbox}
showCrossUnread={showCrossUnread}
showCrossStarred={showCrossStarred}
showCrossAll={showCrossAll}
crossUnreadCount={crossUnreadCount}
onMailboxSelect={handleMailboxSelect}
onTagSelect={handleTagSelect}
onUnreadFilterClick={handleUnreadFilterClick}
@@ -2421,6 +2727,7 @@ export default function Home() {
onImportEmail={handleImportEmailFromContextMenu}
onRefreshMailboxes={handleRefreshMailboxes}
onCompose={() => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) {
@@ -2454,17 +2761,19 @@ export default function Home() {
<div
className={cn(
"relative flex flex-col bg-background",
isHorizontalMailLayout ? "md:w-full md:h-auto" : "h-full border-r border-border",
isHorizontalMailLayout
? (shouldHideHorizontalViewerPane ? "md:w-full md:min-h-0" : "md:w-full md:h-auto")
: "h-full border-e border-border",
// Mobile: full width, hidden when viewing email
"max-md:flex-1 max-md:border-r-0 max-md:border-b-0",
"max-md:flex-1 max-md:border-e-0 max-md:border-b-0",
isMobile && activeView !== "list" && "max-md:hidden",
// Tablet/Desktop: fixed width with collapse animation
!isHorizontalMailLayout && (shouldHideViewerPane ? "md:flex-1 md:border-r-0" : "md:flex-shrink-0"),
!isHorizontalMailLayout && (shouldHideViewerPane ? "md:flex-1 md:border-e-0" : "md:flex-shrink-0"),
isHorizontalMailLayout && (shouldHideHorizontalViewerPane ? "md:flex-1" : "md:flex-shrink-0"),
isHorizontalMailLayout && !shouldHideHorizontalViewerPane && "md:shadow-[0_8px_12px_-6px_rgba(0,0,0,0.18)] dark:md:shadow-[0_8px_14px_-6px_rgba(0,0,0,0.55)]",
!isHorizontalMailLayout && "md:shadow-sm",
!isResizing && "transition-all duration-200 ease-out",
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-e-0"
)}
style={
isMobile
@@ -2481,8 +2790,8 @@ export default function Home() {
{/* Search Bar + Inline Advanced Filters */}
<div className="border-b border-border bg-background">
<div className="px-3 py-3">
<div className="flex items-center gap-1.5">
<div className="px-3 h-14 flex items-center">
<div className="flex items-center gap-1.5 w-full">
{/* Select / Select All toggle */}
<button
type="button"
@@ -2517,13 +2826,13 @@ export default function Home() {
)}
</button>
<form onSubmit={(e) => { e.preventDefault(); if (searchQuery.trim()) handleSearch(searchQuery); }} className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Search className="absolute start-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("sidebar.search_placeholder_hint")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn("pl-9 h-9", searchQuery && "pr-8")}
className={cn("ps-9 h-9", searchQuery && "pe-8")}
data-search-input
data-tour="search-input"
disabled={isUnifiedView || isScheduledView}
@@ -2533,7 +2842,7 @@ export default function Home() {
<button
type="button"
onClick={handleClearSearch}
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
className="absolute end-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
aria-label={t("sidebar.clear_search")}
>
<X className="w-4 h-4" />
@@ -2573,24 +2882,24 @@ export default function Home() {
icon={<Paperclip className="w-3.5 h-3.5" />}
label={t("advanced_search.has_attachment")}
value={searchFilters.hasAttachment}
onClick={() => { const next = searchFilters.hasAttachment === null ? true : searchFilters.hasAttachment === true ? false : null; setSearchFilters({ hasAttachment: next }); handleAdvancedSearch(); }}
onClick={() => { const next = searchFilters.hasAttachment === null ? true : searchFilters.hasAttachment ? false : null; setSearchFilters({ hasAttachment: next }); handleAdvancedSearch(); }}
/>
<ToggleChip
icon={<Star className="w-3.5 h-3.5" />}
label={t("advanced_search.starred")}
value={searchFilters.isStarred}
onClick={() => { const next = searchFilters.isStarred === null ? true : searchFilters.isStarred === true ? false : null; setSearchFilters({ isStarred: next }); handleAdvancedSearch(); }}
onClick={() => { const next = searchFilters.isStarred === null ? true : searchFilters.isStarred ? false : null; setSearchFilters({ isStarred: next }); handleAdvancedSearch(); }}
/>
<ToggleChip
icon={searchFilters.isUnread === false ? <MailOpen className="w-3.5 h-3.5" /> : <Mail className="w-3.5 h-3.5" />}
label={searchFilters.isUnread === false ? t("advanced_search.read") : t("advanced_search.unread")}
value={searchFilters.isUnread}
onClick={() => { const next = searchFilters.isUnread === null ? true : searchFilters.isUnread === true ? false : null; setSearchFilters({ isUnread: next }); handleAdvancedSearch(); }}
onClick={() => { const next = searchFilters.isUnread === null ? true : searchFilters.isUnread ? false : null; setSearchFilters({ isUnread: next }); handleAdvancedSearch(); }}
/>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => { clearSearchFilters(); setShowAdvancedFields(false); if (client) advancedSearch(client); }} className="h-7 px-2 text-xs text-muted-foreground">
<RotateCcw className="w-3 h-3 mr-1" />
<RotateCcw className="w-3 h-3 me-1" />
{t("advanced_search.clear")}
</Button>
</div>
@@ -2774,6 +3083,9 @@ export default function Home() {
await toggleStar(client, email.id);
}
}}
onTogglePinned={async (email) => {
await handleTogglePinned(email);
}}
onDelete={async (email) => {
await handleDelete(email);
}}
@@ -2805,13 +3117,14 @@ export default function Home() {
{/* Floating Compose Button */}
<Button
onClick={() => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
}}
className={cn(
"absolute z-40 rounded-full shadow-lg",
isMobile ? "bottom-4 right-4 h-14 w-14" : "bottom-4 right-4 h-12 w-12"
isMobile ? "bottom-4 end-4 h-14 w-14" : "bottom-4 end-4 h-12 w-12"
)}
aria-label={t('sidebar.compose')}
title={t('sidebar.compose_hint')}
@@ -2870,6 +3183,11 @@ export default function Home() {
<EmailComposer
key={composerSessionId}
mode={pendingDraft?.mode ?? composerMode}
composeFromAccountEmail={
useAccountStore
.getState()
.getAccountById(viewingAccountId ?? activeAccountId ?? '')?.email
}
replyTo={pendingDraft !== null ? pendingDraft.replyTo : (selectedEmail ? {
from: selectedEmail.from,
replyToAddresses: selectedEmail.replyTo,
@@ -2934,13 +3252,13 @@ export default function Home() {
setShowComposer(true);
if (isMobile) setActiveView('viewer');
}}
className="flex items-center gap-3 px-4 py-2.5 bg-primary/10 border-b border-primary/20 hover:bg-primary/15 transition-colors cursor-pointer w-full text-left"
className="flex items-center gap-3 px-4 py-2.5 bg-primary/10 border-b border-primary/20 hover:bg-primary/15 transition-colors cursor-pointer w-full text-start"
>
<PenLine className="w-4 h-4 text-primary shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-primary">{t('email_composer.continue_draft')}</span>
{pendingDraft.subject && (
<span className="text-xs text-muted-foreground ml-2 truncate">{pendingDraft.subject}</span>
<span className="text-xs text-muted-foreground ms-2 truncate">{pendingDraft.subject}</span>
)}
</div>
<X
@@ -2986,7 +3304,17 @@ export default function Home() {
onReply={handleReply}
onReplyAll={handleReplyAll}
onForward={handleForward}
onDelete={() => handleDelete()}
onDelete={() => {
// Deleting the open message returns to the list (Gmail-style),
// not the next email — unless the user turned the setting off.
// Deselect first so the store's remove-and-advance sees no
// selection and doesn't auto-open the next message.
const target = selectedEmail;
if (useSettingsStore.getState().returnToListAfterAction) {
handleMobileBack();
}
handleDelete(target);
}}
onArchive={() => handleArchive()}
onToggleStar={handleToggleStar}
onSetColorTag={handleSetColorTag}
@@ -2995,6 +3323,12 @@ export default function Home() {
onMarkAsRead={async (emailId, read) => {
if (client) {
await markAsRead(client, emailId, read);
// Marking the open message unread returns to the list
// (Gmail-style, gated on returnToListAfterAction). Staying
// in the reading pane would just re-mark it read on view.
if (!read && useSettingsStore.getState().returnToListAfterAction) {
handleMobileBack();
}
}
}}
onDownloadAttachment={handleDownloadAttachment}
@@ -3023,12 +3357,13 @@ export default function Home() {
}
}}
onCompose={() => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
}}
currentUserEmail={client?.getUsername()}
currentUserName={client?.getUsername()?.split("@")[0]}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined)}
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
onMoveToMailbox={async (mailboxId) => {
+2 -2
View File
@@ -139,7 +139,7 @@ export default function ProHome() {
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
const openTab = useProTabStore((s) => s.openTab);
const closeTab = useProTabStore((s) => s.closeTab);
const requestCloseTab = useProTabStore((s) => s.requestCloseTab);
const setActiveTab = useProTabStore((s) => s.setActiveTab);
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
@@ -354,7 +354,7 @@ export default function ProHome() {
activeMainTabId={activeMainTabId}
activeSplitTabId={activeSplitTabId}
onActivate={setActiveTab}
onClose={closeTab}
onClose={requestCloseTab}
onDragStateChange={setIsTabDragging}
/>
+131 -38
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef, useMemo } from 'react';
import { useState, useEffect, useRef, useMemo, useSyncExternalStore } from 'react';
import { useRouter } from '@/i18n/navigation';
import { useTranslations, useMessages } from 'next-intl';
import {
@@ -21,7 +21,6 @@ import {
Tags,
HardDrive,
BookUser,
KeyRound,
PanelLeftClose,
Bell,
Puzzle,
@@ -33,6 +32,7 @@ import {
Languages,
Info,
Bug,
SwatchBook,
Download,
X,
type LucideIcon,
@@ -62,17 +62,19 @@ import { AccountSecuritySettings } from '@/components/settings/account-security-
import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { DownloadsSettings } from '@/components/settings/downloads-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings';
import { SmimeSettings } from '@/components/settings/smime-settings';
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
import { NotificationSettings } from '@/components/settings/notification-settings';
import { ThemesSettings } from '@/components/settings/themes-settings';
import { PluginsSettings } from '@/components/settings/plugins-settings';
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { usePluginStore } from '@/stores/plugin-store';
import { useThemeStore } from '@/stores/theme-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import { useIsDesktop } from '@/hooks/use-media-query';
import { NavigationRail } from '@/components/layout/navigation-rail';
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
@@ -100,7 +102,6 @@ type Tab =
| 'folders'
| 'keywords'
| 'security'
| 'encryption'
| 'content_senders'
| 'calendar'
| 'contacts'
@@ -114,8 +115,14 @@ type Tab =
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
// A plugin that exposes a `settings-section` slot gets its own first-class
// Settings entry, keyed `plugin:<id>`, so its UI (e.g. S/MIME key import) is
// discoverable as a menu point rather than buried inside another panel.
type PluginTabId = `plugin:${string}`;
type SettingsTabId = Tab | PluginTabId;
interface TabDef {
id: Tab;
id: SettingsTabId;
label: string;
icon: LucideIcon;
group: TabGroup;
@@ -137,7 +144,6 @@ const tabIcons: Record<Tab, LucideIcon> = {
folders: FolderOpen,
keywords: Tags,
security: Shield,
encryption: KeyRound,
content_senders: EyeOff,
calendar: Calendar,
contacts: BookUser,
@@ -145,7 +151,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
protocol_handlers: LinkIcon,
sidebar_apps: PanelLeftClose,
about_data: Info,
themes: Palette,
themes: SwatchBook,
plugins: Puzzle,
debug: Bug,
};
@@ -181,6 +187,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
'settings.appearance.hide_account_switcher',
'settings.appearance.show_rail_account_list',
'settings.appearance.unified_mailbox',
'settings.appearance.all_mail',
'settings.appearance.colorful_sidebar_icons',
'settings.email_behavior.mail_layout',
],
@@ -197,11 +204,11 @@ const tabSearchPaths: Record<Tab, string[]> = {
'settings.email_behavior.hover_actions',
'settings.email_behavior.permanently_delete_junk',
'settings.email_behavior.show_preview',
'settings.email_behavior.plain_text_mode',
],
composing: [
'settings.email_behavior.attachment_reminder',
'settings.email_behavior.auto_select_reply_identity',
'settings.email_behavior.plain_text_mode',
'settings.email_behavior.default_mail_program',
'settings.email_behavior.signature_position',
'settings.email_behavior.sub_address_delimiter',
@@ -214,7 +221,6 @@ const tabSearchPaths: Record<Tab, string[]> = {
folders: ['settings.folders'],
keywords: ['settings.keywords'],
security: ['settings.security'],
encryption: ['smime'],
content_senders: [
'settings.email_behavior.always_light_mode',
'settings.email_behavior.external_content',
@@ -249,7 +255,6 @@ const tabKeywords: Record<Tab, string> = {
folders: 'mailbox subscribe',
keywords: 'tags labels colors',
security: 'password 2fa two-factor passkey app password mfa',
encryption: 's/mime smime certificate pgp gpg',
content_senders: 'block sender remote images privacy tracking',
calendar: 'event schedule appointment meeting timezone',
contacts: 'address book contact',
@@ -333,8 +338,16 @@ const LEGACY_TAB_MAP: Record<string, Tab> = {
advanced: 'about_data',
};
function readPersistedTab(): Tab {
function readPersistedTab(): SettingsTabId {
try {
// One-shot deep link from the sidebar section gears (Folders / Tags).
// Used only as the initial tab and intentionally NOT written to
// 'settings-active-tab', so a gear click never becomes the persisted
// default that the regular Settings button lands on. Cleared on mount.
const deepLink = sessionStorage.getItem('settings-deep-link-tab');
if (deepLink) {
return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as SettingsTabId;
}
const saved = localStorage.getItem('settings-active-tab');
if (!saved) return 'appearance';
if (saved in LEGACY_TAB_MAP) {
@@ -342,7 +355,7 @@ function readPersistedTab(): Tab {
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
return migrated;
}
return saved as Tab;
return saved as SettingsTabId;
} catch {
return 'appearance';
}
@@ -359,10 +372,23 @@ export default function SettingsPage() {
const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig();
const { isFeatureEnabled } = usePolicyStore();
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
const [activeTab, setActiveTab] = useState<SettingsTabId>(readPersistedTab);
// Active plugins that expose a `settings-section` slot — each becomes its own
// Settings menu entry. Referentially stable per registry mutation, so it is
// safe to feed useSyncExternalStore directly.
const pluginSettingsOffers = useSyncExternalStore(
pluginRegistrySubscribe,
() => pluginOffersForSlot('settings-section'),
() => pluginOffersForSlot('settings-section'),
);
// Consume the one-shot deep-link key so a section gear only steers this one
// open, never the persisted default for future Settings-button clicks.
useEffect(() => {
try { sessionStorage.removeItem('settings-deep-link-tab'); } catch { /* ignore */ }
}, []);
const [mobileShowContent, setMobileShowContent] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string; pluginId?: string } | null>(null);
const [pendingHighlight, setPendingHighlight] = useState<{ tab: SettingsTabId; label: string; pluginId?: string } | null>(null);
const isDesktop = useIsDesktop();
const messages = useMessages() as Record<string, unknown>;
@@ -371,6 +397,12 @@ export default function SettingsPage() {
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
const proInterface = useSettingsStore((s) => s.proInterface);
// When set, the settings panel is scoped to a shared/group account: a reduced
// tab list and a "Managing: <name>" header. null = the user's own account.
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const managedAccount = useManagedAccountStore((s) => s.managedAccount);
const clearManagedAccount = useManagedAccountStore((s) => s.clear);
// Build a per-tab haystack for fulltext search and a list of sub-results
// (individual settings) per tab. Sub-results come from translation entries
// that have a `label`/`title` field, plus dynamic content (installed
@@ -474,6 +506,10 @@ export default function SettingsPage() {
return () => window.removeEventListener('settings-tab-change', handler);
}, []);
// Leaving the settings panel drops any shared-account scope so it never
// leaks into the next visit or another session.
useEffect(() => () => clearManagedAccount(), [clearManagedAccount]);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
@@ -582,6 +618,7 @@ export default function SettingsPage() {
// Appearance
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
{ id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'appearance' as TabGroup }] : []),
// Mail
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
@@ -596,28 +633,50 @@ export default function SettingsPage() {
// Privacy & Security
...(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' },
// Apps
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
...(supportsCalendar && isFeatureEnabled('calendarEnabled') ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
// Plugin-contributed settings pages: one entry per active plugin that
// offers a `settings-section` slot (e.g. S/MIME key & certificate manager).
...pluginSettingsOffers.map((offer): TabDef => ({
id: `plugin:${offer.pluginId}` as PluginTabId,
label: getActivePlugin(offer.pluginId)?.plugin.name ?? offer.pluginId,
icon: Puzzle,
group: 'apps',
})),
// Advanced
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup }] : []),
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup }] : []),
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
];
// In scoped (shared-account) mode, restrict to the account-relevant tabs the
// account actually advertises. Folders is intentionally excluded (mailbox CRUD
// is hardwired to the active account). Gated on both the per-account
// capability and the session-level support/feature flags.
const scopedTabIds: Tab[] = managedAccount
? ([
managedAccount.capabilities.sieve && supportsSieve ? 'filters' : null,
managedAccount.capabilities.mail && supportsVacation ? 'vacation' : null,
managedAccount.capabilities.calendars && supportsCalendar && isFeatureEnabled('calendarEnabled') ? 'calendar' : null,
managedAccount.capabilities.contacts && isFeatureEnabled('contactsEnabled') ? 'contacts' : null,
].filter(Boolean) as Tab[])
: [];
const visibleTabs = managedAccountId
? tabs.filter((tab) => scopedTabIds.includes(tab.id as Tab))
: tabs;
// Group tabs by category
const groupedTabs = tabGroupOrder
.map((group) => ({
group,
label: t(`tab_groups.${group}`),
items: tabs.filter((tab) => tab.group === group),
items: visibleTabs.filter((tab) => tab.group === group),
}))
.filter((g) => g.items.length > 0);
@@ -625,12 +684,12 @@ export default function SettingsPage() {
const matchesQuery = (tab: TabDef) => {
if (!trimmedQuery) return true;
if (tab.label.toLowerCase().includes(trimmedQuery)) return true;
return tabSearchHaystacks[tab.id]?.includes(trimmedQuery) ?? false;
return tabSearchHaystacks[tab.id as Tab]?.includes(trimmedQuery) ?? false;
};
const subResultsForTab = (tabId: Tab): SubResult[] => {
const subResultsForTab = (tabId: SettingsTabId): SubResult[] => {
if (!trimmedQuery) return [];
const list = tabSubResults[tabId] ?? [];
const list = tabSubResults[tabId as Tab] ?? [];
return list
.filter((r) =>
r.label.toLowerCase().includes(trimmedQuery) ||
@@ -645,11 +704,15 @@ export default function SettingsPage() {
.filter((g) => g.items.length > 0)
: groupedTabs;
// If active tab is not in the visible list (e.g., feature disabled), fall back.
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
// If active tab is not in the visible list (e.g., feature disabled, or scoped
// mode hides it), fall back. In scoped mode fall back to the first scoped tab;
// otherwise the usual 'appearance' default.
const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab);
const effectiveActiveTab: SettingsTabId = isActiveVisible
? activeTab
: (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance');
const handleTabSelect = (tabId: Tab) => {
const handleTabSelect = (tabId: SettingsTabId) => {
setActiveTab(tabId);
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
if (!isDesktop) {
@@ -657,15 +720,31 @@ export default function SettingsPage() {
}
};
const handleSubResultSelect = (tabId: Tab, sub: SubResult) => {
const handleSubResultSelect = (tabId: SettingsTabId, sub: SubResult) => {
handleTabSelect(tabId);
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
};
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
const renderTabContent = () => (
<>
{managedAccountId && managedAccount && (
<button
type="button"
onClick={() => {
clearManagedAccount();
handleTabSelect('account');
}}
className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-start transition-colors"
>
<ArrowLeft className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-muted-foreground">{t('scoped.back')}</span>
<span className="ms-auto text-sm font-medium truncate">
{t('scoped.managing', { name: managedAccount.name })}
</span>
</button>
)}
{effectiveActiveTab === 'account' && <AccountSettings />}
{effectiveActiveTab === 'language' && <LanguageSettings />}
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
@@ -681,10 +760,17 @@ export default function SettingsPage() {
{effectiveActiveTab === 'folders' && <FolderSettings />}
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{effectiveActiveTab === 'calendar' && (
managedAccountId
? <CalendarManagementSettings />
: <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>
)}
{effectiveActiveTab === 'contacts' && (
managedAccountId
? <AddressBookManagementSettings />
: <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>
)}
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
@@ -692,6 +778,13 @@ export default function SettingsPage() {
{effectiveActiveTab === 'themes' && <ThemesSettings />}
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
{effectiveActiveTab === 'debug' && <DebugSettings />}
{effectiveActiveTab.startsWith('plugin:') && (
<PluginIframeSlot
key={effectiveActiveTab}
pluginId={effectiveActiveTab.slice('plugin:'.length)}
slot="settings-section"
/>
)}
</>
);
@@ -758,7 +851,7 @@ export default function SettingsPage() {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('search_placeholder')}
className="pl-9 pr-9 h-10"
className="ps-9 pe-9 h-10"
aria-label={t('search_placeholder')}
/>
{searchQuery && (
@@ -806,7 +899,7 @@ export default function SettingsPage() {
<button
key={`${tab.id}:${sub.label}`}
onClick={() => handleSubResultSelect(tab.id, sub)}
className="w-full flex items-center pl-12 pr-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-left"
className="w-full flex items-center ps-12 pe-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-start"
>
<span className="truncate">{sub.label}</span>
</button>
@@ -870,7 +963,7 @@ export default function SettingsPage() {
<>
<div
className={cn(
"border-r border-border bg-secondary flex flex-col",
"border-e border-border bg-secondary flex flex-col",
!isResizing && "transition-[width] duration-300"
)}
style={{ width: `${settingsSidebarWidth}px` }}
@@ -883,7 +976,7 @@ export default function SettingsPage() {
onClick={() => router.push('/')}
className="w-full justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
<ArrowLeft className="w-4 h-4 me-2" />
{t('back_to_mail')}
</Button>
</div>
@@ -898,7 +991,7 @@ export default function SettingsPage() {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('search_placeholder')}
className="pl-8 pr-8 h-9 text-sm"
className="ps-8 pe-8 h-9 text-sm"
aria-label={t('search_placeholder')}
/>
{searchQuery && (
@@ -933,9 +1026,9 @@ export default function SettingsPage() {
return (
<div key={tab.id}>
<button
onClick={() => setActiveTab(tab.id)}
onClick={() => handleTabSelect(tab.id)}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
effectiveActiveTab === tab.id
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
@@ -951,7 +1044,7 @@ export default function SettingsPage() {
<button
key={`${tab.id}:${sub.label}`}
onClick={() => handleSubResultSelect(tab.id, sub)}
className="w-full text-left pl-9 pr-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
className="w-full text-start ps-9 pe-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
>
<span className="truncate block">{sub.label}</span>
</button>
@@ -220,7 +220,7 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
Per-server OAuth (optional, overrides global)
</button>
{d.oauthExpanded && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 pl-4 border-l border-border">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 ps-4 border-s border-border">
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
<input
+317 -44
View File
@@ -1,8 +1,14 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react';
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
import {
BRANDING_OVERRIDE_KEYS,
parseDomainBranding,
type BrandingOverrideKey,
type DomainBrandingEntry,
} from '@/lib/admin/domain-branding';
interface ConfigEntry {
value?: unknown;
@@ -16,42 +22,67 @@ const IMAGE_FIELDS = [
{ key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
{ key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
{ key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
];
] as const;
const TEXT_FIELDS = [
{ key: 'loginCompanyName', label: 'Company Name' },
{ key: 'loginImprintUrl', label: 'Imprint URL' },
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' },
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
];
] as const;
const PWA_IMAGE_FIELDS = [
{ key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
];
{ key: 'pwaScreenshotMobileUrl', label: 'PWA Screenshot (Mobile)', accept: '.png,.jpg,.webp' },
{ key: 'pwaScreenshotDesktopUrl', label: 'PWA Screenshot (Desktop)', accept: '.png,.jpg,.webp' },
] as const;
const PWA_TEXT_FIELDS = [
{ key: 'appShortName', label: 'Short Name', placeholder: 'Shown on home screen (max ~12 chars)' },
{ key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' },
];
] as const;
const PWA_COLOR_FIELDS = [
{ key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' },
{ key: 'pwaBackgroundColor', label: 'Background Color', defaultValue: '#ffffff' },
];
] as const;
// Accepts exact hosts and one-level wildcards (e.g. *.example.com).
const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
// Tighter rule for uploads: wildcards can only point to externally-hosted
// URLs, since we'd have no concrete subdomain to serve a file from.
const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
export function BrandingTab() {
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
const [edits, setEdits] = useState<Record<string, unknown>>({});
const [edits, setEdits] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [selectedHost, setSelectedHost] = useState<string | null>(null);
const [addingHost, setAddingHost] = useState(false);
const [newHostInput, setNewHostInput] = useState('');
const [newHostError, setNewHostError] = useState<string | null>(null);
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
useEffect(() => {
fetchConfig();
}, []);
const domainEntries = useMemo<DomainBrandingEntry[]>(
() => parseDomainBranding(config['domainBranding']?.value),
[config],
);
// Drop selection if the host disappeared from the config (e.g. concurrent edit).
useEffect(() => {
if (selectedHost && !domainEntries.some(e => e.host === selectedHost)) {
setSelectedHost(null);
setEdits({});
}
}, [domainEntries, selectedHost]);
async function fetchConfig() {
setLoading(true);
const res = await apiFetch('/api/admin/config');
@@ -59,29 +90,81 @@ export function BrandingTab() {
setLoading(false);
}
function selectedEntry(): DomainBrandingEntry | null {
if (!selectedHost) return null;
return domainEntries.find(e => e.host === selectedHost) ?? null;
}
function handleChange(key: string, value: string) {
setEdits(prev => ({ ...prev, [key]: value }));
setMessage(null);
}
function currentValue(key: string): string {
if (key in edits) return edits[key] as string;
if (key in edits) return edits[key];
if (selectedHost) {
const entry = selectedEntry();
return (entry?.[key as BrandingOverrideKey] as string | undefined) ?? '';
}
return (config[key]?.value as string) ?? '';
}
function isOverriddenInScope(key: string): boolean {
if (selectedHost) {
const entry = selectedEntry();
const v = entry?.[key as BrandingOverrideKey];
return typeof v === 'string' && v.length > 0;
}
return config[key]?.source === 'admin';
}
const isUploadedFile = (key: string): boolean => {
const val = currentValue(key);
return val.startsWith('/api/admin/branding/');
};
function buildUpdatedDomainBranding(merge: Record<string, string>): DomainBrandingEntry[] {
if (!selectedHost) return domainEntries;
const next = domainEntries.slice();
const idx = next.findIndex(e => e.host === selectedHost);
const base: DomainBrandingEntry =
idx === -1 ? { host: selectedHost } : { ...next[idx] };
const writable = base as unknown as Record<string, string | undefined>;
for (const [key, value] of Object.entries(merge)) {
if (!(BRANDING_OVERRIDE_KEYS as readonly string[]).includes(key)) continue;
if (typeof value === 'string' && value.length > 0) {
writable[key] = value;
} else {
delete writable[key];
}
}
if (idx === -1) next.push(base);
else next[idx] = base;
return next;
}
async function handleSave() {
if (Object.keys(edits).length === 0) return;
setSaving(true);
setMessage(null);
const payload = selectedHost
? { domainBranding: buildUpdatedDomainBranding(edits) }
: edits;
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits),
body: JSON.stringify(payload),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Branding updated. Changes visible on next page load.' });
setMessage({
type: 'success',
text: selectedHost
? `Branding for ${selectedHost} updated. Changes visible on next page load.`
: 'Branding updated. Changes visible on next page load.',
});
setEdits({});
await fetchConfig();
} else {
@@ -92,12 +175,20 @@ export function BrandingTab() {
}
async function handleUpload(slot: string, file: File) {
if (selectedHost && !EXACT_HOST_RE.test(selectedHost)) {
setMessage({
type: 'error',
text: 'Wildcard hosts cannot upload files. Enter a URL instead.',
});
return;
}
setUploading(slot);
setMessage(null);
const formData = new FormData();
formData.append('file', file);
formData.append('slot', slot);
if (selectedHost) formData.append('host', selectedHost);
const res = await apiFetch('/api/admin/branding', {
method: 'POST',
@@ -112,10 +203,9 @@ export function BrandingTab() {
delete next[slot];
return next;
});
setConfig(prev => ({
...prev,
[slot]: { value: data.url, source: 'admin' },
}));
// Refresh from server so domainBranding entries reflect the upload.
await fetchConfig();
void data;
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Upload failed' });
@@ -126,10 +216,13 @@ export function BrandingTab() {
async function handleDeleteUpload(slot: string) {
setMessage(null);
const body: { slot: string; host?: string } = { slot };
if (selectedHost) body.host = selectedHost;
const res = await apiFetch('/api/admin/branding', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slot }),
body: JSON.stringify(body),
});
if (res.ok) {
@@ -147,6 +240,25 @@ export function BrandingTab() {
}
async function handleRevert(key: string) {
if (selectedHost) {
// Domain scope: drop the field from the entry and PATCH the array.
const updated = buildUpdatedDomainBranding({ [key]: '' });
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domainBranding: updated }),
});
if (res.ok) {
setEdits(prev => {
const next = { ...prev };
delete next[key];
return next;
});
await fetchConfig();
}
return;
}
// Default scope: revert via DELETE /api/admin/config
const res = await apiFetch('/api/admin/config', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
@@ -162,12 +274,71 @@ export function BrandingTab() {
}
}
const isUploadedFile = (key: string): boolean => {
const val = currentValue(key);
return val.startsWith('/api/admin/branding/');
};
async function handleAddDomain() {
const host = newHostInput.trim().toLowerCase().replace(/\.+$/, '');
if (!host) {
setNewHostError('Enter a hostname');
return;
}
if (!HOST_RE.test(host)) {
setNewHostError('Invalid hostname. Use foo.example.com or *.example.com');
return;
}
if (domainEntries.some(e => e.host === host)) {
setNewHostError('A branding entry for this host already exists');
return;
}
setNewHostError(null);
const next: DomainBrandingEntry[] = [...domainEntries, { host }];
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domainBranding: next }),
});
if (res.ok) {
setNewHostInput('');
setAddingHost(false);
setSelectedHost(host);
setEdits({});
await fetchConfig();
} else {
const data = await res.json();
setNewHostError(data.error || 'Failed to add domain');
}
}
async function handleDeleteDomain() {
if (!selectedHost) return;
if (!confirm(`Remove branding entry for ${selectedHost}? Uploaded files for this domain will be left behind on disk.`)) {
return;
}
const next = domainEntries.filter(e => e.host !== selectedHost);
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domainBranding: next }),
});
if (res.ok) {
setSelectedHost(null);
setEdits({});
await fetchConfig();
setMessage({ type: 'success', text: `Removed branding entry for ${selectedHost}.` });
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to remove domain' });
}
}
function handleScopeChange(host: string | null) {
if (Object.keys(edits).length > 0 && !confirm('Discard unsaved changes?')) return;
setSelectedHost(host);
setEdits({});
setMessage(null);
}
const hasEdits = Object.keys(edits).length > 0;
const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost);
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
@@ -192,6 +363,102 @@ export function BrandingTab() {
)}
</div>
{/* Scope picker */}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30 flex items-center gap-2">
<Globe className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Scope</h2>
</div>
<div className="px-4 py-3 space-y-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => handleScopeChange(null)}
className={`h-8 px-3 rounded-md text-sm font-medium transition-colors ${
selectedHost === null
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground hover:bg-muted/70'
}`}
>
Default
</button>
{domainEntries.map(entry => (
<button
key={entry.host}
type="button"
onClick={() => handleScopeChange(entry.host)}
className={`h-8 px-3 rounded-md text-sm font-medium transition-colors ${
selectedHost === entry.host
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground hover:bg-muted/70'
}`}
>
{entry.host}
</button>
))}
{!addingHost && (
<button
type="button"
onClick={() => { setAddingHost(true); setNewHostError(null); }}
className="inline-flex items-center gap-1 h-8 px-3 rounded-md border border-dashed border-input text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Add domain
</button>
)}
</div>
{addingHost && (
<div className="flex flex-wrap items-center gap-2">
<input
type="text"
autoFocus
value={newHostInput}
onChange={(e) => { setNewHostInput(e.target.value); setNewHostError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') void handleAddDomain(); }}
placeholder="mail.example.com or *.example.com"
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<button
type="button"
onClick={handleAddDomain}
className="h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
Add
</button>
<button
type="button"
onClick={() => { setAddingHost(false); setNewHostInput(''); setNewHostError(null); }}
className="h-8 px-2.5 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
{newHostError && <span className="text-xs text-destructive">{newHostError}</span>}
</div>
)}
{selectedHost ? (
<div className="flex items-center justify-between gap-3 text-xs">
<p className="text-muted-foreground">
Editing overrides for <span className="font-mono text-foreground">{selectedHost}</span>.
Unset fields fall back to the Default values.
{wildcardScope && ' Uploads are disabled for wildcard hosts; enter a URL instead.'}
</p>
<button
type="button"
onClick={handleDeleteDomain}
className="inline-flex items-center gap-1 text-destructive hover:underline whitespace-nowrap"
>
<X className="w-3.5 h-3.5" />
Remove domain
</button>
</div>
) : (
<p className="text-xs text-muted-foreground">
Editing the Default branding. Add a domain to override branding when the webmail is served on a specific hostname.
</p>
)}
</div>
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
@@ -209,9 +476,9 @@ export function BrandingTab() {
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && (
{isOverriddenInScope(field.key) && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
{isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
@@ -220,7 +487,7 @@ export function BrandingTab() {
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder="Enter URL or upload a file"
placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<input
@@ -236,9 +503,9 @@ export function BrandingTab() {
/>
<button
onClick={() => fileInputRefs.current[field.key]?.click()}
disabled={uploading === field.key}
disabled={uploading === field.key || wildcardScope}
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
title="Upload file"
title={wildcardScope ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
>
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
</button>
@@ -251,7 +518,7 @@ export function BrandingTab() {
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
{isOverriddenInScope(field.key) && !isUploadedFile(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
@@ -263,7 +530,7 @@ export function BrandingTab() {
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
<img
src={currentValue(field.key)}
src={withBasePath(currentValue(field.key))}
alt={field.label}
className="max-h-6 max-w-[200px] object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
@@ -287,9 +554,9 @@ export function BrandingTab() {
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && (
{isOverriddenInScope(field.key) && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
{isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
@@ -298,7 +565,7 @@ export function BrandingTab() {
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder="Enter URL or upload a file"
placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<input
@@ -314,9 +581,9 @@ export function BrandingTab() {
/>
<button
onClick={() => fileInputRefs.current[field.key]?.click()}
disabled={uploading === field.key}
disabled={uploading === field.key || wildcardScope}
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
title="Upload file"
title={wildcardScope ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
>
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
</button>
@@ -329,7 +596,7 @@ export function BrandingTab() {
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
{isOverriddenInScope(field.key) && !isUploadedFile(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
@@ -341,7 +608,7 @@ export function BrandingTab() {
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
<img
src={currentValue(field.key)}
src={withBasePath(currentValue(field.key))}
alt={field.label}
className="max-h-6 max-w-[200px] object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
@@ -355,8 +622,10 @@ export function BrandingTab() {
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
{isOverriddenInScope(field.key) && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
@@ -367,7 +636,7 @@ export function BrandingTab() {
placeholder={field.placeholder}
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{config[field.key]?.source === 'admin' && (
{isOverriddenInScope(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
@@ -381,8 +650,10 @@ export function BrandingTab() {
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
{isOverriddenInScope(field.key) && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
@@ -400,7 +671,7 @@ export function BrandingTab() {
placeholder={field.defaultValue}
className="h-8 w-full sm:w-32 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{config[field.key]?.source === 'admin' && (
{isOverriddenInScope(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
@@ -421,8 +692,10 @@ export function BrandingTab() {
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
{isOverriddenInScope(field.key) && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
@@ -433,7 +706,7 @@ export function BrandingTab() {
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{config[field.key]?.source === 'admin' && (
{isOverriddenInScope(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
-20
View File
@@ -32,7 +32,6 @@ export function DashboardTab() {
const [themeCount, setThemeCount] = useState(0);
const [policyRuleCount, setPolicyRuleCount] = useState(0);
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
useEffect(() => {
fetchDashboardData();
@@ -82,15 +81,6 @@ export function DashboardTab() {
}
}
if (configData?.jmapServerUrl) {
try {
const jmapRes = await apiFetch('/api/config');
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
} catch {
setJmapHealth('error');
}
}
const w: string[] = [];
if (adminConfigRes.ok) {
const sources = await adminConfigRes.json();
@@ -128,16 +118,6 @@ export function DashboardTab() {
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
<span className="text-sm text-foreground">{jmapHostname}</span>
</SettingItem>
<SettingItem label="JMAP Connection">
<span className={`inline-flex items-center gap-1.5 text-sm font-medium ${
jmapHealth === 'ok' ? 'text-green-600 dark:text-green-400' : jmapHealth === 'error' ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground'
}`}>
<span className={`w-2 h-2 rounded-full ${
jmapHealth === 'ok' ? 'bg-green-500' : jmapHealth === 'error' ? 'bg-red-500' : 'bg-muted-foreground/40'
}`} />
{jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : 'Unknown'}
</span>
</SettingItem>
<SettingItem label="Last Login">
<span className="text-sm text-foreground">
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
+4 -4
View File
@@ -96,10 +96,10 @@ export function LogsTab() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
<th className="text-start px-4 py-2 font-medium text-muted-foreground">Details</th>
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
+2 -2
View File
@@ -171,7 +171,7 @@ export function MarketplaceTab() {
placeholder="Search extensions..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
className="w-full h-9 ps-9 pe-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
/>
</div>
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
@@ -210,7 +210,7 @@ export function MarketplaceTab() {
{loading && !error && (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Searching extensions...</span>
<span className="ms-2 text-sm text-muted-foreground">Searching extensions...</span>
</div>
)}
@@ -155,7 +155,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
<Loader2 className="w-4 h-4 animate-spin me-2" />
Loading...
</div>
);
@@ -217,7 +217,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
<div key={key}>
<label className="text-sm font-medium text-foreground block mb-1">
{field.label}
{field.required && <span className="text-destructive ml-0.5">*</span>}
{field.required && <span className="text-destructive ms-0.5">*</span>}
</label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
@@ -250,7 +250,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
value={formValues[key] ?? ''}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
className="w-full h-9 px-3 pe-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
/>
<button
type="button"
+45
View File
@@ -13,6 +13,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
calendarEnabled: { label: 'Calendar', description: 'Enable calendar features and views' },
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
@@ -21,6 +22,10 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
allMailViewEnabled: { label: 'All Mail View', description: 'Show a virtual "All Mail" folder that merges messages from across an accounts folders into one list. Users choose which folders are included. Requires the per-user toggle in Settings → Appearance.' },
crossUnreadViewEnabled: { label: 'All Accounts: Unread', description: 'Allow an "All unread" entry in the All accounts section that lists unread mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
crossStarredViewEnabled: { label: 'All Accounts: Starred', description: 'Allow an "All starred" entry in the All accounts section that lists flagged/starred mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
crossAllViewEnabled: { label: 'All Accounts: All Mail', description: 'Allow an "All mail" entry in the All accounts section that lists all mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
};
const RESTRICTABLE_SETTINGS = [
@@ -74,6 +79,18 @@ export function PolicyTab() {
setMessage(null);
}
function setPushRelayUrl(value: string) {
setPolicy(prev => ({ ...prev, pushRelayUrl: value }));
setDirty(true);
setMessage(null);
}
function togglePushRelayLocked() {
setPolicy(prev => ({ ...prev, pushRelayUrlLocked: !prev.pushRelayUrlLocked }));
setDirty(true);
setMessage(null);
}
function toggleLocked(settingKey: string) {
setPolicy(prev => {
const existing = prev.restrictions[settingKey] || {};
@@ -183,6 +200,34 @@ export function PolicyTab() {
</div>
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Push Relay</h2>
<p className="text-xs text-muted-foreground mt-0.5">Override the Web Push relay URL shown in user notification settings. Leave empty to use the built-in default.</p>
</div>
<div className="px-4 py-3 space-y-3">
<input
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
value={policy.pushRelayUrl ?? ''}
onChange={(e) => setPushRelayUrl(e.target.value)}
placeholder="https://notifications.relay.example.com"
className="w-full rounded border border-input bg-background px-3 py-2 text-sm"
/>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={!!policy.pushRelayUrlLocked}
onChange={togglePushRelayLocked}
className="rounded border-input"
/>
<Lock className="w-3 h-3" /> Lock - users cannot change this URL
</label>
</div>
</div>
{categories.map(category => (
<div key={category} className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
+3 -2
View File
@@ -117,7 +117,7 @@ export function SettingsTab() {
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
{!!currentValue('allowCustomJmapEndpoint') && (
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-s-2 border-amber-400 dark:border-amber-600">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
</p>
@@ -125,6 +125,7 @@ export function SettingsTab() {
)}
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Search Engine Indexing" description="Allow search engines to index this webmail. Off (the default) sends noindex/nofollow in the page head, recommended for private deployments." configKey="searchEngineIndexing" value={currentValue('searchEngineIndexing') as boolean} source={config.searchEngineIndexing?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
<SettingsSection title="JMAP Servers (multi-server)">
@@ -144,7 +145,7 @@ export function SettingsTab() {
onRevert={() => handleRevert('jmapServers')}
/>
{Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && (
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-s-2 border-amber-400 dark:border-amber-600">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
<strong>CORS warning:</strong> Each JMAP server must allow this webmail's origin in its <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or browser requests will be blocked.
</p>
+6 -5
View File
@@ -118,9 +118,10 @@ export function TelemetryTab() {
<header className="space-y-2">
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
<p className="text-sm text-muted-foreground">
Bulwark sends one anonymous heartbeat per day so we can see how many instances are
running, on what platforms, and which features they use. <strong>Enabled by default</strong>;
one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '}
Bulwark can send one anonymous heartbeat per day so we can see how many instances are
running, on what platforms, and which features they use. It&apos;s <strong>off by
default</strong>; one click below enables it and helps us make the product better. No
email addresses, no hostnames, no IPs are sent.{' '}
<a
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
target="_blank"
@@ -138,8 +139,8 @@ export function TelemetryTab() {
<div className="font-medium">Status</div>
<div className="text-sm text-muted-foreground">
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
{status.consent === 'off' && 'Heartbeats are off.'}
{status.consent === 'on' && 'Heartbeats are enabled. Thanks for helping us improve!'}
{status.consent === 'off' && 'Heartbeats are off (default).'}
{envOverridden && (
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
)}
+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 { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
const BUILTIN_THEME_OPTIONS = [
{ id: 'builtin-nord', name: 'Nord' },
{ id: 'builtin-catppuccin', name: 'Catppuccin' },
{ id: 'builtin-solarized', name: 'Solarized' },
];
// Derive from the single source of truth so newly added built-in themes show
// up here automatically (was previously a hardcoded subset — see #496).
const BUILTIN_THEME_OPTIONS = BUILTIN_THEMES.map(t => ({ id: t.id, name: t.name }));
interface ThemeEntry {
id: string;
+1 -1
View File
@@ -65,7 +65,7 @@ export default function ChangePasswordPage() {
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
required
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="w-full h-9 ps-9 pe-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
autoComplete="current-password"
/>
</div>
+12 -12
View File
@@ -212,7 +212,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
type="button"
onClick={handleClick}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
active
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
@@ -248,7 +248,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<Link
href="/admin/change-password"
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
pathname === '/admin/change-password'
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
@@ -263,7 +263,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
)}
<button
onClick={handleLogout}
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
className="w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
>
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
Sign out
@@ -275,7 +275,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return (
<div className="min-h-screen flex bg-background">
{/* Slim webmail nav rail (desktop only) */}
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-e border-border sticky top-0 h-screen shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
) : (
@@ -326,12 +326,12 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
</nav>
{/* Admin Sidebar (desktop only) */}
<aside className="hidden md:flex w-60 border-r border-border bg-secondary flex-col sticky top-0 h-screen">
<aside className="hidden md:flex w-60 border-e border-border bg-secondary flex-col sticky top-0 h-screen">
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
<Shield className="w-5 h-5 text-primary me-2" />
)}
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
</div>
@@ -350,7 +350,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{/* Mobile drawer */}
<aside
className={cn(
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-r border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-e border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
)}
aria-label="Admin navigation"
@@ -359,9 +359,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
<div className="flex items-center min-w-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
<Shield className="w-5 h-5 text-primary me-2" />
)}
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
</div>
@@ -391,9 +391,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
</button>
<div className="flex items-center min-w-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
<Shield className="w-5 h-5 text-primary me-2" />
)}
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
</div>
+3 -3
View File
@@ -186,7 +186,7 @@ export default function MarketplacePreviewPage() {
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
<Loader2 className="w-4 h-4 animate-spin me-2" />
Loading...
</div>
);
@@ -512,7 +512,7 @@ export default function MarketplacePreviewPage() {
<section className="border border-border rounded-lg">
<button
onClick={() => setShowManifest(v => !v)}
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-start hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-2">
<FileCode className="w-4 h-4 text-muted-foreground" />
@@ -532,7 +532,7 @@ export default function MarketplacePreviewPage() {
<section className="border border-border rounded-lg">
<button
onClick={() => setShowSource(v => !v)}
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-start hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-2">
<FileCode className="w-4 h-4 text-muted-foreground" />
+1 -1
View File
@@ -44,7 +44,7 @@ export default function GlobalError({
onClick={reset}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<RefreshCw className="w-4 h-4 mr-2" />
<RefreshCw className="w-4 h-4 me-2" />
Try again
</button>
</div>
+47 -7
View File
@@ -1,13 +1,31 @@
import type { Metadata, Viewport } from "next";
import { getLocaleDirection } from "@/i18n/direction";
import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import { getLocale } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { getLocale, getTranslations } from "next-intl/server";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import { configManager } from "@/lib/admin/config-manager";
import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
} from "@/lib/admin/domain-branding";
import { withBasePath } from "@/lib/browser-navigation";
import { locales } from "@/i18n/routing";
import "../globals.css";
// This layout renders <html> and sits ABOVE the [locale] segment, so
// next-intl's getLocale() returns the default locale here - emitting
// <html lang="en"> on e.g. /de pages, which makes browsers offer to
// "translate this page". Recover the active locale from the request pathname
// (exposed by proxy.ts as x-pathname), falling back to getLocale() (cookie /
// Accept-Language) when the path carries no locale segment.
async function resolveRequestLocale(): Promise<string> {
const pathname = (await headers()).get("x-pathname") || "";
const seg = pathname.split("/").find((s) => (locales as readonly string[]).includes(s));
return seg ?? (await getLocale());
}
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
@@ -26,11 +44,34 @@ export const viewport: Viewport = {
export async function generateMetadata(): Promise<Metadata> {
await configManager.ensureLoaded();
const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
// The <head> favicon must honor per-domain branding, exactly like
// /api/config, app/manifest.ts, and /api/pwa-icon already do. Resolve the
// request host and prefer its override; fall back to the global
// admin/env/default value when the host has no favicon override (#585).
const host = pickRequestHost(await headers());
const domainOverride = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
).faviconUrl;
const faviconUrl =
domainOverride && domainOverride.length > 0
? domainOverride
: configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
// Localize the <head> description to match the UI language; a hardcoded
// English description is another signal that makes Chrome offer to
// "translate this page". Resolve the locale from the request path, since this
// layout is above the [locale] segment (see resolveRequestLocale).
const locale = await resolveRequestLocale();
const t = await getTranslations({ locale });
return {
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
description: "Minimalist webmail client using JMAP protocol",
description: t("meta_description"),
// A private webmail should not be indexed by search engines. This is opt-in
// via Settings -> General; the default (false) emits noindex/nofollow.
robots: configManager.get<boolean>("searchEngineIndexing", false)
? { index: true, follow: true }
: { index: false, follow: false },
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
@@ -48,12 +89,12 @@ export default async function RootLayout({
}: {
children: React.ReactNode;
}) {
const locale = await getLocale();
const locale = await resolveRequestLocale();
const nonce = (await headers()).get("x-nonce") ?? "";
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
return (
<html lang={locale} suppressHydrationWarning>
<html lang={locale} dir={getLocaleDirection(locale)} suppressHydrationWarning>
<head>
<meta name="theme-color" content="#ffffff" />
<meta name="mobile-web-app-capable" content="yes" />
@@ -92,7 +133,6 @@ export default async function RootLayout({
>
<ServiceWorkerRegistration />
{children}
<PWAInstallPrompt />
</body>
</html>
);
+21 -6
View File
@@ -38,6 +38,7 @@ interface WizardConfig {
// Security
sessionSecret: string;
settingsSyncEnabled: boolean;
telemetryEnabled: boolean;
// Logging
logFormat: 'text' | 'json';
logLevel: 'error' | 'warn' | 'info' | 'debug';
@@ -66,6 +67,7 @@ const EMPTY_CONFIG: WizardConfig = {
oauthIssuerUrl: '',
sessionSecret: '',
settingsSyncEnabled: true,
telemetryEnabled: false,
logFormat: 'text',
logLevel: 'info',
faviconUrl: '',
@@ -778,7 +780,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
</p>
</div>
</div>
<label className="mt-3 ml-[3.25rem] flex items-center gap-2 cursor-pointer">
<label className="mt-3 ms-[3.25rem] flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={confirmedNonJmap}
@@ -868,7 +870,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
)}
{hasRowErrors && (
<ul className="text-xs text-destructive list-disc pl-5 space-y-0.5">
<ul className="text-xs text-destructive list-disc ps-5 space-y-0.5">
{rowErrors.map((err, i) => (
<li key={i}>{err}</li>
))}
@@ -1002,8 +1004,11 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
e.preventDefault();
setSubmitting(true);
try {
const values: Partial<WizardConfig> = {
// telemetryConsent is persisted to the telemetry state file by the API,
// not to admin config - see app/api/setup/step/route.ts.
const values: Record<string, unknown> = {
settingsSyncEnabled: config.settingsSyncEnabled,
telemetryConsent: config.telemetryEnabled ? 'on' : 'off',
};
if (config.sessionSecret) values.sessionSecret = config.sessionSecret;
await onNext('security', values);
@@ -1068,6 +1073,15 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
hint="Stores user preferences server-side, encrypted with the session secret."
disabled={!config.sessionSecret}
/>
<div className="rounded-md border border-border bg-muted/20 p-3">
<Toggle
checked={config.telemetryEnabled}
onChange={(v) => setConfig({ ...config, telemetryEnabled: v })}
label="Send anonymous usage stats to help improve Bulwark"
hint="Off by default. One anonymous heartbeat per day with version, platform, and which features are enabled - never email addresses, hostnames, or IPs. You can change this anytime in admin settings."
/>
</div>
<Footer>
<SecondaryButton onClick={onBack}>Back</SecondaryButton>
<PrimaryButton type="submit" disabled={submitting}>
@@ -1370,7 +1384,7 @@ function BrandingAsset({
</div>
{showUrlField && (
<div className="mt-3 pl-[4.75rem]">
<div className="mt-3 ps-[4.75rem]">
<Input
value={value}
onChange={onChange}
@@ -1380,7 +1394,7 @@ function BrandingAsset({
)}
{uploadError && (
<p className="mt-2 pl-[4.75rem] text-xs text-destructive">{uploadError}</p>
<p className="mt-2 ps-[4.75rem] text-xs text-destructive">{uploadError}</p>
)}
</div>
);
@@ -1480,6 +1494,7 @@ function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack
: 'Off'
}
/>
<SummaryRow label="Anonymous telemetry" value={config.telemetryEnabled ? 'On' : 'Off'} />
</SummaryGroup>
<SummaryGroup icon={<FileText className="w-4 h-4" />} title="Logging">
@@ -1586,7 +1601,7 @@ function SummaryRow({ label, value, mono }: { label: string; value: string; mono
return (
<div className="flex justify-between items-baseline gap-3 text-sm">
<span className="text-muted-foreground shrink-0">{label}</span>
<span className={'text-foreground text-right truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
<span className={'text-foreground text-end truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
{value || <span className="text-muted-foreground italic">-</span>}
</span>
</div>
+9 -15
View File
@@ -1,17 +1,14 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import { Geist, Geist_Mono } from 'next/font/google';
import '../globals.css';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
});
// The plugin sandbox iframe runs with an opaque origin (the `sandbox`
// attribute in production excludes `allow-same-origin` for isolation). Any
// asset request from this layout - bundled fonts, globals.css, etc. - is then
// cross-origin from the "null" origin to the host origin and gets blocked
// (fonts in particular require CORS). So this layout is intentionally minimal:
// no font imports, no CSS imports. Plugins ship their own styles, and both the
// plugin bundle and all host API calls travel over the postMessage RPC bridge,
// so the sandbox never fetches same-origin assets itself.
export const metadata: Metadata = {
title: 'Plugin sandbox',
@@ -21,10 +18,7 @@ export const metadata: Metadata = {
export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
style={{ margin: 0, padding: 0, background: 'transparent' }}
>
<body style={{ margin: 0, padding: 0, background: 'transparent' }}>
{children}
</body>
</html>
@@ -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 />;
}
+159 -32
View File
@@ -3,8 +3,13 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { configManager } from '@/lib/admin/config-manager';
import { getConfigDir } from '@/lib/admin/paths';
import {
parseDomainBranding,
type DomainBrandingEntry,
type BrandingOverrideKey,
} from '@/lib/admin/domain-branding';
import { logger } from '@/lib/logger';
import { writeFile, unlink, mkdir } from 'node:fs/promises';
import { writeFile, unlink, mkdir, readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
@@ -21,27 +26,99 @@ const ALLOWED_MIME_TYPES = new Set([
'image/vnd.microsoft.icon',
]);
type UploadSlot = BrandingOverrideKey;
/** Slots that correspond to branding config keys */
const VALID_SLOTS = new Set([
const VALID_SLOTS = new Set<UploadSlot>([
'faviconUrl',
'pwaIconUrl',
'appLogoLightUrl',
'appLogoDarkUrl',
'loginLogoLightUrl',
'loginLogoDarkUrl',
'pwaScreenshotMobileUrl',
'pwaScreenshotDesktopUrl',
]);
const EXT_BY_MIME: Record<string, string> = {
'image/svg+xml': '.svg',
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'image/x-icon': '.ico',
'image/vnd.microsoft.icon': '.ico',
};
const POSSIBLE_EXTS = ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.ico'];
// Exact hostnames only (no wildcards): wildcards can't be uploaded against
// because we'd need a real subdomain to serve the file from.
const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
function sanitizeFilename(name: string): string {
// Strip directory traversal, keep only safe chars
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
}
function normalizeHost(raw: string): string {
return raw.trim().toLowerCase().replace(/\.+$/, '');
}
/** Filename used to store a per-host uploaded asset. */
function domainAssetName(host: string, slot: BrandingOverrideKey, ext: string): string {
return sanitizeFilename(`domain__${host}__${slot}${ext}`);
}
/** True if the file belongs to the given host+slot (any extension). */
function isDomainAssetFor(filename: string, host: string, slot: BrandingOverrideKey): boolean {
const prefix = sanitizeFilename(`domain__${host}__${slot}.`);
return filename.startsWith(prefix);
}
/** Merge a per-host update into the existing domainBranding array. */
function mergeDomainEntry(
current: DomainBrandingEntry[],
host: string,
patch: Partial<DomainBrandingEntry>,
): DomainBrandingEntry[] {
const next = current.slice();
const idx = next.findIndex(e => e.host === host);
if (idx === -1) {
next.push({ host, ...patch });
} else {
next[idx] = { ...next[idx], ...patch };
}
return next;
}
/** Remove keys from a host's entry. If the entry has nothing left besides
* `host`, drop it entirely. */
function clearDomainKeys(
current: DomainBrandingEntry[],
host: string,
keys: BrandingOverrideKey[],
): DomainBrandingEntry[] {
const idx = current.findIndex(e => e.host === host);
if (idx === -1) return current;
const entry = { ...current[idx] };
for (const key of keys) delete (entry as Record<string, unknown>)[key];
const next = current.slice();
if (Object.keys(entry).filter(k => k !== 'host').length === 0) {
next.splice(idx, 1);
} else {
next[idx] = entry;
}
return next;
}
/**
* POST /api/admin/branding - Upload a branding image file
*
* Expects multipart/form-data with:
* - file: the image file
* - slot: which branding field this is for (e.g. "faviconUrl")
* - host (optional): when set, the upload is stored against the
* per-domain entry for that hostname instead of the global default.
*/
export async function POST(request: NextRequest) {
try {
@@ -52,15 +129,24 @@ export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get('file') as File | null;
const slot = formData.get('slot') as string | null;
const rawHost = (formData.get('host') as string | null) ?? '';
if (!file || !slot) {
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
}
if (!VALID_SLOTS.has(slot)) {
if (!VALID_SLOTS.has(slot as UploadSlot)) {
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
}
const host = rawHost ? normalizeHost(rawHost) : '';
if (host && !EXACT_HOST_RE.test(host)) {
return NextResponse.json(
{ error: `Invalid host: ${rawHost} (wildcards must be configured by URL, not upload)` },
{ status: 400 },
);
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
}
@@ -72,34 +158,51 @@ export async function POST(request: NextRequest) {
);
}
// Determine extension from mime type
const extMap: Record<string, string> = {
'image/svg+xml': '.svg',
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'image/x-icon': '.ico',
'image/vnd.microsoft.icon': '.ico',
};
const ext = extMap[file.type] || '.png';
const safeName = sanitizeFilename(`${slot}${ext}`);
const ext = EXT_BY_MIME[file.type] ?? '.png';
const safeName = host
? domainAssetName(host, slot as BrandingOverrideKey, ext)
: sanitizeFilename(`${slot}${ext}`);
const filePath = path.join(getBrandingDir(), safeName);
// Ensure branding directory exists
if (!existsSync(getBrandingDir())) {
await mkdir(getBrandingDir(), { recursive: true });
}
// Write file to disk
// Strip any prior asset for the same slot but a different extension so
// the directory doesn't accumulate orphan files on re-upload.
const dir = getBrandingDir();
const allFiles = await readdir(dir).catch(() => [] as string[]);
for (const f of allFiles) {
if (f === safeName) continue;
const isSame = host
? isDomainAssetFor(f, host, slot as BrandingOverrideKey)
: POSSIBLE_EXTS.some(e => f === `${slot}${e}`);
if (isSame) {
try { await unlink(path.join(dir, f)); } catch { /* ignore */ }
}
}
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
// Update config to point to the served URL
const servedUrl = `/api/admin/branding/${safeName}`;
await configManager.ensureLoaded();
await configManager.setAdminConfig({ [slot]: servedUrl });
await auditLog('branding_upload', { slot, filename: safeName, size: file.size, mimeType: file.type }, ip);
if (host) {
const current = parseDomainBranding(configManager.get<unknown>('domainBranding', []));
const next = mergeDomainEntry(current, host, { [slot]: servedUrl });
await configManager.setAdminConfig({ domainBranding: next });
} else {
await configManager.setAdminConfig({ [slot]: servedUrl });
}
await auditLog('branding_upload', {
slot,
host: host || undefined,
filename: safeName,
size: file.size,
mimeType: file.type,
}, ip);
return NextResponse.json({ url: servedUrl, filename: safeName });
} catch (error) {
@@ -111,7 +214,11 @@ export async function POST(request: NextRequest) {
/**
* DELETE /api/admin/branding - Remove an uploaded branding file
*
* Expects JSON body: { slot: string }
* Expects JSON body: { slot: string, host?: string }
*
* When `host` is provided, only the per-domain asset for that host+slot is
* removed (and the override in `domainBranding[host][slot]` is cleared).
* Otherwise the global asset and config override are removed.
*/
export async function DELETE(request: NextRequest) {
try {
@@ -119,28 +226,48 @@ export async function DELETE(request: NextRequest) {
if ('error' in result) return result.error;
const ip = getClientIP(request);
const { slot } = await request.json();
const body = await request.json().catch(() => ({})) as { slot?: string; host?: string };
const slot = body.slot;
const rawHost = body.host ?? '';
if (!slot || !VALID_SLOTS.has(slot)) {
if (!slot || !VALID_SLOTS.has(slot as UploadSlot)) {
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
}
// Find and remove matching files for this slot
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
const host = rawHost ? normalizeHost(rawHost) : '';
if (host && !EXACT_HOST_RE.test(host)) {
return NextResponse.json({ error: `Invalid host: ${rawHost}` }, { status: 400 });
}
const dir = getBrandingDir();
let removed = false;
for (const ext of possibleExts) {
const filePath = path.join(getBrandingDir(), `${slot}${ext}`);
if (existsSync(filePath)) {
await unlink(filePath);
removed = true;
if (host) {
const allFiles = await readdir(dir).catch(() => [] as string[]);
for (const f of allFiles) {
if (isDomainAssetFor(f, host, slot as BrandingOverrideKey)) {
try { await unlink(path.join(dir, f)); removed = true; } catch { /* ignore */ }
}
}
} else {
for (const ext of POSSIBLE_EXTS) {
const filePath = path.join(dir, `${slot}${ext}`);
if (existsSync(filePath)) {
await unlink(filePath);
removed = true;
}
}
}
// Clear the config override so it falls back to default/env
await configManager.ensureLoaded();
await configManager.removeAdminOverride(slot);
if (host) {
const current = parseDomainBranding(configManager.get<unknown>('domainBranding', []));
const next = clearDomainKeys(current, host, [slot as BrandingOverrideKey]);
await configManager.setAdminConfig({ domainBranding: next });
} else {
await configManager.removeAdminOverride(slot);
}
await auditLog('branding_delete', { slot, fileRemoved: removed }, ip);
await auditLog('branding_delete', { slot, host: host || undefined, fileRemoved: removed }, ip);
return NextResponse.json({ success: true });
} catch (error) {
+20
View File
@@ -4,6 +4,7 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
import { parseJmapServers } from '@/lib/admin/jmap-servers';
import { parseDomainBranding } from '@/lib/admin/domain-branding';
import { logger } from '@/lib/logger';
// Strings that count as "no real secret configured" - used so the dashboard
@@ -88,6 +89,25 @@ export async function PATCH(request: NextRequest) {
updates.jmapServers = sanitized;
}
// Normalize domainBranding: drop entries with an invalid/missing host or
// duplicate hosts before persisting. Each entry's branding field strings
// are passed through unchanged (URL/string content is the operator's
// responsibility, same as the flat branding fields).
if ('domainBranding' in updates) {
const incoming = updates.domainBranding;
if (incoming != null && !Array.isArray(incoming)) {
return NextResponse.json({ error: 'domainBranding must be an array' }, { status: 400 });
}
const sanitized = parseDomainBranding(incoming);
const incomingCount = Array.isArray(incoming) ? incoming.length : 0;
if (sanitized.length !== incomingCount) {
return NextResponse.json({
error: 'One or more domainBranding entries are invalid (each needs a unique, valid host).',
}, { status: 400 });
}
updates.domainBranding = sanitized;
}
// Get old values for audit
const oldValues: Record<string, unknown> = {};
for (const key of Object.keys(updates)) {
+2 -1
View File
@@ -19,7 +19,7 @@ import {
invalidateFrameOriginsCache,
} from '@/lib/admin/csp-frame-origins';
import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS } from '@/lib/plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
import { configManager } from '@/lib/admin/config-manager';
@@ -341,6 +341,7 @@ export async function POST(request: NextRequest) {
author: (manifest.author as string) || 'Unknown',
description: (manifest.description as string) || '',
type: (manifest.type as string) || 'hook',
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
permissions,
entrypoint,
enabled: existingPlugin?.enabled ?? true,
+4
View File
@@ -183,6 +183,7 @@ export async function POST(request: NextRequest) {
author: manifest.author as string,
description: (manifest.description as string) || '',
type: manifest.type as string,
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
permissions: (manifest.permissions as string[]) || [],
entrypoint: manifest.entrypoint as string,
enabled: true,
@@ -192,6 +193,9 @@ export async function POST(request: NextRequest) {
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
: {}),
...(manifest.locales && typeof manifest.locales === 'object'
? { locales: manifest.locales as ServerPlugin['locales'] }
: {}),
...(declaredFrameOrigins.length > 0
? { frameOrigins: declaredFrameOrigins }
: {}),
+53
View File
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager';
import { getMetadata, getRequiredConfig } from '@/lib/oauth/token-exchange';
/**
* Same-origin OAuth metadata (discovery) proxy.
*
* The login page needs the authorization_endpoint to build the PKCE authorize
* URL in the browser. Discovering it directly from the browser means a
* cross-origin fetch to the IdP's /.well-known/* documents, which is subject
* to CORS: providers like Authentik serve those documents without an
* Access-Control-Allow-Origin header, so the browser blocks the response and
* discovery fails (issue #382). Performing discovery here - server to server,
* where CORS does not apply - and handing the result back as a same-origin
* response sidesteps the problem entirely.
*
* The discovery URL is resolved from admin config (via server_id), never from
* client input, so this cannot be abused as an open SSRF proxy. Endpoint URLs
* in the discovered document are still gated by the SSRF validator inside
* discoverOAuth. The returned fields are public well-known metadata.
*/
export async function GET(request: NextRequest) {
await configManager.ensureLoaded();
const serverId = request.nextUrl.searchParams.get('server_id');
let discoveryUrl: string;
try {
({ discoveryUrl } = getRequiredConfig(serverId));
} catch {
// OAuth not configured for this server - surface as "no metadata" rather
// than a 500 so the login page just hides the SSO button.
return NextResponse.json({ error: 'OAuth not configured' }, { status: 404 });
}
try {
const metadata = await getMetadata(serverId);
if (!metadata?.authorization_endpoint || !metadata.token_endpoint) {
logger.warn('OAuth metadata discovery returned no usable endpoints', { discoveryUrl });
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
}
return NextResponse.json(metadata, {
// Mirror the in-process discovery cache TTL so repeated login-page loads
// hit the CDN/browser cache instead of re-running discovery.
headers: { 'Cache-Control': 'private, max-age=600' },
});
} catch (error) {
logger.error('OAuth metadata discovery error', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
}
}
+96
View File
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { buildOAuthParams, getRequiredConfig, getTokenEndpoint } from '@/lib/oauth/token-exchange';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { createPairing } from '@/lib/auth/pairing-store';
import { hasValidPairReauth } from '@/lib/auth/pair-reauth';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
// Desktop side of the cross-device QR login. The caller must be a signed-in
// webmail session (its refresh token lives in the httpOnly jmap_rt cookie). We
// refresh that token to (a) prove the session is live and (b) obtain a fresh
// access token to hand the phone, then stash the bundle under a one-time
// pairing code. The desktop renders the returned code as a QR; the phone
// redeems it at /api/auth/pair/redeem.
//
// Token sharing note: the phone receives the SAME refresh token as the desktop.
// That is correct for OAuth servers (such as Stalwart in its default config)
// that do not rotate refresh tokens on use. If the server rotates refresh
// tokens, the two devices would fight over the latest token — such deployments
// should disable rotation for this client or use a token-exchange grant.
export async function POST(request: NextRequest) {
const cookieStore = await cookies();
try {
// Step-up gate: minting a pairing code grants new-device access, so it
// requires a recent fresh IdP re-authentication (see the reauth SSO flow).
// The client turns this 401 into a re-auth redirect, then retries.
if (!(await hasValidPairReauth())) {
return NextResponse.json({ error: 'reauth_required' }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const slot =
typeof body.slot === 'number' && body.slot >= 0 && body.slot < MAX_ACCOUNT_SLOTS
? body.slot
: 0;
const cookieName = refreshTokenCookieName(slot);
const refreshToken = cookieStore.get(cookieName)?.value;
if (!refreshToken) {
return NextResponse.json({ error: 'Not signed in' }, { status: 401 });
}
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
const tokenEndpoint = await getTokenEndpoint(serverId);
const params = buildOAuthParams({ grant_type: 'refresh_token', refresh_token: refreshToken }, serverId);
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
logger.warn('Pair create: refresh failed', { status: tokenResponse.status, error: errorText });
// Stale session — clear the dead cookie so the user is prompted to log
// back in, mirroring the token route's behaviour.
cookieStore.delete(cookieName);
cookieStore.delete(refreshTokenServerCookieName(slot));
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
}
const tokens = await tokenResponse.json();
if (!tokens.access_token) {
logger.error('Pair create: refresh response missing access_token');
return NextResponse.json({ error: 'Invalid token response' }, { status: 502 });
}
// If the server rotated the refresh token, persist the new one back to the
// desktop's cookie so this very session keeps working. The phone will get
// the same (new) token below.
const effectiveRefreshToken = tokens.refresh_token || refreshToken;
if (tokens.refresh_token) {
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
}
const { clientId, serverUrl } = getRequiredConfig(serverId);
const { code, expiresIn } = createPairing({
accessToken: tokens.access_token,
refreshToken: effectiveRefreshToken,
expiresIn: tokens.expires_in,
tokenEndpoint,
clientId,
serverUrl,
serverId,
});
return NextResponse.json({ pairing_code: code, server_url: serverUrl, expires_in: expiresIn });
} catch (error) {
logger.error('Pair create error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { consumePairing } from '@/lib/auth/pairing-store';
// Phone side of the cross-device QR login. The app POSTs the pairing code it
// scanned; we hand back the OAuth token bundle the desktop stashed at
// /api/auth/pair/create. The code is the only credential required — it is
// high-entropy, single-use, and expires within ~2 minutes — so this route is
// intentionally unauthenticated (the scanning device has no webmail cookies).
export async function POST(request: NextRequest) {
try {
const { pairing_code: pairingCode } = await request.json().catch(() => ({}));
if (!pairingCode || typeof pairingCode !== 'string') {
return NextResponse.json({ error: 'Missing pairing code' }, { status: 400 });
}
const tokens = consumePairing(pairingCode);
if (!tokens) {
// Unknown, expired, or already redeemed — do not distinguish.
return NextResponse.json({ error: 'Invalid or expired pairing code' }, { status: 400 });
}
return NextResponse.json({
flow: 'oauth',
server_url: tokens.serverUrl,
access_token: tokens.accessToken,
...(tokens.refreshToken ? { refresh_token: tokens.refreshToken } : {}),
...(typeof tokens.expiresIn === 'number' ? { expires_in: tokens.expiresIn } : {}),
token_endpoint: tokens.tokenEndpoint,
client_id: tokens.clientId,
});
} catch (error) {
logger.error('Pair redeem error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+70
View File
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { decryptPayload } from '@/lib/auth/crypto';
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
import { setPairReauth } from '@/lib/auth/pair-reauth';
// Completes the step-up re-authentication for device pairing. The user was sent
// to the IdP with prompt=login (see /api/auth/sso/start with purpose=reauth);
// here we verify the returned code against the pending state and exchange it to
// confirm a fresh login actually happened, then set the short-lived pairing
// re-auth proof cookie. We deliberately do NOT issue a login session or write
// any refresh-token cookies — the user is already signed in; this only proves
// recency for the pairing action.
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000;
export async function POST(request: NextRequest) {
const cookieStore = await cookies();
try {
const { code, state } = await request.json();
if (!code || !state) {
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
}
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
if (!pendingCookie) {
return NextResponse.json({ error: 'No pending re-auth session' }, { status: 400 });
}
const pending = decryptPayload(pendingCookie);
cookieStore.delete(SSO_PENDING_COOKIE);
if (!pending) {
return NextResponse.json({ error: 'Invalid re-auth session' }, { status: 400 });
}
// Only honor pending sessions that were started for the reauth purpose, so
// a normal login code can't be redirected into setting a pairing proof.
if (pending.purpose !== 'reauth') {
return NextResponse.json({ error: 'Not a re-auth session' }, { status: 400 });
}
if (pending.state !== state) {
return NextResponse.json({ error: 'State mismatch' }, { status: 400 });
}
const createdAt = pending.created_at as number;
if (!createdAt || Date.now() - createdAt > SSO_PENDING_MAX_AGE_MS) {
return NextResponse.json({ error: 'Re-auth session expired' }, { status: 400 });
}
const codeVerifier = pending.code_verifier as string;
const redirectUri = pending.redirect_uri as string;
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
if (!codeVerifier || !redirectUri) {
return NextResponse.json({ error: 'Invalid re-auth session data' }, { status: 400 });
}
// A successful exchange proves the user just authenticated at the IdP (the
// freshness is enforced by prompt=login on the authorize request). We don't
// keep the resulting tokens.
await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
await setPairReauth();
return NextResponse.json({ ok: true });
} catch (error) {
cookieStore.delete(SSO_PENDING_COOKIE);
logger.error('Reauth complete error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Re-authentication failed' }, { status: 401 });
}
}
+22 -2
View File
@@ -8,6 +8,7 @@ import { discoverOAuth } from '@/lib/oauth/discovery';
import { getOauthScopes } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { configManager } from '@/lib/admin/config-manager';
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
@@ -30,8 +31,14 @@ export async function POST(request: NextRequest) {
server_id: bodyServerId,
mobile_redirect_uri: rawMobileRedirectUri,
mobile_state: rawMobileState,
purpose: rawPurpose,
} = await request.json();
// `reauth` drives the step-up flow for device pairing: it forces a fresh
// IdP login (prompt=login) and the /reauth/sso/complete handler sets the
// short-lived pairing re-auth proof instead of logging the user in again.
const isReauth = rawPurpose === 'reauth';
if (!redirect_uri || typeof redirect_uri !== 'string') {
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
}
@@ -85,6 +92,7 @@ export async function POST(request: NextRequest) {
...(serverId ? { server_id: serverId } : {}),
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
...(mobileState ? { mobile_state: mobileState } : {}),
...(isReauth ? { purpose: 'reauth' } : {}),
};
const encrypted = encryptPayload(pendingData);
@@ -95,8 +103,12 @@ export async function POST(request: NextRequest) {
maxAge: SSO_PENDING_MAX_AGE,
});
// Build authorize URL
const authUrl = new URL(metadata.authorization_endpoint);
// Build authorize URL. OAUTH_AUTHORIZE_URL, when set, overrides only the
// user-facing authorize endpoint (e.g. a per-brand login host). Discovery,
// token exchange and refresh keep using the canonical discovered endpoints.
const authorizeOverride =
configManager.get<string>('oauthAuthorizeUrl', '') || process.env.OAUTH_AUTHORIZE_URL;
const authUrl = new URL(authorizeOverride?.trim() || metadata.authorization_endpoint);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', clientId);
authUrl.searchParams.set('redirect_uri', redirect_uri);
@@ -109,6 +121,14 @@ export async function POST(request: NextRequest) {
authUrl.searchParams.set('ui_locales', locale);
}
// Force a fresh credential entry for step-up re-auth. prompt=login and
// max_age=0 both ask the IdP to re-authenticate even if it has an active
// session; honoring them depends on the IdP supporting these OIDC params.
if (isReauth) {
authUrl.searchParams.set('prompt', 'login');
authUrl.searchParams.set('max_age', '0');
}
return NextResponse.json({
authorize_url: authUrl.toString(),
state,
+10 -3
View File
@@ -82,9 +82,16 @@ export async function PUT(request: NextRequest) {
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
cookieStore.delete(cookieName);
cookieStore.delete(refreshTokenServerCookieName(slot));
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
// Drop the refresh token only when the server definitively rejected it
// (invalid/expired/revoked grant). A 5xx or 429 is an outage - keeping
// the cookie lets the session resume once the server is back.
const status = tokenResponse.status;
if (status === 400 || status === 401 || status === 403) {
cookieStore.delete(cookieName);
cookieStore.delete(refreshTokenServerCookieName(slot));
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
}
return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 });
}
const tokens = await tokenResponse.json();
+161 -151
View File
@@ -1,8 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
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 { getCookieOptions } from '@/lib/oauth/cookie-config';
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 { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
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
* to token-based auth, avoiding session expiry when the TOTP rotates.
* Stalwart 0.16+ no longer accepts the legacy `password$totp` convention over
* 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:
* 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set)
* 2. ROPC grant without client_id
* 3. ROPC grant authenticated via Basic Auth header (Stalwart-style)
* This route drives that flow server-side (avoiding browser CORS against the
* mail server, same as OAuth discovery):
* 1. POST {serverUrl}/api/auth -> authenticate with a separate `mfaToken`,
* 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(
tokenEndpoint: string,
params: URLSearchParams,
extraHeaders?: Record<string, string>,
): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> {
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(),
});
// Fallback OAuth client id used when no client is configured. Stalwart accepts
// any client id unless `require_client_registration` is enabled (default off);
// when it is enabled the admin must configure `oauthClientId` with this
// redirect URI registered.
const DEFAULT_CLIENT_ID = 'bulwark-webmail';
if (!response.ok) {
const errorText = await response.text();
return { ok: false, status: response.status, error: errorText.substring(0, 500) };
}
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) };
}
interface LoginResult {
type?: string;
// 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;
}
async function findTokenEndpoint(serverUrl: string, adminTrusted: boolean): Promise<string | null> {
// Admin-trusted callers (matched server entry or configured JMAP server URL)
// 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;
function trimUrl(url: string): string {
return url.replace(/\/+$/, '');
}
// 2. Try common Stalwart token endpoint paths directly
const candidates = [
`${serverUrl}/auth/token`,
`${serverUrl}/api/oauth/token`,
];
async function attemptLogin(
upstreamUrl: string,
username: string,
password: string,
totp: string | undefined,
redirectUri: string,
slot: number,
serverId: string | null,
): Promise<NextResponse> {
const base = trimUrl(upstreamUrl);
for (const url of candidates) {
try {
// A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists
const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' });
if (probe.status !== 404 && probe.status !== 405) {
return url;
}
} catch {
// Network error - endpoint not reachable
// 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 {
const loginResponse = await fetch(`${base}/api/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'authCode',
accountName: username,
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 },
);
}
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 });
}
return null;
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) {
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) {
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 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
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
@@ -110,20 +201,17 @@ export async function POST(request: NextRequest) {
let upstreamUrl: string;
let resolvedServerId: string | null = null;
let adminTrusted = false;
const requestedEntry = findServerById(serverList, requestedServerId);
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
if (matchedEntry) {
upstreamUrl = matchedEntry.url;
resolvedServerId = matchedEntry.id;
adminTrusted = true;
} else if (configuredServerUrl) {
upstreamUrl = configuredServerUrl;
adminTrusted = true;
} else if (allowCustomEndpoint) {
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 });
}
upstreamUrl = serverUrl;
@@ -131,100 +219,22 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
}
const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted);
if (!tokenEndpoint) {
logger.warn('TOTP token exchange: no token endpoint found');
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
}
// The redirect URI must be identical in the login and token-exchange steps,
// and (when require_client_registration is on) registered for the client.
// Prefer the browser-supplied callback URL the OAuth client already uses;
// 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) {
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 });
}
}
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(
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
slot: number,
+334
View File
@@ -0,0 +1,334 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
import { parseISO } from 'date-fns';
import type { CalendarEvent } from '@/lib/jmap/types';
/**
* POST /api/calendar-agenda
*
* Sidecar for the "Calendar Agenda" plugin. Resolves the caller's calendar
* account from the stored Stalwart auth context, queries upcoming
* CalendarEvents over JMAP, expands recurring series server-side, and returns
* a slim, structured-cloneable agenda the plugin can render directly.
*
* Credentials never leave the server the sandboxed plugin only ever sees
* the resulting agenda DTOs.
*
* Body: { days?: number (1-90, default 7), limit?: number (1-200, default 50) }
*/
const CALENDAR_CAP = 'urn:ietf:params:jmap:calendars';
const PRINCIPALS_CAP = 'urn:ietf:params:jmap:principals';
// Mirror of lib/jmap/client.ts CALENDAR_EVENT_PROPERTIES, trimmed to what the
// agenda actually needs (start/recurrence/display fields).
const EVENT_PROPERTIES = [
'id', '@type', 'uid', 'calendarIds', 'title', 'start', 'duration', 'timeZone',
'showWithoutTime', 'utcStart', 'utcEnd', 'status', 'freeBusyStatus', 'color',
'locations', 'recurrenceId', 'recurrenceIdTimeZone', 'recurrenceRule',
'recurrenceOverrides', 'excludedRecurrenceRule',
] as const;
interface JmapSession {
apiUrl?: string;
primaryAccounts?: Record<string, string>;
capabilities?: Record<string, unknown>;
}
interface AgendaEvent {
id: string;
uid: string | null;
title: string;
start: string;
end: string;
allDay: boolean;
status: string | null;
color: string | null;
location: string | null;
calendarId: string | null;
}
// Pure, server-safe equivalents of lib/calendar-utils' getEventStartDate /
// getEventEndDate. We can't import that module here because it transitively
// pulls in a "use client" calendar component (which throws at load on the
// server). Logic mirrors the originals.
function parseDurationMinutes(duration: string | undefined): number {
if (!duration) return 0;
let total = 0;
const week = duration.match(/(\d+)W/);
const day = duration.match(/(\d+)D/);
const hour = duration.match(/(\d+)H/);
const min = duration.match(/(\d+)M/);
if (week) total += parseInt(week[1], 10) * 7 * 24 * 60;
if (day) total += parseInt(day[1], 10) * 24 * 60;
if (hour) total += parseInt(hour[1], 10) * 60;
if (min) total += parseInt(min[1], 10);
return total;
}
function eventStart(event: Partial<CalendarEvent>): Date {
if (!event.showWithoutTime && event.utcStart) {
const utc = parseISO(event.utcStart);
if (!isNaN(utc.getTime())) return utc;
}
return parseISO(event.start as string);
}
function eventEnd(event: Partial<CalendarEvent>): Date {
if (!event.showWithoutTime && event.utcEnd) {
const utc = parseISO(event.utcEnd);
if (!isNaN(utc.getTime())) return utc;
}
const start = eventStart(event);
if (!event.duration) return start;
return new Date(start.getTime() + parseDurationMinutes(event.duration) * 60000);
}
function firstLocationName(event: Partial<CalendarEvent>): string | null {
const locations = event.locations;
if (!locations || typeof locations !== 'object') return null;
for (const loc of Object.values(locations)) {
const name = (loc as { name?: unknown })?.name;
if (typeof name === 'string' && name.trim()) return name.trim();
}
return null;
}
function firstCalendarId(event: Partial<CalendarEvent>): string | null {
const ids = event.calendarIds;
if (!ids || typeof ids !== 'object') return null;
const keys = Object.keys(ids);
return keys.length > 0 ? keys[0] : null;
}
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
let body: { days?: unknown; limit?: unknown } = {};
try {
body = await request.json();
} catch {
/* empty body is fine */
}
const days = clampInt(body.days, 1, 90, 7);
const limit = clampInt(body.limit, 1, 200, 50);
// ── Resolve the calendar account from the JMAP session ──
// Hit Stalwart's canonical session endpoint on the SAME host as serverUrl.
// We deliberately avoid /.well-known/jmap: it 301s to the server's
// configured public hostname, which the host process may not be able to
// resolve (the browser client rewrites those URLs back to the origin for
// the same reason). Fall back to /.well-known/jmap for non-Stalwart servers.
const session = await fetchJmapSession(creds.serverUrl, creds.authHeader);
if (!session) {
return NextResponse.json({ error: 'JMAP session fetch failed' }, { status: 502 });
}
const accountId = session.primaryAccounts?.[CALENDAR_CAP];
if (!accountId) {
// No calendar account for this user — return an empty agenda, not an error.
return NextResponse.json({ events: [], generatedAt: new Date().toISOString() });
}
const using = ['urn:ietf:params:jmap:core', CALENDAR_CAP];
if (session.capabilities && PRINCIPALS_CAP in session.capabilities) {
using.push('urn:ietf:params:jmap:principals:owner');
}
// Send method calls to the same-origin JMAP endpoint the app's passthrough
// uses — never to session.apiUrl's (possibly unreachable) public host.
const apiUrl = `${creds.serverUrl}/jmap/`;
const now = new Date();
const horizon = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
// Expand from the start of today so all-day / already-running events still
// show in the agenda.
const windowStart = new Date(now);
windowStart.setHours(0, 0, 0, 0);
// ── 1) Query event IDs in range + load calendars (colours) ──
const queryReq = {
using,
methodCalls: [
[
'CalendarEvent/query',
{
accountId,
// Mirror the app's calendar store: an { after, before } window lets
// Stalwart evaluate recurrence so masters with occurrences in range
// are returned (a `before`-only filter can drop unbounded series).
filter: { after: windowStart.toISOString(), before: horizon.toISOString() },
limit: 1000,
},
'0',
],
[
'Calendar/get',
{ accountId, ids: null, properties: ['id', 'name', 'color'] },
'c',
],
],
};
const queryRes = await jmapPost(apiUrl, creds.authHeader, queryReq);
const queryResp = findResponse(queryRes, 'CalendarEvent/query', '0');
if (!queryResp) {
const err = findResponse(queryRes, 'error', '0');
return NextResponse.json(
{ error: (err?.description as string) || 'CalendarEvent/query failed' },
{ status: 502 },
);
}
const ids = (queryResp.ids as string[]) || [];
const calColors = new Map<string, { name: string; color: string | null }>();
const calResp = findResponse(queryRes, 'Calendar/get', 'c');
for (const cal of ((calResp?.list as Array<Record<string, unknown>>) || [])) {
if (typeof cal.id === 'string') {
calColors.set(cal.id, {
name: typeof cal.name === 'string' ? cal.name : '',
color: typeof cal.color === 'string' ? cal.color : null,
});
}
}
if (ids.length === 0) {
return NextResponse.json({ events: [], generatedAt: now.toISOString() });
}
// ── 2) Fetch full event objects (batched) ──
const raw: Array<Record<string, unknown>> = [];
const BATCH = 100;
for (let i = 0; i < ids.length; i += BATCH) {
const batch = ids.slice(i, i + BATCH);
const getRes = await jmapPost(apiUrl, creds.authHeader, {
using,
methodCalls: [
['CalendarEvent/get', { accountId, ids: batch, properties: EVENT_PROPERTIES }, '0'],
],
});
const getResp = findResponse(getRes, 'CalendarEvent/get', '0');
if (getResp?.list) raw.push(...(getResp.list as Array<Record<string, unknown>>));
}
// ── 3) Normalize + expand recurrences server-side ──
const normalized = raw
.map((e) => normalizeCalendarEventLike(e as Partial<CalendarEvent>))
.filter((e) => (e['@type'] ?? 'Event') === 'Event')
// Drop malformed events without a parseable start (would crash format()/
// parseISO downstream) — mirrors the calendar store guard (#316).
.filter((e) => typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())) as CalendarEvent[];
const expanded = expandRecurringEvents(
normalized,
windowStart.toISOString(),
horizon.toISOString(),
);
// ── 4) Keep ongoing/upcoming, sort, slice, map to DTOs ──
const agenda: AgendaEvent[] = expanded
.filter((e) => eventEnd(e).getTime() >= now.getTime())
.sort((a, b) => eventStart(a).getTime() - eventStart(b).getTime())
.slice(0, limit)
.map((e) => {
const calId = firstCalendarId(e);
const cal = calId ? calColors.get(calId) : undefined;
return {
id: String(e.id ?? ''),
uid: e.uid ?? null,
title: (e.title ?? '').trim() || '(no title)',
start: eventStart(e).toISOString(),
end: eventEnd(e).toISOString(),
allDay: !!e.showWithoutTime,
status: e.status ?? null,
color: e.color || cal?.color || null,
location: firstLocationName(e),
calendarId: calId,
};
});
return NextResponse.json({ events: agenda, generatedAt: now.toISOString() });
} catch (error) {
// `fetch failed` from undici is too generic to debug — the real reason
// (ENOTFOUND, ECONNREFUSED, self-signed TLS, …) lives on `error.cause`.
const err = error as Error & { cause?: { code?: string; message?: string } };
logger.error('Calendar agenda error', {
error: err?.message ?? 'Unknown',
causeCode: err?.cause?.code,
causeMessage: err?.cause?.message,
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.round(n)));
}
/**
* Fetch the JMAP session from the same host as `serverUrl`. Tries Stalwart's
* canonical /jmap/session first (no redirect), then /.well-known/jmap as a
* fallback for other servers. Returns null if neither yields a usable session.
*/
async function fetchJmapSession(
serverUrl: string,
authHeader: string,
): Promise<JmapSession | null> {
const candidates = [`${serverUrl}/jmap/session`, `${serverUrl}/.well-known/jmap`];
for (const url of candidates) {
try {
const res = await fetch(url, {
method: 'GET',
headers: { Authorization: authHeader },
redirect: 'follow',
});
if (!res.ok) continue;
const session = (await res.json()) as JmapSession;
if (session && typeof session === 'object' && session.primaryAccounts) {
return session;
}
} catch {
// Try the next candidate (e.g. canonical path 404s on a non-Stalwart server).
}
}
return null;
}
async function jmapPost(
apiUrl: string,
authHeader: string,
payload: unknown,
): Promise<unknown> {
const res = await fetch(apiUrl, {
method: 'POST',
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
throw new Error(`JMAP request failed (${res.status})`);
}
return res.json();
}
function findResponse(
res: unknown,
name: string,
callId: string,
): Record<string, unknown> | null {
const responses = (res as { methodResponses?: unknown[] })?.methodResponses;
if (!Array.isArray(responses)) return null;
for (const entry of responses) {
if (Array.isArray(entry) && entry[0] === name && entry[2] === callId) {
return entry[1] as Record<string, unknown>;
}
}
return null;
}
+70 -36
View File
@@ -1,9 +1,15 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { getOauthScopes } from '@/lib/oauth/tokens';
import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
type BrandingOverrideKey,
} from '@/lib/admin/domain-branding';
/**
* Runtime configuration endpoint
@@ -13,49 +19,77 @@ import { getOauthScopes } from '@/lib/oauth/tokens';
* post-build configuration for Docker deployments.
*
* Priority order:
* 1. Admin dashboard overrides (data/admin/config.json)
* 2. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
* 3. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
* 4. Default values
* 1. Per-domain branding override (admin-configured, matched on request host)
* 2. Admin dashboard overrides (data/admin/config.json)
* 3. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
* 4. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
* 5. Default values
*/
export async function GET() {
export async function GET(request: NextRequest) {
logger.debug('Config requested');
await configManager.ensureLoaded();
const appName = configManager.get<string>('appName') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
const host = pickRequestHost(request);
const domainOverrides = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
);
// Per-domain override wins over the global value, but only when the
// entry explicitly sets that key. Otherwise we fall through to the
// global admin/env/default chain.
const branded = <T,>(key: BrandingOverrideKey, fallback: T): T => {
const override = domainOverrides[key];
if (typeof override === 'string' && override.length > 0) return override as T;
return configManager.get<T>(key, fallback);
};
const appName =
branded<string>('appName', '') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
const jmapServerUrl = configManager.get<string>('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
const oauthOnly = oauthEnabled && configManager.get<boolean>('oauthOnly', false);
const stalwartFeaturesEnabled = configManager.get<boolean>('stalwartFeaturesEnabled', true);
const allowedFrameAncestors = configManager.get<string>('allowedFrameAncestors', '');
return NextResponse.json({
appName,
jmapServerUrl,
oauthEnabled,
oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
oauthScopes: getOauthScopes(),
rememberMeEnabled: hasSessionSecret(),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
stalwartFeaturesEnabled,
devMode: configManager.get<boolean>('devMode', false),
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
appLogoLightUrl: configManager.get<string>('appLogoLightUrl', ''),
appLogoDarkUrl: configManager.get<string>('appLogoDarkUrl', ''),
loginLogoLightUrl: configManager.get<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
loginLogoDarkUrl: configManager.get<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
loginCompanyName: configManager.get<string>('loginCompanyName', ''),
loginImprintUrl: configManager.get<string>('loginImprintUrl', ''),
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get<string>('parentOrigin', ''),
});
return NextResponse.json(
{
appName,
jmapServerUrl,
oauthEnabled,
oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
oauthScopes: getOauthScopes(),
rememberMeEnabled: hasSessionSecret(),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
stalwartFeaturesEnabled,
devMode: configManager.get<boolean>('devMode', false),
faviconUrl: branded<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
appLogoLightUrl: branded<string>('appLogoLightUrl', ''),
appLogoDarkUrl: branded<string>('appLogoDarkUrl', ''),
loginLogoLightUrl: branded<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
loginLogoDarkUrl: branded<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
loginCompanyName: branded<string>('loginCompanyName', ''),
loginImprintUrl: branded<string>('loginImprintUrl', ''),
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
loginLogoMaxHeight: configManager.get<string>('loginLogoMaxHeight', ''),
loginLogoMaxWidth: configManager.get<string>('loginLogoMaxWidth', ''),
loginShowHeading: configManager.get<boolean>('loginShowHeading', true),
loginShowSubtitle: configManager.get<boolean>('loginShowSubtitle', true),
demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get<string>('parentOrigin', ''),
},
{
// Branding varies by host, so any cache between us and the browser
// must key its entry by the host headers we consulted.
headers: { Vary: 'Host, X-Forwarded-Host' },
},
);
}
+62 -8
View File
@@ -121,9 +121,9 @@ const emails: MockEmail[] = [
},
{
id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 5100, receivedAt: daysAgo(1),
from: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }],
from: [{ name: 'Dubois, Pierre', email: 'pierre@dubois.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }],
cc: [{ name: 'Karel de Vries', email: 'karel@devries.example' }],
cc: [{ name: 'de Vries, Karel', email: 'karel@devries.example' }],
subject: 'Project Update - Q1 Review',
preview: 'Salut team, I wanted to share the latest project numbers. We are on track to meet our targets for Q1.',
hasAttachment: true,
@@ -723,7 +723,18 @@ const emails: MockEmail[] = [
// Identities
// ---------------------------------------------------------------------------
const IDENTITIES = [
type MockIdentity = {
id: string;
name: string;
email: string;
replyTo: Array<{ name?: string; email: string }> | null;
bcc: Array<{ name?: string; email: string }> | null;
textSignature: string | null;
htmlSignature: string | null;
mayDelete: boolean;
};
const IDENTITIES: MockIdentity[] = [
{
id: 'identity-001',
name: 'Dev User',
@@ -1562,13 +1573,55 @@ function handleIdentityGet(_args: MethodArgs, callId: string): MethodResult {
function handleIdentitySet(args: MethodArgs, callId: string): MethodResult {
const created: Record<string, { id: string }> = {};
const create = args.create as Record<string, unknown> | undefined;
const updated: Record<string, null> = {};
const destroyed: string[] = [];
const create = args.create as Record<string, Record<string, unknown>> | undefined;
if (create) {
for (const key of Object.keys(create)) {
created[key] = { id: `identity-new-${Date.now()}-${key}` };
for (const [key, data] of Object.entries(create)) {
const newId = `identity-${Date.now()}-${key}`;
IDENTITIES.push({
id: newId,
name: (data.name as string) || '',
email: (data.email as string) || '',
replyTo: (data.replyTo as MockIdentity['replyTo']) ?? null,
bcc: (data.bcc as MockIdentity['bcc']) ?? null,
textSignature: (data.textSignature as string | null) ?? null,
htmlSignature: (data.htmlSignature as string | null) ?? null,
mayDelete: true,
});
created[key] = { id: newId };
}
}
return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated: null, destroyed: null }, callId];
const update = args.update as Record<string, Record<string, unknown>> | undefined;
if (update) {
for (const [id, changes] of Object.entries(update)) {
const identity = IDENTITIES.find((i) => i.id === id);
if (identity) {
// Email is immutable per the identity form, so it's never in `changes`.
if (changes.name !== undefined) identity.name = changes.name as string;
if (changes.replyTo !== undefined) identity.replyTo = changes.replyTo as MockIdentity['replyTo'];
if (changes.bcc !== undefined) identity.bcc = changes.bcc as MockIdentity['bcc'];
if (changes.textSignature !== undefined) identity.textSignature = changes.textSignature as string | null;
if (changes.htmlSignature !== undefined) identity.htmlSignature = changes.htmlSignature as string | null;
updated[id] = null;
}
}
}
const destroy = args.destroy as string[] | undefined;
if (destroy) {
for (const id of destroy) {
const idx = IDENTITIES.findIndex((i) => i.id === id);
if (idx !== -1) {
IDENTITIES.splice(idx, 1);
destroyed.push(id);
}
}
}
return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, destroyed, notCreated: null, notUpdated: null, notDestroyed: null }, callId];
}
function handleThreadGet(args: MethodArgs, callId: string): MethodResult {
@@ -1626,7 +1679,8 @@ function handleEmailSubmissionGet(args: MethodArgs, callId: string): MethodResul
}
function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult {
return ['Quota/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ resourceType: 'mail', scope: 'mail', used: 52428800, hardLimit: 1073741824 }], notFound: [] }, callId];
// mirroring Stalwart: resourceType "octets", scope "account"
return ['Quota/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ id: 'quota-1', resourceType: 'octets', scope: 'account', types: ['Email', 'SieveScript'], used: 52428800, hardLimit: 1073741824 }], notFound: [] }, callId];
}
function handleVacationResponseGet(_args: MethodArgs, callId: string): MethodResult {
+17 -12
View File
@@ -19,6 +19,20 @@ const negativeCache = new Map<string, NegativeCacheEntry>();
const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
const NEGATIVE_CACHE_MAX_SIZE = 2000;
// 1x1 transparent PNG. Returned with HTTP 200 (instead of 404) when no
// favicon exists for a domain, so the browser's <img> tag loads it cleanly
// without spamming the DevTools console with red 404 errors. Avatar.tsx
// checks `naturalWidth <= 1` in onLoad and falls back to initials.
const TRANSPARENT_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=',
'base64',
);
const MISSING_FAVICON_HEADERS = {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400', // 1 day
'X-Bulwark-Favicon': 'missing',
};
// Strict domain validation to prevent SSRF
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
@@ -434,10 +448,7 @@ export async function GET(request: NextRequest) {
// Check negative cache (domains known to have no favicon)
const neg = negativeCache.get(normalizedDomain);
if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS) {
return new NextResponse(null, {
status: 404,
headers: { 'Cache-Control': 'public, max-age=86400' }, // 1 day
});
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
}
// Check cache
@@ -460,10 +471,7 @@ export async function GET(request: NextRequest) {
if (!upstream.ok) {
evictNegativeOldest();
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
return new NextResponse(null, {
status: 404,
headers: { 'Cache-Control': 'public, max-age=86400' },
});
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
}
const contentType = upstream.headers.get('content-type') || 'image/x-icon';
@@ -473,10 +481,7 @@ export async function GET(request: NextRequest) {
if (data.byteLength < 10) {
evictNegativeOldest();
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
return new NextResponse(null, {
status: 404,
headers: { 'Cache-Control': 'public, max-age=86400' },
});
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
}
// Cache the result
+6
View File
@@ -37,6 +37,9 @@ export async function GET() {
author: p.author,
description: p.description,
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,
entrypoint: p.entrypoint,
// Policy is the canonical source for force-enable. The per-plugin field
@@ -57,6 +60,9 @@ export async function GET() {
// Per-user settings schema, captured from the manifest at upload/load
// time so the client can render the settings UI without re-parsing.
settingsSchema: p.settingsSchema,
// Plugin-declared i18n tables, so the sandbox can localize plugin
// strings via api.i18n.t().
locales: p.locales,
}));
// Only serve enabled themes
+20 -5
View File
@@ -4,6 +4,11 @@ import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { configManager } from '@/lib/admin/config-manager';
import { getConfigDir } from '@/lib/admin/paths';
import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
} from '@/lib/admin/domain-branding';
const VALID_SIZES = new Set([192, 512]);
@@ -33,7 +38,7 @@ async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
}
export async function GET(
_req: NextRequest,
req: NextRequest,
{ params }: { params: Promise<{ size: string }> }
) {
const { size: sizeParam } = await params;
@@ -44,17 +49,27 @@ export async function GET(
}
await configManager.ensureLoaded();
const host = pickRequestHost(req);
const domainOverrides = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
);
const sources = configManager.getAllWithSources();
const iconUrl =
domainOverrides.pwaIconUrl ||
domainOverrides.faviconUrl ||
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
if (!iconUrl) {
return new NextResponse('No PWA icon configured', { status: 404 });
}
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '') ||
// Fall back to the built-in default so this endpoint ALWAYS returns an app
// icon (custom if configured, else the bundled default). This lets callers
// that can't run the custom-vs-default check themselves - notably the
// service worker's notifications - use a single stable URL.
`/icon-${size}x${size}.png`;
const pngHeaders = {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400',
Vary: 'Host, X-Forwarded-Host',
};
const cacheKey = `${size}|${iconUrl}`;
+104
View File
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from 'next/server';
import sharp from 'sharp';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { configManager } from '@/lib/admin/config-manager';
import { getConfigDir } from '@/lib/admin/paths';
import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
} from '@/lib/admin/domain-branding';
/**
* Variant target output size + admin config key.
* Matches the sizes declared in app/manifest.ts so the rendered PNG fits
* the slot the manifest tells the browser about.
*/
const VARIANTS = {
mobile: { width: 540, height: 720, configKey: 'pwaScreenshotMobileUrl' as const },
desktop: { width: 1280, height: 720, configKey: 'pwaScreenshotDesktopUrl' as const },
} as const;
type Variant = keyof typeof VARIANTS;
// Cache resized images keyed by (variant, source URL).
const cache = new Map<string, Blob>();
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) {
const res = await fetch(iconUrl);
if (!res.ok) throw new Error(`Failed to fetch PWA screenshot: ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
// Admin-uploaded branding asset: served from /api/admin/branding/<file>
// but stored on disk under getConfigDir()/branding/.
const ADMIN_BRANDING_PREFIX = '/api/admin/branding/';
if (iconUrl.startsWith(ADMIN_BRANDING_PREFIX)) {
const filename = path.basename(iconUrl.slice(ADMIN_BRANDING_PREFIX.length));
return readFile(path.join(getConfigDir(), 'branding', filename));
}
// Path relative to public/ directory
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
return readFile(publicPath);
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ variant: string }> },
) {
const { variant: variantParam } = await params;
if (!(variantParam in VARIANTS)) {
return new NextResponse('Invalid variant. Allowed: mobile, desktop', { status: 400 });
}
const { width, height, configKey } = VARIANTS[variantParam as Variant];
await configManager.ensureLoaded();
const host = pickRequestHost(req);
const domainOverrides = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
);
const sources = configManager.getAllWithSources();
const sourceEntry = sources[configKey];
const screenshotUrl =
domainOverrides[configKey] ||
(sourceEntry?.source !== 'default' ? (sourceEntry?.value as string | undefined) : undefined);
if (!screenshotUrl) {
return new NextResponse('No PWA screenshot configured', { status: 404 });
}
const pngHeaders = {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400',
Vary: 'Host, X-Forwarded-Host',
};
const cacheKey = `${variantParam}|${screenshotUrl}`;
try {
if (cache.has(cacheKey)) {
return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
}
const sourceBuffer = await fetchSourceImage(screenshotUrl);
// 'cover' fills the target box without letterboxing - screenshots benefit
// more from cropping than from a transparent frame around them. Users get
// a hint about the recommended aspect ratio in the admin UI.
const resized = await sharp(sourceBuffer)
.resize(width, height, { fit: 'cover', position: 'center' })
.png()
.toBuffer();
const ab = new ArrayBuffer(resized.byteLength);
new Uint8Array(ab).set(resized);
const blob = new Blob([ab], { type: 'image/png' });
cache.set(cacheKey, blob);
return new NextResponse(blob, { headers: pngHeaders });
} catch (err) {
console.error('Failed to generate PWA screenshot:', err);
return new NextResponse('Failed to generate screenshot', { status: 500 });
}
}
+3
View File
@@ -23,10 +23,13 @@ const ALLOWED_MIME_TYPES = new Set([
const VALID_SLOTS = new Set([
'faviconUrl',
'pwaIconUrl',
'appLogoLightUrl',
'appLogoDarkUrl',
'loginLogoLightUrl',
'loginLogoDarkUrl',
'pwaScreenshotMobileUrl',
'pwaScreenshotDesktopUrl',
]);
const EXT_BY_MIME: Record<string, string> = {
+26 -1
View File
@@ -4,6 +4,7 @@ import { authenticateWizardRequest } from '@/lib/setup/session';
import { configManager } from '@/lib/admin/config-manager';
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
import { parseJmapServers } from '@/lib/admin/jmap-servers';
import { effectiveConsent, loadState, saveState, reschedule } from '@/lib/telemetry';
import { logger } from '@/lib/logger';
export const dynamic = 'force-dynamic';
@@ -76,8 +77,32 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'values must be an object' }, { status: 400 });
}
const valuesObj = { ...(values as Record<string, unknown>) };
// Telemetry consent lives in the telemetry state file, not admin config, so
// it has no CONFIG_ENV_MAP entry. Pull it out of the security step and
// persist it directly, mirroring POST /api/admin/telemetry (set-consent).
if (step === 'security' && 'telemetryConsent' in valuesObj) {
const consent = valuesObj.telemetryConsent;
delete valuesObj.telemetryConsent;
if (consent !== 'on' && consent !== 'off') {
return NextResponse.json({ error: 'telemetryConsent must be "on" or "off"' }, { status: 400 });
}
// A BULWARK_TELEMETRY env var hard-locks the choice; don't fight it.
const { source } = await effectiveConsent();
if (source !== 'env') {
const tstate = await loadState();
tstate.consent = consent;
if (consent === 'on' && !tstate.consentedAt) {
tstate.consentedAt = new Date().toISOString();
}
await saveState(tstate);
await reschedule();
}
}
const updates: Record<string, unknown> = {};
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
for (const [key, value] of Object.entries(valuesObj)) {
if (!allowedKeys.includes(key)) {
return NextResponse.json({ error: `Key not allowed in step ${step}: ${key}` }, { status: 400 });
}
+298
View File
@@ -0,0 +1,298 @@
import { NextRequest, NextResponse } from 'next/server';
// Host-side proxy backing the "Translate" plugin (manifest apiPostPaths:
// ["/api/translate"]). The plugin slot iframe POSTs { text, target, source,
// provider } here via api.http.post; we forward to a free translation backend
// and return { translatedText, detectedSource } in a stable shape.
//
// Two providers:
// - "mymemory" — public MyMemory API, no configuration required. Its
// langpair needs an explicit source language, so when the
// plugin asks for "auto" we detect it locally first.
// - "libretranslate" — only available when the host sets LIBRETRANSLATE_URL
// (and optionally LIBRETRANSLATE_API_KEY). Supports native
// source auto-detection.
export const runtime = 'nodejs';
const MAX_CHARS = 5000;
// Long bodies are split into ~480-char chunks for MyMemory and translated
// sequentially, so allow enough headroom for ~10 round-trips.
const TIMEOUT_MS = 25000;
type Provider = 'mymemory' | 'libretranslate';
interface TranslateBody {
text?: unknown;
target?: unknown;
source?: unknown;
provider?: unknown;
}
interface TranslateResult {
translatedText: string;
detectedSource?: string;
}
// ─── Lightweight language detection ───────────────────────────
//
// MyMemory has no auto-detect, so we infer a source language from the text.
// Non-Latin scripts are decided by Unicode range; Latin-script European
// languages are scored by stop-word frequency. Detection only needs to be good
// enough to (a) pick a sensible langpair and (b) let the plugin skip messages
// already in the target language.
const SCRIPT_RANGES: ReadonlyArray<[RegExp, string]> = [
[/[぀-ヿ]/, 'ja'], // Hiragana / Katakana
[/[가-힯]/, 'ko'], // Hangul
[/[一-鿿]/, 'zh'], // CJK ideographs (after JP/KR checks)
[/[Ѐ-ӿ]/, 'ru'], // Cyrillic (ru vs uk refined below)
[/[Ͱ-Ͽ]/, 'el'], // Greek
[/[؀-ۿ]/, 'ar'], // Arabic
[/[֐-׿]/, 'he'], // Hebrew
[/[ऀ-ॿ]/, 'hi'], // Devanagari
];
// Distinctive stop words per Latin-script language from the manifest's option
// list. Kept small and high-signal to avoid cross-language collisions.
const LATIN_STOPWORDS: Record<string, readonly string[]> = {
en: ['the', 'and', 'you', 'that', 'with', 'for', 'this', 'have', 'are'],
de: ['der', 'die', 'und', 'das', 'ist', 'nicht', 'mit', 'sie', 'ein', 'auch'],
fr: ['les', 'des', 'une', 'est', 'pour', 'que', 'vous', 'dans', 'avec', 'pas'],
es: ['que', 'los', 'una', 'por', 'con', 'para', 'como', 'pero', 'más', 'esta'],
it: ['che', 'non', 'per', 'una', 'sono', 'con', 'come', 'questo', 'anche', 'della'],
pt: ['que', 'não', 'uma', 'com', 'para', 'como', 'mais', 'você', 'está', 'isso'],
nl: ['het', 'een', 'van', 'dat', 'niet', 'met', 'voor', 'aan', 'zijn', 'maar'],
pl: ['nie', 'jest', 'się', 'ale', 'oraz', 'tego', 'jak', 'tym', 'przez', 'dla'],
sv: ['och', 'att', 'det', 'som', 'för', 'med', 'inte', 'den', 'till', 'har'],
no: ['og', 'det', 'som', 'for', 'med', 'ikke', 'har', 'til', 'denne', 'jeg'],
da: ['og', 'det', 'som', 'for', 'med', 'ikke', 'har', 'til', 'denne', 'jeg'],
fi: ['että', 'olen', 'tämä', 'kanssa', 'mutta', 'sekä', 'ei', 'on', 'ja', 'jotta'],
cs: ['není', 'jsem', 'pro', 'ale', 'jako', 'tento', 'také', 'přes', 'jsou', 'své'],
ro: ['este', 'pentru', 'care', 'dar', 'sunt', 'această', 'mai', 'din', 'sau', 'nu'],
hu: ['hogy', 'nem', 'egy', 'van', 'ezt', 'vagy', 'mint', 'csak', 'ezzel', 'így'],
tr: ['bir', 'için', 'değil', 'bu', 'çok', 'daha', 'ama', 'gibi', 've', 'ile'],
};
function detectLanguage(text: string): string {
const sample = text.slice(0, 1000);
for (const [range, lang] of SCRIPT_RANGES) {
if (range.test(sample)) {
// Ukrainian shares Cyrillic with Russian; its unique glyphs decide it.
if (lang === 'ru' && /[єіїґ]/.test(sample)) return 'uk';
return lang;
}
}
const words = sample.toLowerCase().match(/[a-zà-ÿčśžłńęąółżźć]+/gi) || [];
if (words.length === 0) return 'en';
const counts: Record<string, number> = {};
const wordSet = new Set(words);
for (const [lang, stops] of Object.entries(LATIN_STOPWORDS)) {
let score = 0;
for (const stop of stops) if (wordSet.has(stop)) score += 1;
counts[lang] = score;
}
let best = 'en';
let bestScore = -1;
for (const [lang, score] of Object.entries(counts)) {
if (score > bestScore) {
best = lang;
bestScore = score;
}
}
return bestScore > 0 ? best : 'en';
}
function baseLang(code: string): string {
return String(code || '').toLowerCase().split('-')[0];
}
// ─── Chunking ─────────────────────────────────────────────────
//
// MyMemory's free endpoint caps each request's `q` at 500 characters and
// silently returns only the translated prefix beyond that — which is why long
// emails came back truncated ("…about Sel…"). We split the text into
// line-aware chunks under the limit, translate each, then rejoin so the whole
// body is covered.
const MYMEMORY_CHUNK = 480;
function chunkText(text: string, max: number): string[] {
const chunks: string[] = [];
let cur = '';
const flush = () => {
if (cur) {
chunks.push(cur);
cur = '';
}
};
for (const line of text.split('\n')) {
if (line.length > max) {
flush();
// A single over-long line (e.g. a long URL list): split on words, and
// hard-cut any word that is itself longer than the limit.
let seg = '';
for (const word of line.split(' ')) {
const piece = seg ? seg + ' ' + word : word;
if (piece.length > max) {
if (seg) {
chunks.push(seg);
seg = '';
}
if (word.length > max) {
for (let i = 0; i < word.length; i += max) chunks.push(word.slice(i, i + max));
} else {
seg = word;
}
} else {
seg = piece;
}
}
if (seg) chunks.push(seg);
continue;
}
if (cur && cur.length + 1 + line.length > max) flush();
cur = cur ? cur + '\n' + line : line;
}
flush();
return chunks;
}
// ─── Providers ────────────────────────────────────────────────
async function mymemoryRequest(
q: string,
langpair: string,
signal: AbortSignal,
): Promise<string> {
const url = new URL('https://api.mymemory.translated.net/get');
url.searchParams.set('q', q);
url.searchParams.set('langpair', langpair);
const res = await fetch(url.toString(), {
signal,
headers: { 'User-Agent': 'JMAP-Webmail/1.0 Translate-Plugin' },
});
const data = (await res.json().catch(() => null)) as
| { responseStatus?: number | string; responseData?: { translatedText?: string }; responseDetails?: string }
| null;
if (!res.ok || !data) {
throw new Error(`MyMemory returned ${res.status}`);
}
const status = Number(data.responseStatus);
if (status && status !== 200) {
throw new Error(data.responseDetails || `MyMemory error ${status}`);
}
const translatedText = data.responseData?.translatedText || '';
if (!translatedText) {
throw new Error('MyMemory returned no translation');
}
return translatedText;
}
async function translateMyMemory(
text: string,
source: string,
target: string,
signal: AbortSignal,
): Promise<TranslateResult> {
const detected = source === 'auto' || !source ? detectLanguage(text) : baseLang(source);
const tgt = baseLang(target);
// Nothing to do if already in the target language; the plugin skips display.
if (detected === tgt) {
return { translatedText: text, detectedSource: detected };
}
const langpair = `${detected}|${tgt}`;
const chunks = chunkText(text, MYMEMORY_CHUNK);
// Sequential to stay friendly to MyMemory's free-tier rate limits; emails are
// usually one or two chunks.
const translated: string[] = [];
for (const chunk of chunks) {
translated.push(await mymemoryRequest(chunk, langpair, signal));
}
return { translatedText: translated.join('\n'), detectedSource: detected };
}
async function translateLibre(
text: string,
source: string,
target: string,
signal: AbortSignal,
): Promise<TranslateResult> {
const endpoint = process.env.LIBRETRANSLATE_URL;
if (!endpoint) {
throw new Error('LibreTranslate is not configured on this server');
}
const apiKey = process.env.LIBRETRANSLATE_API_KEY;
const url = endpoint.replace(/\/+$/, '') + '/translate';
const res = await fetch(url, {
method: 'POST',
signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
q: text,
source: source || 'auto',
target: baseLang(target),
format: 'text',
...(apiKey ? { api_key: apiKey } : {}),
}),
});
const data = (await res.json().catch(() => null)) as
| { translatedText?: string; detectedLanguage?: { language?: string }; error?: string }
| null;
if (!res.ok || !data) {
throw new Error(data?.error || `LibreTranslate returned ${res.status}`);
}
if (!data.translatedText) {
throw new Error(data.error || 'LibreTranslate returned no translation');
}
return {
translatedText: data.translatedText,
detectedSource: data.detectedLanguage?.language || (source !== 'auto' ? baseLang(source) : undefined),
};
}
// ─── Route ────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
let body: TranslateBody;
try {
body = (await request.json()) as TranslateBody;
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const text = typeof body.text === 'string' ? body.text.trim() : '';
const target = typeof body.target === 'string' && body.target.trim() ? body.target.trim() : 'en';
const source = typeof body.source === 'string' && body.source.trim() ? body.source.trim() : 'auto';
const provider: Provider = body.provider === 'libretranslate' ? 'libretranslate' : 'mymemory';
if (!text) {
return NextResponse.json({ error: 'No text to translate' }, { status: 400 });
}
if (text.length > MAX_CHARS) {
return NextResponse.json(
{ error: `Text exceeds the ${MAX_CHARS}-character limit` },
{ status: 413 },
);
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const result =
provider === 'libretranslate'
? await translateLibre(text, source, target, controller.signal)
: await translateMyMemory(text, source, target, controller.signal);
return NextResponse.json(result, { status: 200 });
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') {
return NextResponse.json({ error: 'Translation timed out' }, { status: 504 });
}
const message = error instanceof Error ? error.message : 'Translation failed';
return NextResponse.json({ error: message }, { status: 502 });
} finally {
clearTimeout(timeout);
}
}
+23 -6
View File
@@ -70,7 +70,7 @@
}
.dark {
--color-border: #262626;
--color-border: rgba(128, 128, 128, 0.3);
--color-input: #262626;
--color-ring: #d4d4d4;
--color-background: #0a0a0a;
@@ -660,13 +660,13 @@ body {
.tiptap ul {
list-style-type: disc;
padding-left: 1.5rem;
padding-inline-start: 1.5rem;
margin: 0.25rem 0;
}
.tiptap ol {
list-style-type: decimal;
padding-left: 1.5rem;
padding-inline-start: 1.5rem;
margin: 0.25rem 0;
}
@@ -675,8 +675,8 @@ body {
}
.tiptap blockquote {
border-left: 3px solid var(--color-border);
padding-left: 1rem;
border-inline-start: 3px solid var(--color-border);
padding-inline-start: 1rem;
margin: 0.5rem 0;
color: var(--color-muted-foreground);
}
@@ -719,7 +719,7 @@ body {
.tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
float: inline-start;
color: var(--color-muted-foreground);
pointer-events: none;
height: 0;
@@ -808,3 +808,20 @@ body {
border-radius: 8px;
animation: settings-search-pulse 1.6s ease-in-out forwards;
}
/* RTL: mirror directional icons (chevrons/arrows) so prev/next, back/forward,
and panel-collapse affordances point the correct way in right-to-left layouts.
lucide-react emits a `lucide-<name>` class per icon, so we target the
directional ones only vertical chevrons (up/down) are intentionally left. */
[dir="rtl"] .lucide-chevron-left,
[dir="rtl"] .lucide-chevron-right,
[dir="rtl"] .lucide-chevrons-left,
[dir="rtl"] .lucide-chevrons-right,
[dir="rtl"] .lucide-arrow-left,
[dir="rtl"] .lucide-arrow-right,
[dir="rtl"] .lucide-arrow-big-left,
[dir="rtl"] .lucide-arrow-big-right,
[dir="rtl"] .lucide-panel-left,
[dir="rtl"] .lucide-panel-right {
transform: scaleX(-1);
}
+51 -14
View File
@@ -1,5 +1,12 @@
import type { MetadataRoute } from "next";
import { headers } from "next/headers";
import { configManager } from "@/lib/admin/config-manager";
import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
type BrandingOverrideKey,
} from "@/lib/admin/domain-branding";
export const dynamic = "force-dynamic";
@@ -25,25 +32,40 @@ const withBase = (p: string) => `${BASE_PATH}${p}`;
export default async function manifest(): Promise<ExtendedManifest> {
await configManager.ensureLoaded();
const host = pickRequestHost(await headers());
const domainOverrides = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
);
const branded = <T,>(key: BrandingOverrideKey, fallback: T): T => {
const override = domainOverrides[key];
if (typeof override === "string" && override.length > 0) return override as T;
return configManager.get<T>(key, fallback);
};
const appName =
configManager.get<string>("appName") ||
branded<string>("appName", "") ||
process.env.NEXT_PUBLIC_APP_NAME ||
"Bulwark Webmail";
const shortName = configManager.get<string>("appShortName") || appName;
const shortName = branded<string>("appShortName", "") || appName;
const description =
configManager.get<string>("appDescription") ||
branded<string>("appDescription", "") ||
"A modern webmail client built for Stalwart Mail Server";
const themeColor = configManager.get<string>("pwaThemeColor") || "#ffffff";
const backgroundColor = configManager.get<string>("pwaBackgroundColor") || "#ffffff";
const themeColor = branded<string>("pwaThemeColor", "") || "#ffffff";
const backgroundColor = branded<string>("pwaBackgroundColor", "") || "#ffffff";
// If pwaIconUrl or faviconUrl was explicitly configured (admin override or
// env var), serve dynamically resized PNGs via /api/pwa-icon/[size].
// Otherwise fall back to the static Bulwark PNGs - sources marked "default"
// are the built-in placeholder paths and not real custom icons.
// If pwaIconUrl or faviconUrl was explicitly configured (admin override,
// env var, or per-domain override), serve dynamically resized PNGs via
// /api/pwa-icon/[size]. Otherwise fall back to the static Bulwark PNGs -
// sources marked "default" are the built-in placeholder paths and not
// real custom icons.
const sources = configManager.getAllWithSources();
const hasCustomIcon =
sources.pwaIconUrl?.source !== "default" || sources.faviconUrl?.source !== "default";
!!domainOverrides.pwaIconUrl ||
!!domainOverrides.faviconUrl ||
sources.pwaIconUrl?.source !== "default" ||
sources.faviconUrl?.source !== "default";
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
? [
@@ -73,10 +95,25 @@ export default async function manifest(): Promise<ExtendedManifest> {
background_color: backgroundColor,
icons,
categories: ["productivity"],
screenshots: [
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
],
// Use admin-uploaded screenshots when configured (per-domain override,
// admin/env global; resized on the fly via /api/pwa-screenshot/[variant]);
// otherwise fall back to the built-in Bulwark screenshots from public/.
screenshots: (() => {
const hasMobile =
!!domainOverrides.pwaScreenshotMobileUrl ||
sources.pwaScreenshotMobileUrl?.source !== "default";
const hasDesktop =
!!domainOverrides.pwaScreenshotDesktopUrl ||
sources.pwaScreenshotDesktopUrl?.source !== "default";
return [
hasMobile
? { src: withBase("/api/pwa-screenshot/mobile"), sizes: "540x720", type: "image/png" }
: { src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
hasDesktop
? { src: withBase("/api/pwa-screenshot/desktop"), sizes: "1280x720", type: "image/png" }
: { src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
];
})(),
protocol_handlers: [
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
+10 -5
View File
@@ -5,7 +5,7 @@ import { useTranslations, useFormatter } from "next-intl";
import { format, isToday, isTomorrow, startOfDay } from "date-fns";
import { MapPin, Users } from "lucide-react";
import { cn } from "@/lib/utils";
import { parseDuration, getEventColor } from "./event-card";
import { getEventColor } from "./event-card";
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { getParticipantCount } from "@/lib/calendar-participants";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
@@ -131,7 +131,7 @@ export function CalendarAgendaView({
)}>
{formatDateHeader(group.date)}
</span>
<span className="text-xs text-muted-foreground ml-2">
<span className="text-xs text-muted-foreground ms-2">
{intlFormatter.dateTime(group.date, { month: "short", day: "numeric", year: "numeric" })}
</span>
</div>
@@ -147,8 +147,10 @@ export function CalendarAgendaView({
const calendar = calId ? calendarMap.get(calId) : undefined;
const color = getEventColor(ev, calendar);
const start = getEventStartDate(ev);
const durMin = parseDuration(ev.duration);
const end = getEventEndDate(ev);
// iTIP CANCEL marks the attendee's copy with status "cancelled"
// instead of deleting it (#572).
const isCancelled = ev.status === "cancelled";
const locationName = ev.locations
? Object.values(ev.locations)[0]?.name
: null;
@@ -160,7 +162,10 @@ export function CalendarAgendaView({
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onHoverLeave?.()}
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left"
className={cn(
"w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-start",
isCancelled && "opacity-60"
)}
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
<div className="flex flex-col items-center pt-0.5 min-w-[60px]">
@@ -182,7 +187,7 @@ export function CalendarAgendaView({
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
<div className={cn("text-sm font-medium truncate", isCancelled && "line-through")}>
{ev.title || t("events.no_title")}
</div>
{locationName && (
+4 -4
View File
@@ -218,7 +218,7 @@ export function CalendarDayView({
{HOURS.map((h) => (
<div
key={h}
className="relative text-muted-foreground text-right pr-2"
className="relative text-muted-foreground text-end pe-2"
style={{ height: HOUR_HEIGHT }}
>
{h > 0 && (
@@ -231,7 +231,7 @@ export function CalendarDayView({
</div>
<div
className="flex-1 relative border-l border-border"
className="flex-1 relative border-s border-border"
role="row"
aria-label={t("views.day")}
onPointerDown={(e) => handleGridPointerDown(e, dayKey, selectedDate)}
@@ -312,7 +312,7 @@ export function CalendarDayView({
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2.5 h-2.5 rounded-full bg-destructive -ml-1" />
<div className="w-2.5 h-2.5 rounded-full bg-destructive -ms-1" />
<div className="flex-1 h-px bg-destructive" />
</div>
</div>
@@ -346,7 +346,7 @@ export function CalendarDayView({
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2.5 h-2.5 rounded-full bg-primary -ml-1" />
<div className="w-2.5 h-2.5 rounded-full bg-primary -ms-1" />
<div className="flex-1 h-0.5 bg-primary rounded-full" />
</div>
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
+28 -29
View File
@@ -1,11 +1,8 @@
"use client";
import { useMemo, useState, useCallback, type DragEvent } from "react";
import { useTranslations, useFormatter } from "next-intl";
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
eachDayOfInterval, isSameDay, isSameMonth, isToday, format, parseISO,
} from "date-fns";
import { useTranslations } from "next-intl";
import { format, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import { EventCard } from "./event-card";
import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
@@ -14,6 +11,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
import type { PendingEventPreview } from "./event-modal";
import { toast } from "@/stores/toast-store";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
interface CalendarMonthViewProps {
selectedDate: Date;
@@ -47,16 +45,21 @@ export function CalendarMonthView({
pendingPreview,
}: CalendarMonthViewProps) {
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
const {
weekStartsOn,
dayHeaderKeys,
getMonthGridDays,
checkIsToday,
checkIsSameMonth,
checkIsSameDay,
formatDayNumber,
formatFullDate,
} = useCalendarLocale();
const days = useMemo(() => {
const monthStart = startOfMonth(selectedDate);
const monthEnd = endOfMonth(selectedDate);
const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart });
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart });
return eachDayOfInterval({ start: gridStart, end: gridEnd });
}, [selectedDate, weekStart]);
const days = useMemo(
() => getMonthGridDays(selectedDate),
[selectedDate, getMonthGridDays],
);
const calendarMap = useMemo(() => {
const map = new Map<string, Calendar>();
@@ -83,10 +86,6 @@ export function CalendarMonthView({
return map;
}, [events]);
const dayHeaders = firstDayOfWeek === 0
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
const weeks = useMemo(() => {
const result: Date[][] = [];
for (let i = 0; i < days.length; i += 7) {
@@ -141,11 +140,11 @@ export function CalendarMonthView({
}, [t]);
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { month: "long", year: "numeric" })}>
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={formatFullDate(selectedDate)}>
<div className="grid grid-cols-7 border-b border-border" role="row">
{dayHeaders.map((d) => (
{dayHeaderKeys.map((d) => (
<div key={d} role="columnheader" className={cn(
"text-center text-xs font-medium text-muted-foreground py-2 border-r border-border last:border-r-0",
"text-center text-xs font-medium text-muted-foreground py-2 border-e border-border last:border-e-0",
isMobile && "py-1.5 text-[11px]"
)}>
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
@@ -161,12 +160,12 @@ export function CalendarMonthView({
)} role="row" style={isMobile ? undefined : { minHeight: Math.max(100, 34 + rowCount * 22 + 8) }}>
<div className="grid grid-cols-7 h-full">
{week.map((day) => {
const inMonth = isSameMonth(day, selectedDate);
const selected = isSameDay(day, selectedDate);
const today = isToday(day);
const inMonth = checkIsSameMonth(day, selectedDate);
const selected = checkIsSameDay(day, selectedDate);
const today = checkIsToday(day);
const key = format(day, "yyyy-MM-dd");
const dayEvents = eventsByDate.get(key) || [];
const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
const fullDateLabel = formatFullDate(day);
return (
<div
@@ -181,7 +180,7 @@ export function CalendarMonthView({
onDragLeave={handleCellDragLeave}
onDrop={(e) => handleCellDrop(e, day)}
className={cn(
"border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors touch-manipulation",
"border-e border-border last:border-e-0 p-1 cursor-pointer transition-colors touch-manipulation",
!inMonth && "bg-muted/30",
"hover:bg-muted/50",
selected && isMobile && "bg-primary/10",
@@ -199,7 +198,7 @@ export function CalendarMonthView({
inMonth && !selected && !today && "font-medium"
)}
>
{format(day, "d")}
{formatDayNumber(day)}
</span>
</div>
{isMobile ? (
@@ -219,7 +218,7 @@ export function CalendarMonthView({
{dayEvents.length > 3 && (
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
)}
{pendingPreview && isSameDay(pendingPreview.start, day) && (
{pendingPreview && checkIsSameDay(pendingPreview.start, day) && (
<span
className="w-1.5 h-1.5 rounded-full border border-dashed"
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
@@ -233,7 +232,7 @@ export function CalendarMonthView({
</div>
{!isMobile && pendingPreview && (() => {
const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start));
const previewDayIdx = week.findIndex(d => checkIsSameDay(d, pendingPreview.start));
if (previewDayIdx === -1) return null;
const previewRow = rowCount;
const cal = calendarMap.get(pendingPreview.calendarId);
+37 -5
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette } from "lucide-react";
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -11,6 +11,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useTaskStore } from "@/stores/task-store";
import { useAccountStore } from "@/stores/account-store";
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { sharedCalendarColorKey } from "@/lib/shared-calendar-colors";
import { toast } from "@/stores/toast-store";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
@@ -50,6 +51,7 @@ interface CalendarSidebarPanelProps {
selectedCalendarIds: string[];
onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void;
onResetColor?: (calendar: Calendar) => void;
onShareCalendar?: (calendar: Calendar) => void;
onCreateEvent?: (calendar: Calendar) => void;
onClearCalendar?: (calendar: Calendar) => void;
@@ -71,6 +73,7 @@ export function CalendarSidebarPanel({
selectedCalendarIds,
onToggleVisibility,
onColorChange,
onResetColor,
onShareCalendar,
onCreateEvent,
onClearCalendar,
@@ -93,7 +96,9 @@ export function CalendarSidebarPanel({
);
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
const tasks = useTaskStore((s) => s.tasks);
const setViewMode = useCalendarStore((s) => s.setViewMode);
@@ -215,6 +220,16 @@ export function CalendarSidebarPanel({
}
};
const handleSetDefault = async (calendarId: string) => {
if (!client) return;
try {
await setDefaultCalendar(client, calendarId);
toast.success(tMgmt('default_updated'));
} catch {
toast.error(tMgmt('error_default'));
}
};
if (calendars.length === 0 && !onSubscribe) return null;
const renderCalendarItem = (cal: Calendar) => {
@@ -302,10 +317,13 @@ export function CalendarSidebarPanel({
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
const canSetDefault = !!client && !isBirthday && !cal.isShared && !cal.isDefault;
const canChangeColor = !!onColorChange;
const hasColorOverride = !!cal.isShared && !!sharedCalendarColors[sharedCalendarColorKey(cal)];
const canResetColor = !!onResetColor && hasColorOverride;
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete);
const showSeparator = (canCreate || canShare || canSetDefault || canChangeColor || canResetColor) && (canClear || canDelete);
const color = cal.color || "#3b82f6";
return (
@@ -324,6 +342,13 @@ export function CalendarSidebarPanel({
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
/>
)}
{canSetDefault && (
<ContextMenuItem
icon={Star}
label={tMgmt('set_default')}
onClick={() => { closeContextMenu(); handleSetDefault(cal.id); }}
/>
)}
{canChangeColor && (
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
<div className="px-2 py-1.5 w-[200px]">
@@ -335,6 +360,13 @@ export function CalendarSidebarPanel({
</div>
</ContextMenuSubMenu>
)}
{canResetColor && (
<ContextMenuItem
icon={Shuffle}
label={tMgmt('random_color')}
onClick={() => { closeContextMenu(); onResetColor!(cal); }}
/>
)}
{showSeparator && <ContextMenuSeparator />}
{canClear && (
<ContextMenuItem
@@ -365,7 +397,7 @@ export function CalendarSidebarPanel({
<ListTodo className="w-4 h-4 text-muted-foreground" />
<span>{t('tasks.label')}</span>
{pendingTaskCount > 0 && (
<span className="ml-auto text-xs text-muted-foreground">{pendingTaskCount}</span>
<span className="ms-auto text-xs text-muted-foreground">{pendingTaskCount}</span>
)}
{overdueTaskCount > 0 && (
<span className="text-xs text-destructive font-medium">{overdueTaskCount} {t('tasks.filter_overdue').toLowerCase()}</span>
@@ -405,7 +437,7 @@ export function CalendarSidebarPanel({
onCreateCalendar();
}
}}
className="ml-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
className="ms-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
title={tMgmt('add_calendar')}
>
<Plus className="w-3 h-3" />
@@ -413,7 +445,7 @@ export function CalendarSidebarPanel({
)}
</button>
{expanded && (
<div className="mt-1 pl-3">
<div className="mt-1 ps-3">
{owned.length > 0 && (
<div>
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
+30 -28
View File
@@ -1,13 +1,14 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react";
import { addDays, startOfWeek } from "date-fns";
import { startOfWeek } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store";
import type { Calendar } from "@/lib/jmap/types";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
interface CalendarToolbarProps {
selectedDate: Date;
@@ -50,7 +51,14 @@ export function CalendarToolbar({
onMenuClick,
}: CalendarToolbarProps) {
const t = useTranslations("calendar");
const formatter = useFormatter();
const {
weekStartsOn,
formatMonthYear,
formatMonthYearShort,
formatWeekRange,
formatWeekRangeShort,
formatFullDate,
} = useCalendarLocale();
const views: CalendarViewMode[] = enableCalendarTasks
? ["month", "week", "day", "agenda", "tasks"]
: ["month", "week", "day", "agenda"];
@@ -72,28 +80,22 @@ export function CalendarToolbar({
switch (viewMode) {
case "month":
return isMobile
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
? formatMonthYearShort(selectedDate)
: formatMonthYear(selectedDate);
case "week": {
const ws = startOfWeek(selectedDate, { weekStartsOn: firstDayOfWeek as 0 | 1 });
const we = addDays(ws, 6);
if (isMobile) {
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} ${formatter.dateTime(we, { day: "numeric" })}`;
}
const sameMonth = ws.getMonth() === we.getMonth();
if (sameMonth) {
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} ${formatter.dateTime(we, { day: "numeric" })}, ${we.getFullYear()}`;
}
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} ${formatter.dateTime(we, { month: "short", day: "numeric" })}, ${we.getFullYear()}`;
const ws = startOfWeek(selectedDate, { weekStartsOn });
return isMobile
? formatWeekRangeShort(ws)
: formatWeekRange(ws);
}
case "day":
return isMobile
? formatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" })
: formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
? formatFullDate(selectedDate)
: formatFullDate(selectedDate);
case "agenda":
return isMobile
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
? formatMonthYearShort(selectedDate)
: formatMonthYear(selectedDate);
case "tasks":
return t("views.tasks");
}
@@ -124,7 +126,7 @@ export function CalendarToolbar({
variant="ghost"
size="icon"
onClick={onMenuClick}
className="h-8 w-8 -ml-1 mr-1"
className="h-8 w-8 -ms-1 me-1"
aria-label={t("nav_open_menu")}
>
<Menu className="w-4 h-4" />
@@ -138,7 +140,7 @@ export function CalendarToolbar({
{onMenuClick && (
<button
onClick={onMenuClick}
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
className="p-1.5 -ms-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
aria-label={t("nav_open_menu")}
>
<Menu className="w-4 h-4" />
@@ -147,7 +149,7 @@ export function CalendarToolbar({
{onNavigateBack && (
<button
onClick={onNavigateBack}
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
className="p-1.5 -ms-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
aria-label={t("back_to_month")}
>
<ArrowLeft className="w-4 h-4" />
@@ -162,7 +164,7 @@ export function CalendarToolbar({
<button onClick={onNext} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</button>
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ml-0.5">
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ms-0.5">
{t("views.today")}
</Button>
</div>
@@ -284,7 +286,7 @@ export function CalendarToolbar({
{/* ── DESKTOP TOOLBAR ── */}
{!isMobile && (
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={onToday} className="h-8 mr-1">
<Button variant="outline" size="sm" onClick={onToday} className="h-8 me-1">
{t("views.today")}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
@@ -293,7 +295,7 @@ export function CalendarToolbar({
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</Button>
<span className="text-base font-semibold ml-2 select-none">
<span className="text-base font-semibold ms-2 select-none">
{getDateLabel()}
</span>
</div>
@@ -328,9 +330,9 @@ export function CalendarToolbar({
{(onImport || onSubscribe) && !isMobile && (
<div className="relative" ref={importDropdownRef}>
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowImportDropdown((v) => !v)}>
<Upload className="w-4 h-4 mr-1" />
<Upload className="w-4 h-4 me-1" />
{t("import.title")}
<ChevronDown className="w-3 h-3 ml-1" />
<ChevronDown className="w-3 h-3 ms-1" />
</Button>
{showImportDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]">
@@ -359,7 +361,7 @@ export function CalendarToolbar({
{!isMobile && (
<Button size="sm" className="h-8" onClick={onCreateEvent} data-tour="create-event-button">
<Plus className="w-4 h-4 mr-1" />
<Plus className="w-4 h-4 me-1" />
{t("events.create")}
</Button>
)}
+9 -9
View File
@@ -58,7 +58,7 @@ export function CalendarWeekView({
const intlFormatter = useFormatter();
const scrollRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
const weekStart = (firstDayOfWeek === 0 ? 0 : firstDayOfWeek === 6 ? 6 : 1) as 0 | 1 | 6;
const weekDays = useMemo(() => {
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
@@ -211,7 +211,7 @@ export function CalendarWeekView({
<div className={cn("flex min-h-0 flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
<div className="flex border-b border-border">
<div
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-end", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
>
{t("events.all_day")}
@@ -306,7 +306,7 @@ export function CalendarWeekView({
<div className="flex border-b border-border" role="row">
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")} />
<div className="flex-1 border-l border-border grid grid-cols-7">
<div className="flex-1 border-s border-border grid grid-cols-7">
{weekDays.map((day) => {
const todayCol = isToday(day);
const selected = isSameDay(day, selectedDate);
@@ -318,7 +318,7 @@ export function CalendarWeekView({
role="columnheader"
aria-label={fullLabel}
className={cn(
"text-center py-2 text-sm border-r border-border last:border-r-0 transition-colors touch-manipulation",
"text-center py-2 text-sm border-e border-border last:border-e-0 transition-colors touch-manipulation",
"hover:bg-muted/50",
todayCol && "font-bold",
)}
@@ -345,7 +345,7 @@ export function CalendarWeekView({
{HOURS.map((h) => (
<div
key={h}
className="relative text-muted-foreground text-right pr-2"
className="relative text-muted-foreground text-end pe-2"
style={{ height: HOUR_HEIGHT }}
>
{h > 0 && (
@@ -357,7 +357,7 @@ export function CalendarWeekView({
))}
</div>
<div className="flex-1 border-l border-border relative grid grid-cols-7">
<div className="flex-1 border-s border-border relative grid grid-cols-7">
{weekDays.map((day) => {
const key = format(day, "yyyy-MM-dd");
const dayEvents = timedEvents.get(key) || [];
@@ -367,7 +367,7 @@ export function CalendarWeekView({
return (
<div
key={key}
className="relative border-r border-border last:border-r-0"
className="relative border-e border-border last:border-e-0"
role="row"
aria-label={intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" })}
onPointerDown={(e) => handleGridPointerDown(e, key, day)}
@@ -448,7 +448,7 @@ export function CalendarWeekView({
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-destructive -ml-1" />
<div className="w-2 h-2 rounded-full bg-destructive -ms-1" />
<div className="flex-1 h-px bg-destructive" />
</div>
</div>
@@ -482,7 +482,7 @@ export function CalendarWeekView({
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-primary -ml-1" />
<div className="w-2 h-2 rounded-full bg-primary -ms-1" />
<div className="flex-1 h-0.5 bg-primary rounded-full" />
</div>
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
@@ -137,7 +137,7 @@ export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProp
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
<Loader2 className="w-4 h-4 animate-spin me-2" />
{tCommon("loading")}
</>
) : (
+20 -9
View File
@@ -34,6 +34,11 @@ function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"):
}
function getEventColor(event: CalendarEvent, calendar?: Calendar): string {
// A local color override on a shared calendar wins over per-event colors,
// so the whole shared calendar paints uniformly in the viewer's chosen hue.
if (calendar?.colorIsLocalOverride && calendar.color) {
return sanitizeColor(calendar.color);
}
return sanitizeColor(event.color, sanitizeColor(calendar?.color));
}
@@ -66,7 +71,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
return el;
}
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesAfter = false, className, style }: EventCardProps) {
const t = useTranslations("calendar");
const [isBeingDragged, setIsBeingDragged] = useState(false);
const color = getEventColor(event, calendar);
@@ -83,7 +88,10 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
try { return format(d, fmt); } catch { return "--:--"; }
};
const timeString = `${safeFormat(startDate, timeFmt)} ${safeFormat(endTime, timeFmt)}`;
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
// iTIP CANCEL marks the attendee's copy with status "cancelled" instead of
// deleting it (#572) - render it struck through and dimmed.
const isCancelled = event.status === "cancelled";
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}${isCancelled ? `, ${t("detail.cancelled")}` : ""}`;
const handleDragStart = useCallback((e: DragEvent) => {
e.stopPropagation();
@@ -126,11 +134,12 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
aria-label={ariaLabel}
{...dragProps}
className={cn(
"flex items-center gap-1 w-full text-left text-xs px-1 py-0.5 rounded truncate",
"flex items-center gap-1 w-full text-start text-xs px-1 py-0.5 rounded truncate",
"min-h-[44px] sm:min-h-0",
"hover:opacity-80 transition-opacity",
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50",
isCancelled && !isBeingDragged && "opacity-60",
className
)}
style={{ backgroundColor: `${color}20`, color, ...style }}
@@ -139,7 +148,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ backgroundColor: color }}
/>
<span className="truncate">{event.title || t("events.no_title")}</span>
<span className={cn("truncate", isCancelled && "line-through")}>{event.title || t("events.no_title")}</span>
</button>
);
}
@@ -154,12 +163,13 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
aria-label={ariaLabel}
{...dragProps}
className={cn(
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"w-full h-full text-start rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
continuesAfter && "rounded-r-sm",
continuesAfter && "pr-2",
continuesAfter && "pe-2",
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50",
isCancelled && !isBeingDragged && "opacity-60",
className
)}
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
@@ -168,7 +178,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
{showTimeInMonthView && !event.showWithoutTime && (
<span className="flex-shrink-0 opacity-80">{format(startDate, timeFmt)}</span>
)}
<span className="truncate font-medium">{event.title || t("events.no_title")}</span>
<span className={cn("truncate font-medium", isCancelled && "line-through")}>{event.title || t("events.no_title")}</span>
</div>
</button>
);
@@ -184,15 +194,16 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
{...dragProps}
data-calendar-event
className={cn(
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"w-full h-full text-start rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50",
isCancelled && !isBeingDragged && "opacity-60",
className
)}
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color, ...style }}
>
<div className="font-medium truncate">{event.title || t("events.no_title")}</div>
<div className={cn("font-medium truncate", isCancelled && "line-through")}>{event.title || t("events.no_title")}</div>
{!event.showWithoutTime && (
<div className="opacity-80 text-[10px]">
{timeString}
+25 -27
View File
@@ -1,18 +1,19 @@
"use client";
import { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect } from "react";
import { useTranslations } from "next-intl";
import { useTranslations, useLocale } from "next-intl";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import {
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
Pencil, Trash2, Copy, Send, Check,
} from "lucide-react";
import { format, isSameDay, parseISO } from "date-fns";
import { format, isSameDay } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { buildRecurrenceSummary } from "./recurrence-editor";
import {
isOrganizer,
getUserParticipantId,
@@ -101,16 +102,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
return null;
}
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
if (!event.recurrenceRules?.length) return null;
const freq = event.recurrenceRules[0].frequency;
const labels: Record<string, string> = {
daily: t("recurrence.daily"),
weekly: t("recurrence.weekly"),
monthly: t("recurrence.monthly"),
yearly: t("recurrence.yearly"),
};
return labels[freq] || null;
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
}
export function EventDetailPopover({
@@ -130,6 +124,7 @@ export function EventDetailPopover({
isMobile,
}: EventDetailPopoverProps) {
const t = useTranslations("calendar");
const locale = useLocale();
const popoverRef = useRef<HTMLDivElement>(null);
const noteInputRef = useRef<HTMLTextAreaElement>(null);
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
@@ -158,7 +153,7 @@ export function EventDetailPopover({
}, [event.virtualLocations]);
const participants = useMemo(() => getParticipantList(event), [event]);
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t), [event, t]);
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t, locale), [event, t, locale]);
const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]);
const userIsOrganizer = useMemo(() => {
@@ -295,20 +290,23 @@ export function EventDetailPopover({
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: color }}
/>
<h3 className="text-base font-semibold truncate text-foreground">
<h3 className={cn(
"text-base font-semibold truncate text-foreground",
event.status === "cancelled" && "line-through text-muted-foreground"
)}>
{event.title || t("events.no_title")}
</h3>
</div>
{calendar && (
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">
<p className="text-xs text-muted-foreground mt-0.5 ps-[18px]">
{calendar.name}
{event.status === "tentative" && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
<span className="ms-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
{t("detail.tentative")}
</span>
)}
{event.status === "cancelled" && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 line-through">
<span className="ms-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 line-through">
{t("detail.cancelled")}
</span>
)}
@@ -348,13 +346,13 @@ export function EventDetailPopover({
<>
<div className="font-medium text-foreground">
{formatEventDate(startDate)}
<span className="ml-1.5 font-normal text-muted-foreground">
<span className="ms-1.5 font-normal text-muted-foreground">
{formatTime(startDate)}
</span>
</div>
<div className="font-medium text-foreground">
{formatEventDate(endDate)}
<span className="ml-1.5 font-normal text-muted-foreground">
<span className="ms-1.5 font-normal text-muted-foreground">
{formatTime(endDate)}
</span>
</div>
@@ -369,11 +367,11 @@ export function EventDetailPopover({
{formatEventDate(startDate)}
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
<span className="text-muted-foreground ms-1.5">{t("events.all_day")}</span>
) : (
<div className="text-muted-foreground">
{formatTime(startDate)} {formatTime(endDate)}
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
<span className="ms-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
</div>
)}
</>
@@ -439,7 +437,7 @@ export function EventDetailPopover({
<span className="truncate text-foreground">
{p.name || p.email}
{p.isOrganizer && (
<span className="text-muted-foreground ml-1">
<span className="text-muted-foreground ms-1">
({t("participants.organizer").toLowerCase()})
</span>
)}
@@ -517,7 +515,7 @@ export function EventDetailPopover({
disabled={!noteText.trim() || isSavingNote}
className="h-7 text-xs"
>
<Send className="w-3 h-3 mr-1" />
<Send className="w-3 h-3 me-1" />
{t("detail.save_note")}
</Button>
</div>
@@ -551,7 +549,7 @@ export function EventDetailPopover({
: "text-success border-success/30 hover:bg-success/10"
}
>
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 mr-1" />}
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 me-1" />}
{t("participants.accepted")}
</Button>
<Button
@@ -564,7 +562,7 @@ export function EventDetailPopover({
: "border border-warning/30 text-warning hover:bg-warning/10"
}
>
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 mr-1" />}
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 me-1" />}
{t("participants.tentative")}
</Button>
<Button
@@ -577,7 +575,7 @@ export function EventDetailPopover({
: "text-destructive hover:bg-destructive/10"
}
>
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 mr-1" />}
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 me-1" />}
{t("participants.declined")}
</Button>
</div>
@@ -611,7 +609,7 @@ export function EventDetailPopover({
) : (
<>
<Button variant="default" size="sm" onClick={onEdit} className="h-7 text-xs">
<Pencil className="w-3.5 h-3.5 mr-1" />
<Pencil className="w-3.5 h-3.5 me-1" />
{t("events.edit")}
</Button>
<Button
@@ -621,7 +619,7 @@ export function EventDetailPopover({
className="h-7 text-xs"
title={t("events.duplicate")}
>
<Copy className="w-3.5 h-3.5 mr-1" />
<Copy className="w-3.5 h-3.5 me-1" />
{t("events.duplicate")}
</Button>
<div className="flex-1" />
+110 -47
View File
@@ -1,12 +1,13 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { useTranslations, useLocale } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
@@ -75,7 +76,7 @@ function buildDuration(startDate: Date, endDate: Date): string {
return dur;
}
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly";
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly" | "custom";
type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks";
@@ -157,16 +158,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
return labels.join(", ");
}
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
if (!event.recurrenceRules?.length) return null;
const freq = event.recurrenceRules[0].frequency;
const labels: Record<string, string> = {
daily: t("recurrence.daily"),
weekly: t("recurrence.weekly"),
monthly: t("recurrence.monthly"),
yearly: t("recurrence.yearly"),
};
return labels[freq] || null;
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
}
export function EventModal({
@@ -186,6 +180,7 @@ export function EventModal({
isMobile = false,
}: EventModalProps) {
const t = useTranslations("calendar");
const locale = useLocale();
const timeFormat = useSettingsStore((s) => s.timeFormat);
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const isEdit = !!event;
@@ -270,8 +265,35 @@ export function EventModal({
});
const [recurrence, setRecurrence] = useState<RecurrenceOption>(() => {
if (!event?.recurrenceRules?.length) return "none";
return event.recurrenceRules[0].frequency as RecurrenceOption;
const rule = event.recurrenceRules[0];
return isSimpleRecurrenceRule(rule) ? (rule.frequency as RecurrenceOption) : "custom";
});
const [customRule, setCustomRule] = useState<CalendarRecurrenceRule | null>(() => {
if (!event?.recurrenceRules?.length) return null;
const rule = event.recurrenceRules[0];
return isSimpleRecurrenceRule(rule) ? null : rule;
});
const [showRecurrenceEditor, setShowRecurrenceEditor] = useState(false);
// Dropdown value to restore when the custom editor is cancelled without a saved rule.
const recurrenceBeforeCustomRef = useRef<RecurrenceOption>("none");
const handleRecurrenceEditorSave = useCallback((rule: CalendarRecurrenceRule) => {
setCustomRule(rule);
setRecurrence("custom");
setShowRecurrenceEditor(false);
}, []);
const handleRecurrenceEditorCancel = useCallback(() => {
setShowRecurrenceEditor(false);
if (!customRule) {
setRecurrence(recurrenceBeforeCustomRef.current);
}
}, [customRule]);
const customRuleSummary = useMemo(
() => (customRule ? buildRecurrenceSummary(customRule, t, locale) : null),
[customRule, t, locale]
);
const preservedAlertsRef = useRef<Record<string, CalendarEventAlert>>({});
const [alertRows, setAlertRows] = useState<AlertRow[]>(() => {
if (!event?.alerts) return [];
@@ -444,7 +466,9 @@ export function EventModal({
data.virtualLocations = null;
}
if (recurrence !== "none") {
if (recurrence === "custom" && customRule) {
data.recurrenceRules = [customRule];
} else if (recurrence !== "none" && recurrence !== "custom") {
data.recurrenceRules = [{
"@type": "RecurrenceRule",
frequency: recurrence,
@@ -498,10 +522,16 @@ export function EventModal({
{ name: organizerName, email: organizerEmail },
effectiveAttendees
) as Record<string, CalendarParticipant>;
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.
// The RFC 8984 replyTo property is retired in jscalendarbis and ignored.
data.organizerCalendarAddress = `mailto:${organizerEmail}`;
} else if (effectiveAttendees.length === 0 && event?.participants) {
data.participants = null;
// Also clear the retired replyTo that older releases (<= 1.7.6) wrote.
data.replyTo = null;
data.organizerCalendarAddress = null;
}
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
@@ -511,7 +541,7 @@ export function EventModal({
} finally {
setIsSaving(false);
}
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return;
@@ -593,7 +623,6 @@ export function EventModal({
if (isAttendeeMode && event) {
const startD = getEventStartDate(event);
const durMin = parseDuration(event.duration);
const endD = getEventEndDate(event);
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
const participants = getParticipantList(event);
@@ -637,11 +666,11 @@ export function EventModal({
<div className="text-sm">
<div>
<span className="font-medium">{formatEventDate(startD)}</span>
<span className="text-muted-foreground ml-2">{format(startD, timeDisplayFmt)}</span>
<span className="text-muted-foreground ms-2">{format(startD, timeDisplayFmt)}</span>
</div>
<div>
<span className="font-medium">{formatEventDate(endD)}</span>
<span className="text-muted-foreground ml-2">{format(endD, timeDisplayFmt)}</span>
<span className="text-muted-foreground ms-2">{format(endD, timeDisplayFmt)}</span>
</div>
</div>
);
@@ -650,7 +679,7 @@ export function EventModal({
<div className="text-sm">
<span className="font-medium">{formatEventDate(startD)}</span>
{!event.showWithoutTime && (
<span className="text-muted-foreground ml-2">
<span className="text-muted-foreground ms-2">
{format(startD, timeDisplayFmt)} {format(endD, timeDisplayFmt)}
</span>
)}
@@ -672,7 +701,7 @@ export function EventModal({
<Users className="w-4 h-4" />
{t("participants.title")}
</div>
<div className="space-y-1 pl-5">
<div className="space-y-1 ps-5">
{participants.map(p => (
<div key={p.id} className="flex items-center justify-between text-sm">
<span className="truncate">{p.name || p.email}</span>
@@ -697,7 +726,7 @@ export function EventModal({
? "bg-success hover:bg-success/80 text-success-foreground"
: "text-success border-success/30 hover:bg-success/10"}
>
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 me-1" />}
{t("participants.accepted")}
</Button>
<Button
@@ -708,7 +737,7 @@ export function EventModal({
? "bg-warning hover:bg-warning/80 text-warning-foreground"
: "border border-warning/30 text-warning hover:bg-warning/10"}
>
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 me-1" />}
{t("participants.tentative")}
</Button>
<Button
@@ -719,7 +748,7 @@ export function EventModal({
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
: "text-destructive hover:bg-destructive/10"}
>
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
{userCurrentStatus === "declined" && <Check className="w-4 h-4 me-1" />}
{t("participants.declined")}
</Button>
</div>
@@ -737,7 +766,7 @@ export function EventModal({
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
const viewParticipants = getParticipantList(event);
const recurrenceLabel = getRecurrenceLabel(event, t);
const recurrenceLabel = getRecurrenceLabel(event, t, locale);
const alertLabel = getAlertLabel(event, t);
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
const color = getEventColor(event, eventCalendar);
@@ -755,7 +784,7 @@ export function EventModal({
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
</div>
{eventCalendar && (
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">{eventCalendar.name}</p>
<p className="text-xs text-muted-foreground mt-0.5 ps-[18px]">{eventCalendar.name}</p>
)}
</div>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 flex-shrink-0 mt-0.5 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
@@ -787,13 +816,13 @@ export function EventModal({
<>
<div className="font-medium text-foreground">
{formatEventDate(startD)}
<span className="ml-1.5 font-normal text-muted-foreground">
<span className="ms-1.5 font-normal text-muted-foreground">
{format(startD, timeDisplayFmt)}
</span>
</div>
<div className="font-medium text-foreground">
{formatEventDate(endD)}
<span className="ml-1.5 font-normal text-muted-foreground">
<span className="ms-1.5 font-normal text-muted-foreground">
{format(endD, timeDisplayFmt)}
</span>
</div>
@@ -809,11 +838,11 @@ export function EventModal({
{formatEventDate(startD)}
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
<span className="text-muted-foreground ms-1.5">{t("events.all_day")}</span>
) : (
<div className="text-muted-foreground">
{format(startD, timeDisplayFmt)} {format(endD, timeDisplayFmt)}
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
<span className="ms-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
</div>
)}
</>
@@ -860,7 +889,7 @@ export function EventModal({
<span className="truncate text-foreground">
{p.name || p.email}
{p.isOrganizer && (
<span className="text-muted-foreground ml-1">({t("participants.organizer").toLowerCase()})</span>
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
)}
</span>
<StatusBadge status={p.status} isOrganizer={p.isOrganizer} t={t} />
@@ -913,21 +942,21 @@ export function EventModal({
</div>
) : (
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-destructive">
<Trash2 className="w-4 h-4 mr-1" />
<Trash2 className="w-4 h-4 me-1" />
{t("events.delete")}
</Button>
)
)}
{onDuplicate && !showDeleteConfirm && (
<Button variant="ghost" size="sm" onClick={handleDuplicate} aria-label={t("events.duplicate")}>
<Copy className="w-4 h-4 mr-1" />
<Copy className="w-4 h-4 me-1" />
{t("events.duplicate")}
</Button>
)}
</div>
{!showDeleteConfirm && (
<Button onClick={() => setMode("edit")}>
<Pencil className="w-4 h-4 mr-1" />
<Pencil className="w-4 h-4 me-1" />
{t("events.edit")}
</Button>
)}
@@ -1131,17 +1160,51 @@ export function EventModal({
<div>
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
<select
value={recurrence}
onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="none">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
</select>
<div className="flex items-center gap-2">
<select
value={recurrence}
onChange={(e) => {
const value = e.target.value as RecurrenceOption;
if (value === "custom") {
recurrenceBeforeCustomRef.current = recurrence;
setRecurrence("custom");
setShowRecurrenceEditor(true);
} else {
setRecurrence(value);
setShowRecurrenceEditor(false);
}
}}
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="none">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
<option value="custom">{customRuleSummary || t("recurrence.custom")}</option>
</select>
{recurrence === "custom" && !showRecurrenceEditor && (
<button
type="button"
onClick={() => setShowRecurrenceEditor(true)}
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t("recurrence.edit_custom")}
>
<Pencil className="w-4 h-4" />
</button>
)}
</div>
{showRecurrenceEditor && (
<RecurrenceEditor
rule={customRule}
eventStart={(() => {
const d = new Date(`${startDate}T${allDay ? "00:00" : (startTime || "00:00")}:00`);
return isNaN(d.getTime()) ? new Date() : d;
})()}
onSave={handleRecurrenceEditorSave}
onCancel={handleRecurrenceEditorCancel}
/>
)}
</div>
<div>
@@ -1257,7 +1320,7 @@ export function EventModal({
onClick={() => setShowDeleteConfirm(true)}
className="text-red-600 dark:text-red-400"
>
<Trash2 className="w-4 h-4 mr-1" />
<Trash2 className="w-4 h-4 me-1" />
{t("events.delete")}
</Button>
)
@@ -1269,7 +1332,7 @@ export function EventModal({
onClick={handleDuplicate}
aria-label={t("events.duplicate")}
>
<Copy className="w-4 h-4 mr-1" />
<Copy className="w-4 h-4 me-1" />
{t("events.duplicate")}
</Button>
)}
+2 -2
View File
@@ -4,7 +4,7 @@ import { useState, useCallback, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
import { format, parseISO } from "date-fns";
import { format } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { getEventStartDate } from "@/lib/calendar-utils";
@@ -444,7 +444,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
onClick={handleImport}
disabled={selectedIndices.size === 0}
>
<Check className="w-4 h-4 mr-1" />
<Check className="w-4 h-4 me-1" />
{t("import_button")} ({selectedIndices.size})
</Button>
)}
@@ -208,7 +208,7 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription, initi
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
<Loader2 className="w-4 h-4 animate-spin me-2" />
{isEdit ? t("saving") : t("subscribing")}
</>
) : (
+30 -32
View File
@@ -1,25 +1,19 @@
"use client";
import { useState, useMemo, Fragment } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { useTranslations } from "next-intl";
import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
addMonths, subMonths, addYears, subYears, setMonth, setYear,
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
isSameDay, isSameMonth, isToday, format,
getISOWeek, getWeek, format,
} from "date-fns";
import { cn } from "@/lib/utils";
import { getEventDayBounds } from "@/lib/calendar-utils";
import type { CalendarEvent } from "@/lib/jmap/types";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
type PickerView = "days" | "months" | "years";
const MONTH_LABELS = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
interface MiniCalendarProps {
selectedDate: Date;
displayMonth: Date;
@@ -40,17 +34,25 @@ export function MiniCalendar({
showWeekNumbers = false,
}: MiniCalendarProps) {
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
const {
weekStartsOn,
dayHeaderKeys,
getMonthGridDays,
checkIsToday,
checkIsSameMonth,
checkIsSameDay,
formatDayNumber,
formatMonthYear,
getMonth,
getYear,
monthLabelKeys,
} = useCalendarLocale();
const [pickerView, setPickerView] = useState<PickerView>("days");
const days = useMemo(() => {
const monthStart = startOfMonth(displayMonth);
const monthEnd = endOfMonth(displayMonth);
const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart });
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart });
return eachDayOfInterval({ start: gridStart, end: gridEnd });
}, [displayMonth, weekStart]);
const days = useMemo(
() => getMonthGridDays(displayMonth),
[displayMonth, getMonthGridDays],
);
const eventDates = useMemo(() => {
const set = new Set<string>();
@@ -67,20 +69,16 @@ export function MiniCalendar({
return set;
}, [events]);
const dayHeaders = firstDayOfWeek === 0
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
// Compute week numbers for each row (one per 7-day chunk)
const weekNumbers = useMemo(() => {
if (!showWeekNumbers) return [];
const nums: number[] = [];
for (let i = 0; i < days.length; i += 7) {
// Use the first day of each row to determine the week number
nums.push(weekStart === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
nums.push(weekStartsOn === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
}
return nums;
}, [days, showWeekNumbers, weekStart]);
}, [days, showWeekNumbers, weekStartsOn]);
const currentYear = getYear(displayMonth);
const currentMonth = getMonth(displayMonth);
@@ -116,7 +114,7 @@ export function MiniCalendar({
const headerLabel =
pickerView === "days"
? intlFormatter.dateTime(displayMonth, { month: "long", year: "numeric" })
? formatMonthYear(displayMonth)
: pickerView === "months"
? String(currentYear)
: `${decadeStart}\u2013${decadeStart + 9}`;
@@ -160,15 +158,15 @@ export function MiniCalendar({
{showWeekNumbers && (
<div className="text-center text-[10px] font-medium text-muted-foreground py-1 w-5" />
)}
{dayHeaders.map((d) => (
{dayHeaderKeys.map((d) => (
<div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1">
{t(`days.${d}`)}
</div>
))}
{days.map((day, index) => {
const inMonth = isSameMonth(day, displayMonth);
const selected = isSameDay(day, selectedDate);
const today = isToday(day);
const inMonth = checkIsSameMonth(day, displayMonth);
const selected = checkIsSameDay(day, selectedDate);
const today = checkIsToday(day);
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
const isFirstDayOfRow = index % 7 === 0;
@@ -193,7 +191,7 @@ export function MiniCalendar({
selected && "bg-primary text-primary-foreground"
)}
>
{format(day, "d")}
{formatDayNumber(day)}
{hasEvent && !selected && (
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
)}
@@ -206,7 +204,7 @@ export function MiniCalendar({
{pickerView === "months" && (
<div className="grid grid-cols-3 gap-1 py-1">
{MONTH_LABELS.map((label, i) => {
{monthLabelKeys.map((labelKey, i) => {
const isCurrentMonth = i === currentMonth && currentYear === getYear(new Date());
const isSelected = i === getMonth(selectedDate) && currentYear === getYear(selectedDate);
return (
@@ -220,7 +218,7 @@ export function MiniCalendar({
!isSelected && !isCurrentMonth && "hover:bg-muted"
)}
>
{label}
{t(`months.${labelKey}`)}
</button>
);
})}
+9 -5
View File
@@ -4,6 +4,7 @@ import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHand
import { useTranslations } from "next-intl";
import { X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Avatar } from "@/components/ui/avatar";
import { useContactStore } from "@/stores/contact-store";
interface Participant {
@@ -144,14 +145,17 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
role="option"
aria-selected={i === activeIndex}
onMouseDown={(e) => { e.preventDefault(); addParticipant(s); }}
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
className={`px-3 py-2 text-sm cursor-pointer transition-colors flex items-center gap-2 ${
i === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
}`}
>
<div className="font-medium truncate">{s.name || s.email}</div>
{s.name && (
<div className="text-xs text-muted-foreground truncate">{s.email}</div>
)}
<Avatar name={s.name} email={s.email} size="sm" className="shrink-0 w-7 h-7 text-[10px]" />
<div className="min-w-0">
<div className="font-medium truncate">{s.name || s.email}</div>
{s.name && (
<div className="text-xs text-muted-foreground truncate">{s.email}</div>
)}
</div>
</li>
))}
</ul>
+427
View File
@@ -0,0 +1,427 @@
"use client";
import { useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { addYears, format } from "date-fns";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { CalendarRecurrenceRule } from "@/lib/jmap/types";
type EditorFrequency = "daily" | "weekly" | "monthly" | "yearly";
type MonthlyMode = "day" | "nth";
type EndsMode = "never" | "on" | "after";
const EDITOR_FREQUENCIES: EditorFrequency[] = ["daily", "weekly", "monthly", "yearly"];
// 2024-01-01 is a Monday - used to render localized weekday names via Intl.
const WEEKDAYS: string[] = ["mo", "tu", "we", "th", "fr", "sa", "su"];
const DAY_TO_REF_DATE: Record<string, number> = { mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6, su: 7 };
const INDEX_TO_DAY = ["su", "mo", "tu", "we", "th", "fr", "sa"];
const UNIT_LABEL_KEYS: Record<EditorFrequency, string> = {
daily: "recurrence.editor_unit_days",
weekly: "recurrence.editor_unit_weeks",
monthly: "recurrence.editor_unit_months",
yearly: "recurrence.editor_unit_years",
};
type CalendarT = ReturnType<typeof useTranslations>;
function weekdayName(day: string, locale: string, style: "long" | "short" = "long"): string {
const ref = new Date(2024, 0, DAY_TO_REF_DATE[day] ?? 1);
return new Intl.DateTimeFormat(locale, { weekday: style }).format(ref);
}
function monthName(month: number, locale: string): string {
return new Intl.DateTimeFormat(locale, { month: "long" }).format(new Date(2024, month - 1, 1));
}
function nthLabel(nth: number, t: CalendarT): string {
if (nth === -1) return t("recurrence.nth_last");
if (nth >= 1 && nth <= 4) return t(`recurrence.nth_${nth}`);
return String(nth);
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/**
* Extract an "nth weekday" pattern from a rule, accepting both the
* byDay+nthOfPeriod encoding and the byDay+bySetPosition encoding.
*/
function getNthDay(rule: CalendarRecurrenceRule): { day: string; nth: number } | null {
if (rule.byDay?.length === 1) {
const nd = rule.byDay[0];
if (nd.nthOfPeriod) return { day: nd.day, nth: nd.nthOfPeriod };
if (rule.bySetPosition?.length === 1) return { day: nd.day, nth: rule.bySetPosition[0] };
}
return null;
}
/**
* True when the rule is exactly what the plain Daily/Weekly/Monthly/Yearly
* dropdown presets produce, i.e. it needs no custom editor to represent.
*/
export function isSimpleRecurrenceRule(rule: CalendarRecurrenceRule): boolean {
return (
(EDITOR_FREQUENCIES as string[]).includes(rule.frequency) &&
(!rule.interval || rule.interval === 1) &&
!rule.byDay?.length &&
!rule.byMonthDay?.length &&
!rule.byMonth?.length &&
!rule.byYearDay?.length &&
!rule.byWeekNo?.length &&
!rule.bySetPosition?.length &&
!rule.count &&
!rule.until
);
}
/**
* Human-readable summary of a recurrence rule, e.g.
* "Every 2 months on the third Thursday · 12 occurrences".
* Returns null for frequencies the UI cannot describe (hourly etc.).
*/
export function buildRecurrenceSummary(
rule: CalendarRecurrenceRule,
t: CalendarT,
locale: string,
): string | null {
const interval = rule.interval || 1;
let base: string;
switch (rule.frequency) {
case "daily":
base = interval > 1 ? t("recurrence.every_n_days", { count: interval }) : t("recurrence.daily");
break;
case "weekly":
base = interval > 1 ? t("recurrence.every_n_weeks", { count: interval }) : t("recurrence.weekly");
break;
case "monthly":
base = interval > 1 ? t("recurrence.every_n_months", { count: interval }) : t("recurrence.monthly");
break;
case "yearly":
base = interval > 1 ? t("recurrence.every_n_years", { count: interval }) : t("recurrence.yearly");
break;
default:
return null;
}
const parts = [base];
if (rule.frequency === "weekly" && rule.byDay?.length) {
const days = rule.byDay
.filter((d) => WEEKDAYS.includes(d.day))
.sort((a, b) => WEEKDAYS.indexOf(a.day) - WEEKDAYS.indexOf(b.day))
.map((d) => weekdayName(d.day, locale, "short"))
.join(", ");
if (days) parts.push(t("recurrence.on_days", { days }));
}
if (rule.frequency === "monthly" || rule.frequency === "yearly") {
if (rule.frequency === "yearly" && rule.byMonth?.length) {
const m = parseInt(rule.byMonth[0], 10);
if (m >= 1 && m <= 12) parts.push(t("recurrence.in_month", { month: monthName(m, locale) }));
}
const nthDay = getNthDay(rule);
if (nthDay) {
parts.push(t("recurrence.on_the_nth", {
nth: nthLabel(nthDay.nth, t),
day: weekdayName(nthDay.day, locale),
}));
} else if (rule.byMonthDay?.length) {
parts.push(t("recurrence.on_day_n", { day: rule.byMonthDay[0] }));
}
}
let summary = parts.join(" ");
if (rule.count) {
summary += ` · ${t("recurrence.occurrences", { count: rule.count })}`;
} else if (rule.until) {
const d = new Date(rule.until);
if (!isNaN(d.getTime())) {
summary += ` · ${t("recurrence.until")} ${new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(d)}`;
}
}
return summary;
}
interface RecurrenceEditorProps {
rule: CalendarRecurrenceRule | null;
eventStart: Date;
onSave: (rule: CalendarRecurrenceRule) => void;
onCancel: () => void;
}
export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) {
const t = useTranslations("calendar");
const locale = useLocale();
const startDay = INDEX_TO_DAY[eventStart.getDay()];
const initialNthDay = rule ? getNthDay(rule) : null;
const [frequency, setFrequency] = useState<EditorFrequency>(() =>
rule && (EDITOR_FREQUENCIES as string[]).includes(rule.frequency)
? (rule.frequency as EditorFrequency)
: "weekly"
);
const [interval, setIntervalValue] = useState<number>(rule?.interval || 1);
const [weekDays, setWeekDays] = useState<string[]>(() => {
if (rule?.frequency === "weekly" && rule.byDay?.length) {
const days = rule.byDay.map((d) => d.day).filter((d) => WEEKDAYS.includes(d));
if (days.length) return days;
}
return [startDay];
});
const [monthlyMode, setMonthlyMode] = useState<MonthlyMode>(initialNthDay ? "nth" : "day");
const [monthDay, setMonthDay] = useState<number>(() => {
const md = rule?.byMonthDay?.[0];
return md && md >= 1 && md <= 31 ? md : eventStart.getDate();
});
const [nth, setNth] = useState<number>(() => {
if (initialNthDay && (initialNthDay.nth === -1 || (initialNthDay.nth >= 1 && initialNthDay.nth <= 4))) {
return initialNthDay.nth;
}
return Math.min(4, Math.floor((eventStart.getDate() - 1) / 7) + 1);
});
const [nthDay, setNthDay] = useState<string>(() =>
initialNthDay && WEEKDAYS.includes(initialNthDay.day) ? initialNthDay.day : startDay
);
const [month, setMonth] = useState<number>(() => {
const m = rule?.byMonth?.length ? parseInt(rule.byMonth[0], 10) : NaN;
return m >= 1 && m <= 12 ? m : eventStart.getMonth() + 1;
});
const [endsMode, setEndsMode] = useState<EndsMode>(rule?.count ? "after" : rule?.until ? "on" : "never");
const [untilDate, setUntilDate] = useState<string>(() => {
if (rule?.until) {
const d = new Date(rule.until);
if (!isNaN(d.getTime())) return format(d, "yyyy-MM-dd");
}
return format(addYears(eventStart, 1), "yyyy-MM-dd");
});
const [count, setCount] = useState<number>(rule?.count ?? 12);
const toggleWeekDay = (day: string) => {
setWeekDays((prev) =>
prev.includes(day)
? prev.length > 1 ? prev.filter((d) => d !== day) : prev
: [...prev, day]
);
};
const buildRule = (): CalendarRecurrenceRule => {
const built: CalendarRecurrenceRule = {
"@type": "RecurrenceRule",
frequency,
interval: Math.max(1, interval),
rscale: "gregorian",
skip: "omit",
firstDayOfWeek: "mo",
byDay: null,
byMonthDay: null,
byMonth: null,
byYearDay: null,
byWeekNo: null,
byHour: null,
byMinute: null,
bySecond: null,
bySetPosition: null,
count: endsMode === "after" ? Math.max(1, count) : null,
until: endsMode === "on" && untilDate ? `${untilDate}T23:59:59` : null,
};
if (frequency === "weekly") {
const days = weekDays.length ? weekDays : [startDay];
built.byDay = WEEKDAYS.filter((d) => days.includes(d)).map((day) => ({ day }));
} else if (frequency === "monthly" || frequency === "yearly") {
if (monthlyMode === "nth") {
built.byDay = [{ day: nthDay, nthOfPeriod: nth }];
} else {
built.byMonthDay = [Math.min(31, Math.max(1, monthDay))];
}
if (frequency === "yearly") {
built.byMonth = [String(month)];
}
}
return built;
};
const handleSave = () => onSave(buildRule());
const summary = buildRecurrenceSummary(buildRule(), t, locale);
const selectCls = "rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
return (
<div className="mt-2 rounded-md border border-border bg-muted/20 p-3 space-y-3">
<div className="flex items-center gap-2 text-sm">
<span className="shrink-0">{t("recurrence.editor_every")}</span>
<Input
type="number"
min={1}
max={999}
value={interval}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setIntervalValue(Number.isFinite(n) ? Math.max(1, n) : 1);
}}
className="w-16 shrink-0"
aria-label={t("recurrence.editor_every")}
/>
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value as EditorFrequency)}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.title")}
>
{EDITOR_FREQUENCIES.map((f) => (
<option key={f} value={f}>{t(UNIT_LABEL_KEYS[f])}</option>
))}
</select>
</div>
{frequency === "weekly" && (
<div className="flex gap-1">
{WEEKDAYS.map((day) => (
<button
key={day}
type="button"
onClick={() => toggleWeekDay(day)}
title={weekdayName(day, locale)}
aria-pressed={weekDays.includes(day)}
className={
weekDays.includes(day)
? "flex-1 min-w-0 px-1 py-1.5 text-xs font-medium rounded-md border border-primary text-primary bg-primary/10 transition-colors"
: "flex-1 min-w-0 px-1 py-1.5 text-xs font-medium rounded-md border border-input text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
}
>
{weekdayName(day, locale, "short")}
</button>
))}
</div>
)}
{frequency === "yearly" && (
<div className="flex items-center gap-2 text-sm">
<span className="shrink-0">{capitalize(t("recurrence.editor_in"))}</span>
<select
value={month}
onChange={(e) => setMonth(parseInt(e.target.value, 10))}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.editor_in")}
>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<option key={m} value={m}>{capitalize(monthName(m, locale))}</option>
))}
</select>
</div>
)}
{(frequency === "monthly" || frequency === "yearly") && (
<div className="flex items-center gap-2 text-sm">
<select
value={monthlyMode}
onChange={(e) => setMonthlyMode(e.target.value as MonthlyMode)}
className={`${selectCls} shrink-0`}
aria-label={t("recurrence.editor_repeats_on")}
>
<option value="day">{capitalize(t("recurrence.editor_on_day"))}</option>
<option value="nth">{capitalize(t("recurrence.editor_on_the"))}</option>
</select>
{monthlyMode === "day" ? (
<Input
type="number"
min={1}
max={31}
value={monthDay}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setMonthDay(Number.isFinite(n) ? Math.min(31, Math.max(1, n)) : 1);
}}
className="w-16 shrink-0"
aria-label={t("recurrence.editor_on_day")}
/>
) : (
<>
<select
value={nth}
onChange={(e) => setNth(parseInt(e.target.value, 10))}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.editor_on_the")}
>
{[1, 2, 3, 4, -1].map((n) => (
<option key={n} value={n}>{capitalize(nthLabel(n, t))}</option>
))}
</select>
<select
value={nthDay}
onChange={(e) => setNthDay(e.target.value)}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.editor_on_the")}
>
{WEEKDAYS.map((d) => (
<option key={d} value={d}>{capitalize(weekdayName(d, locale))}</option>
))}
</select>
</>
)}
</div>
)}
<div className="flex items-center gap-2 text-sm">
<span className="shrink-0">{t("recurrence.editor_ends")}</span>
<select
value={endsMode}
onChange={(e) => setEndsMode(e.target.value as EndsMode)}
className={`${selectCls} ${endsMode === "never" ? "flex-1" : "shrink-0"} min-w-0`}
aria-label={t("recurrence.editor_ends")}
>
<option value="never">{t("recurrence.editor_never")}</option>
<option value="on">{t("recurrence.until")}</option>
<option value="after">{t("recurrence.editor_ends_after")}</option>
</select>
{endsMode === "on" && (
<input
type="date"
value={untilDate}
onChange={(e) => setUntilDate(e.target.value)}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.editor_ends_on")}
/>
)}
{endsMode === "after" && (
<>
<Input
type="number"
min={1}
max={999}
value={count}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setCount(Number.isFinite(n) ? Math.max(1, n) : 1);
}}
className="w-16 shrink-0"
aria-label={t("recurrence.editor_ends_after")}
/>
<span className="text-muted-foreground truncate">{t("recurrence.editor_occurrences")}</span>
</>
)}
</div>
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
<p className="text-xs text-muted-foreground truncate min-w-0" title={summary ?? undefined}>
{summary}
</p>
<div className="flex gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={onCancel}>
{t("form.cancel")}
</Button>
<Button size="sm" onClick={handleSave}>
{t("form.save")}
</Button>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -302,7 +302,7 @@ export function TaskModal({
className="text-destructive hover:text-destructive"
onClick={() => onDelete(task.id)}
>
<Trash2 className="h-4 w-4 mr-1" />
<Trash2 className="h-4 w-4 me-1" />
{t("tasks.delete")}
</Button>
)}
+2 -2
View File
@@ -44,7 +44,7 @@ export function TaskToolbar({
))}
</div>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none ml-2">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none ms-2">
<input
type="checkbox"
checked={showCompleted}
@@ -57,7 +57,7 @@ export function TaskToolbar({
<div className="flex-1" />
<Button size="sm" onClick={onCreateTask}>
<Plus className="w-4 h-4 mr-1" />
<Plus className="w-4 h-4 me-1" />
{t("tasks.create")}
</Button>
</div>
@@ -62,6 +62,27 @@ describe('ContactForm', () => {
expect(phoneAfter.length).toBe(phoneBefore.length + 1);
});
it('sends media: null when an existing photo is removed', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const contactWithPhoto: ContactCard = {
...existingContact,
media: {
photo: { kind: 'photo', uri: 'data:image/png;base64,AAAA', mediaType: 'image/png' },
},
};
render(<ContactForm contact={contactWithPhoto} onSave={onSave} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('remove_photo'));
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => {
expect(onSave).toHaveBeenCalledOnce();
});
const savedData = onSave.mock.calls[0][0];
expect(savedData.media).toBeNull();
});
it('submits form data correctly', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
@@ -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');
});
});
+3 -3
View File
@@ -172,7 +172,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
const handleOpenEmail = (email: Email) => {
selectEmail(email);
router.push("/mail");
router.push("/");
};
const handleOpenEvent = (event: CalendarEvent) => {
@@ -229,7 +229,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
key={email.id}
type="button"
onClick={() => handleOpenEmail(email)}
className="w-full text-left flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
className="w-full text-start flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
>
<Avatar name={sender.name} email={sender.address} size="sm" />
<div className="flex-1 min-w-0">
@@ -277,7 +277,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
key={event.id}
type="button"
onClick={() => handleOpenEvent(event)}
className="w-full text-left flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
className="w-full text-start flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
>
<span className="text-xs text-muted-foreground tabular-nums w-20 flex-shrink-0">
{formatEventTime(event)}
+26 -129
View File
@@ -2,16 +2,13 @@
import { useState, useEffect, useRef } from "react";
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 { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
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 { exportContact } from "./contact-export";
import { printContact } from "./contact-print";
@@ -32,6 +29,8 @@ interface ContactDetailProps {
onDelete: () => void;
onAddToGroup?: () => void;
onDuplicate?: () => void;
/** Compose an email to this contact in the app (no OS mailto handoff). */
onCompose?: () => void;
isMobile?: boolean;
className?: string;
}
@@ -115,51 +114,11 @@ function formatDate(dateInput: AnniversaryDate): string {
return dateStr;
}
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, isMobile, className }: ContactDetailProps) {
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) {
const t = useTranslations("contacts");
const smimeStore = useSmimeStore();
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
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) {
return (
<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 titles = contact.titles ? Object.values(contact.titles) : [];
const jobTitles = titles.filter(t => t.kind !== "role");
const roles = titles.filter(t => t.kind === "role");
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : [];
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 preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : [];
const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : [];
@@ -260,26 +194,28 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
</div>
</div>
<div className="flex gap-2 flex-shrink-0 flex-wrap">
{email && (
<a
href={`mailto:${email}`}
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
{email && onCompose && (
<Button
variant="outline"
size="sm"
onClick={onCompose}
className="touch-manipulation"
>
<Send className="w-4 h-4 mr-1" />
<Send className="w-4 h-4 me-1" />
{t("detail.compose_email")}
</a>
</Button>
)}
{phone && (
<a
href={`tel:${phone}`}
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"
>
<Phone className="w-4 h-4 mr-1" />
<Phone className="w-4 h-4 me-1" />
{t("context_menu.call")}
</a>
)}
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
<Pencil className="w-4 h-4 mr-1" />
<Pencil className="w-4 h-4 me-1" />
{t("form.edit_title")}
</Button>
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
@@ -490,59 +426,20 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
{cryptoKeys.length > 0 && (
<Section title={t("detail.crypto_keys")}>
<div className="space-y-3">
{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">
{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>
)}
</>
{cryptoKeys.map((key, i) => (
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
<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" />
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{key.uri}
</a>
) : (
<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" />
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{key.uri}
</a>
) : (
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
)}
</div>
<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>
</Section>
)}
@@ -690,7 +587,7 @@ function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string })
setOpen(false);
}}
className={cn(
"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-muted focus:bg-muted focus:outline-none transition-colors",
"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-start hover:bg-muted focus:bg-muted focus:outline-none transition-colors",
item.destructive && "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 focus:bg-red-50 dark:focus:bg-red-950",
)}
>
+17 -12
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
import { useState, useMemo, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -72,7 +72,7 @@ function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = t
<button
type="button"
className={cn(
"flex items-center gap-2 w-full text-left",
"flex items-center gap-2 w-full text-start",
collapsible ? "cursor-pointer" : "cursor-default"
)}
onClick={() => collapsible && setOpen(!open)}
@@ -525,6 +525,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
mediaMap[photoKey] = { kind: "photo", uri: photoUri, mediaType: photoMediaType };
}
// Set mediaVluae to null if we are removing media, so the server removes it
const hadMedia = !!contact?.media && Object.keys(contact.media).length > 0;
const mediaValue: Record<string, ContactMedia> | null | undefined =
Object.keys(mediaMap).length > 0 ? mediaMap : (hadMedia ? null : undefined);
const data: Partial<ContactCard> = {
name: { components: nameComponents, isOrdered: true },
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
@@ -551,7 +556,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
calendarUri: calendarUri.trim() || undefined,
schedulingUri: schedulingUri.trim() || undefined,
freeBusyUri: freeBusyUri.trim() || undefined,
media: Object.keys(mediaMap).length > 0 ? mediaMap : undefined,
media: mediaValue as Record<string, ContactMedia> | undefined,
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
};
@@ -731,12 +736,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
)}
</div>
{emailErrors[i] && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1 ml-1">{emailErrors[i]}</p>
<p className="text-xs text-red-600 dark:text-red-400 mt-1 ms-1">{emailErrors[i]}</p>
)}
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setEmails([...emails, { address: "", context: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 me-1" />
{t("add_email")}
</Button>
</div>
@@ -794,7 +799,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setPhones([...phones, { number: "", context: "", feature: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 me-1" />
{t("add_phone")}
</Button>
</div>
@@ -850,7 +855,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setAddresses([...addresses, { street: "", locality: "", region: "", postcode: "", country: "", context: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 me-1" />
{t("add_address")}
</Button>
</div>
@@ -879,7 +884,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setOnlineServices([...onlineServices, { uri: "", service: "", label: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 me-1" />
{t("add_online_service")}
</Button>
</div>
@@ -911,7 +916,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setAnniversaries([...anniversaries, { date: "", kind: "birth" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 me-1" />
{t("add_anniversary")}
</Button>
</div>
@@ -952,7 +957,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setPersonalInfoEntries([...personalInfoEntries, { value: "", kind: "hobby", level: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 me-1" />
{t("add_personal_info")}
</Button>
</div>
@@ -1140,7 +1145,7 @@ function CategoryComboBox({
<button
key={kw}
type="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-start"
onClick={() => { addKeyword(kw); inputRef.current?.focus(); }}
>
<Tag className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
@@ -1150,7 +1155,7 @@ function CategoryComboBox({
{canAddNew && (
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left text-primary"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start text-primary"
onClick={() => { addKeyword(inputValue); inputRef.current?.focus(); }}
>
<Plus className="w-3.5 h-3.5 flex-shrink-0" />
+26 -3
View File
@@ -1,7 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { Users, Pencil, Trash2, UserMinus } from "lucide-react";
import { Users, Pencil, Trash2, UserMinus, Mail } from "lucide-react";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -15,6 +15,8 @@ interface ContactGroupDetailProps {
onDelete: () => void;
onRemoveMember: (memberId: string) => void;
onSelectMember: (id: string) => void;
/** Compose an email to every member with the recipients placed in `field`. */
onComposeGroup?: (field: "to" | "cc" | "bcc") => void;
isMobile?: boolean;
className?: string;
}
@@ -26,11 +28,13 @@ export function ContactGroupDetail({
onDelete,
onRemoveMember,
onSelectMember,
onComposeGroup,
isMobile,
className,
}: ContactGroupDetailProps) {
const t = useTranslations("contacts");
const groupName = getContactDisplayName(group);
const hasEmailMembers = members.some((m) => getContactPrimaryEmail(m).trim());
return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
@@ -49,7 +53,7 @@ export function ContactGroupDetail({
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
<Pencil className="w-4 h-4 mr-1" />
<Pencil className="w-4 h-4 me-1" />
{t("form.edit_title")}
</Button>
<Button
@@ -62,6 +66,25 @@ export function ContactGroupDetail({
</Button>
</div>
</div>
{onComposeGroup && hasEmailMembers && (
<div className="flex flex-wrap items-center gap-2 mt-4">
<Mail className="w-4 h-4 text-muted-foreground" aria-hidden />
<span className="text-sm text-muted-foreground">{t("groups.send_email")}</span>
<div className="inline-flex gap-1">
{(["to", "cc", "bcc"] as const).map((field) => (
<Button
key={field}
variant="outline"
size="sm"
onClick={() => onComposeGroup(field)}
className="touch-manipulation"
>
{t(`groups.send_email_${field}`)}
</Button>
))}
</div>
</div>
)}
</div>
<div className="px-6 py-4">
@@ -83,7 +106,7 @@ export function ContactGroupDetail({
className="flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-muted group transition-colors"
>
<button
className="flex items-center gap-3 flex-1 min-w-0 text-left"
className="flex items-center gap-3 flex-1 min-w-0 text-start"
onClick={() => onSelectMember(member.id)}
>
<Avatar name={mName} email={mEmail} size="sm" />
+2 -2
View File
@@ -114,7 +114,7 @@ export function ContactGroupForm({
placeholder={t("groups.search_members")}
value={memberSearch}
onChange={(e) => setMemberSearch(e.target.value)}
className="pl-9"
className="ps-9"
/>
</div>
@@ -134,7 +134,7 @@ export function ContactGroupForm({
type="button"
onClick={() => toggleMember(contact.id)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors",
"w-full flex items-center gap-3 px-3 py-2.5 text-start transition-colors",
"hover:bg-muted",
isSelected && "bg-primary/5"
)}
+2 -2
View File
@@ -45,7 +45,7 @@ export function ContactGroupList({
<div className={cn("flex flex-col", className)}>
<div className="px-4 py-2 border-b border-border">
<Button size="sm" variant="outline" onClick={onCreateGroup} className="w-full">
<Plus className="w-4 h-4 mr-1" />
<Plus className="w-4 h-4 me-1" />
{t("groups.create")}
</Button>
</div>
@@ -69,7 +69,7 @@ export function ContactGroupList({
key={group.id}
onClick={() => onSelectGroup(group.id)}
className={cn(
"w-full flex items-center px-4 text-left transition-colors",
"w-full flex items-center px-4 text-start transition-colors",
"hover:bg-muted",
group.id === selectedGroupId && "bg-accent text-accent-foreground"
)}
@@ -187,7 +187,7 @@ export function ContactImportDialog({
type="button"
onClick={() => toggleSelect(idx)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-muted",
"w-full flex items-center gap-3 px-3 py-2.5 text-start transition-colors hover:bg-muted",
isSelected && "bg-primary/5"
)}
>
+7 -7
View File
@@ -310,7 +310,7 @@ export function ContactList({
placeholder={t("search_placeholder")}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className={cn("pl-9 h-9", searchQuery && "pr-8")}
className={cn("ps-9 h-9", searchQuery && "pe-8")}
/>
{searchQuery && (
<button
@@ -359,7 +359,7 @@ export function ContactList({
onClick={() => setFilters(EMPTY_FILTERS)}
className="h-7 px-2 text-xs"
>
<RotateCcw className="w-3 h-3 mr-1" />
<RotateCcw className="w-3 h-3 me-1" />
{t("filters.clear")}
</Button>
<Button
@@ -479,11 +479,11 @@ export function ContactList({
</span>
<div className="flex-1" />
<Button variant="ghost" size="sm" onClick={onBulkAddToGroup} className="h-7 text-xs">
<Users className="w-3.5 h-3.5 mr-1" />
<Users className="w-3.5 h-3.5 me-1" />
{t("bulk.add_to_group")}
</Button>
<Button variant="ghost" size="sm" onClick={onBulkExport} className="h-7 text-xs">
<Download className="w-3.5 h-3.5 mr-1" />
<Download className="w-3.5 h-3.5 me-1" />
{t("bulk.export")}
</Button>
<Button
@@ -492,7 +492,7 @@ export function ContactList({
onClick={onBulkDelete}
className="h-7 text-xs text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
<Trash2 className="w-3.5 h-3.5 mr-1" />
<Trash2 className="w-3.5 h-3.5 me-1" />
{t("bulk.delete")}
</Button>
<Button variant="ghost" size="icon" onClick={onClearSelection} className="h-7 w-7">
@@ -526,7 +526,7 @@ export function ContactList({
)}
{activeFilters > 0 && (
<Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}>
<RotateCcw className="w-3.5 h-3.5 mr-1" />
<RotateCcw className="w-3.5 h-3.5 me-1" />
{t("filters.clear")}
</Button>
)}
@@ -538,7 +538,7 @@ export function ContactList({
<p className="text-sm font-medium text-foreground">{t("empty_state_title")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("empty_state_subtitle")}</p>
<Button size="sm" className="mt-3" onClick={onCreateNew}>
<UserPlus className="w-4 h-4 mr-1.5" />
<UserPlus className="w-4 h-4 me-1.5" />
{t("create_new")}
</Button>
</>
+49 -21
View File
@@ -2,10 +2,10 @@
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { BookUser, User, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
import { BookUser, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils";
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
@@ -22,9 +22,11 @@ interface ContactsSidebarProps {
onSelectCategory: (category: ContactCategory) => void;
onCreateGroup: () => void;
onCreateContact: () => void;
onCreateAddressBook?: () => void;
onImport?: () => void;
onEditGroup?: (groupId: string) => void;
onDeleteGroup?: (groupId: string) => void;
onComposeGroup?: (groupId: string, field: "to" | "cc" | "bcc") => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
onRenameAddressBook?: (addressBook: AddressBook) => void;
@@ -90,9 +92,11 @@ export function ContactsSidebar({
onSelectCategory,
onCreateGroup,
onCreateContact,
onCreateAddressBook,
onImport,
onEditGroup,
onDeleteGroup,
onComposeGroup,
onDropContacts,
onDropContactsToCategory,
onRenameAddressBook,
@@ -283,22 +287,31 @@ export function ContactsSidebar({
className="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-background text-foreground shadow-md z-50 py-1"
>
<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-start"
onClick={() => { setShowMenu(false); onCreateContact(); }}
>
<UserPlus className="w-4 h-4" />
{t("create_new")}
</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-start"
onClick={() => { setShowMenu(false); onCreateGroup(); }}
>
<UsersRound className="w-4 h-4" />
{t("groups.create")}
</button>
{onCreateAddressBook && (
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
onClick={() => { setShowMenu(false); onCreateAddressBook(); }}
>
<BookPlus className="w-4 h-4" />
{t("address_books.create")}
</button>
)}
{onImport && (
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
onClick={() => { setShowMenu(false); onImport(); }}
>
<Upload className="w-4 h-4" />
@@ -325,7 +338,7 @@ export function ContactsSidebar({
>
<BookUser className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{t("tabs.all")}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
{individuals.length}
</span>
</button>
@@ -342,7 +355,7 @@ export function ContactsSidebar({
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection(sectionKey)}
className="flex items-center gap-1 flex-1 min-w-0 text-left"
className="flex items-center gap-1 flex-1 min-w-0 text-start"
>
{expanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
@@ -356,7 +369,7 @@ export function ContactsSidebar({
</button>
</div>
{expanded && (
<div className="pl-2">
<div className="ps-2">
{owned.length > 0 && (
<div className="mt-1">
<div className="px-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
@@ -405,7 +418,7 @@ export function ContactsSidebar({
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection("addressBooks")}
className="flex items-center gap-1 flex-1 text-left"
className="flex items-center gap-1 flex-1 text-start"
>
{collapsed.addressBooks ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
@@ -448,7 +461,7 @@ export function ContactsSidebar({
<div className="mt-2">
<button
onClick={() => toggleSection("groups")}
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
className="flex items-center gap-1 px-3 py-1 w-full text-start group"
>
{collapsed.groups ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
@@ -470,7 +483,7 @@ export function ContactsSidebar({
onClick={() => onSelectCategory({ groupId: group.id })}
onContextMenu={(e) => openGroupContextMenu(e, group)}
className={cn(
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted"
@@ -479,7 +492,7 @@ export function ContactsSidebar({
>
<Users className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{getContactDisplayName(group)}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
{memberCount}
</span>
</button>
@@ -492,7 +505,7 @@ export function ContactsSidebar({
<div className="mt-2">
<button
onClick={() => toggleSection("categories")}
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
className="flex items-center gap-1 px-3 py-1 w-full text-start group"
>
{collapsed.categories ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
@@ -510,7 +523,7 @@ export function ContactsSidebar({
<button
onClick={() => onSelectCategory("uncategorized")}
className={cn(
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
activeCategory === "uncategorized"
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted"
@@ -519,7 +532,7 @@ export function ContactsSidebar({
>
<Tag className="w-3.5 h-3.5 flex-shrink-0 opacity-50" />
<span className="truncate italic">{t("no_category")}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
{uncategorizedCount}
</span>
</button>
@@ -548,7 +561,7 @@ export function ContactsSidebar({
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection(`shared-${group.accountId}`)}
className="flex items-center gap-1 flex-1 min-w-0 text-left"
className="flex items-center gap-1 flex-1 min-w-0 text-start"
>
{collapsed[`shared-${group.accountId}`] ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
@@ -684,6 +697,21 @@ export function ContactsSidebar({
onEditGroup?.(groupContextMenu.data!.id);
}}
/>
{onComposeGroup && (
<ContextMenuSubMenu icon={Mail} label={t("groups.send_email")}>
{(["to", "cc", "bcc"] as const).map((field) => (
<ContextMenuItem
key={field}
label={t(`groups.send_email_${field}`)}
onClick={() => {
const groupId = groupContextMenu.data!.id;
closeGroupContextMenu();
onComposeGroup(groupId, field);
}}
/>
))}
</ContextMenuSubMenu>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
@@ -751,7 +779,7 @@ function CategoryItem({
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted",
@@ -761,7 +789,7 @@ function CategoryItem({
>
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{keyword}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
{count}
</span>
</button>
@@ -819,7 +847,7 @@ function AddressBookItem({
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted",
@@ -830,11 +858,11 @@ function AddressBookItem({
<Book className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{book.name}</span>
{!book.isShared && Object.keys(book.shareWith || {}).length > 0 && (
<Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ml-auto" />
<Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ms-auto" />
)}
<span className={cn(
"text-xs text-muted-foreground tabular-nums",
!(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ml-auto"
!(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ms-auto"
)}>
{contactCount}
</span>
@@ -540,7 +540,9 @@ describe('CalendarInvitationBanner', () => {
mocks.clientMock,
'event-8',
expect.objectContaining({
replyTo: { imip: 'mailto:organizer@example.com' },
// The stored event lacks an ORGANIZER, so the RSVP repair writes
// organizerCalendarAddress (replyTo is retired in jscalendarbis).
organizerCalendarAddress: 'mailto:organizer@example.com',
participants: expect.objectContaining({
attendee: expect.objectContaining({
participationStatus: 'accepted',
@@ -0,0 +1,106 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ContactSidebarPanel } from '../email-viewer';
import type { ContactCard } from '@/lib/jmap/types';
const contact: ContactCard = {
id: 'c1',
addressBookIds: {},
name: {
components: [
{ kind: 'given', value: 'Alice' },
{ kind: 'surname', value: 'Smith' },
],
isOrdered: true,
},
emails: { e0: { address: 'alice@example.com' } },
};
const unknownEmail = 'unknown@example.com';
describe('ContactSidebarPanel', () => {
beforeEach(() => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});
it('shows Edit button when contact is known and onEditContact is provided', () => {
render(
<ContactSidebarPanel
email="alice@example.com"
contact={contact}
onClose={vi.fn()}
onEditContact={vi.fn()}
/>,
);
// useTranslations mock returns the key, so we look for the common.edit key
expect(screen.getByTitle('contact_sidebar.action_edit_title')).toBeInTheDocument();
expect(screen.getByText('edit')).toBeInTheDocument();
});
it('calls onEditContact when Edit button is clicked', () => {
const onEditContact = vi.fn();
render(
<ContactSidebarPanel
email="alice@example.com"
contact={contact}
onClose={vi.fn()}
onEditContact={onEditContact}
/>,
);
fireEvent.click(screen.getByTitle('contact_sidebar.action_edit_title'));
expect(onEditContact).toHaveBeenCalledOnce();
});
it('does not show Edit button when contact is null', () => {
render(
<ContactSidebarPanel
email={unknownEmail}
contact={null}
onClose={vi.fn()}
onEditContact={vi.fn()}
/>,
);
expect(screen.queryByTitle('contact_sidebar.action_edit_title')).not.toBeInTheDocument();
});
it('does not show Edit button when onEditContact is not provided', () => {
render(
<ContactSidebarPanel
email="alice@example.com"
contact={contact}
onClose={vi.fn()}
/>,
);
expect(screen.queryByTitle('contact_sidebar.action_edit_title')).not.toBeInTheDocument();
});
it('shows "not in contacts" message and Add button for unknown email', () => {
const onAddToContacts = vi.fn();
render(
<ContactSidebarPanel
email={unknownEmail}
contact={null}
onClose={vi.fn()}
onAddToContacts={onAddToContacts}
/>,
);
expect(screen.getByText('contact_sidebar.not_in_contacts')).toBeInTheDocument();
fireEvent.click(screen.getByText('contact_sidebar.add_to_contacts'));
expect(onAddToContacts).toHaveBeenCalledWith(unknownEmail, undefined);
});
it('calls onClose when the close button is clicked', () => {
const onClose = vi.fn();
render(
<ContactSidebarPanel
email="alice@example.com"
contact={contact}
onClose={onClose}
/>,
);
fireEvent.click(screen.getByLabelText('contact_sidebar.close'));
expect(onClose).toHaveBeenCalledOnce();
});
});
@@ -68,10 +68,12 @@ describe('EmailListItem tag badge', () => {
expect(screen.getByText('Blue')).toBeInTheDocument();
});
it('does not show badge when keyword id not in settings', () => {
it('shows a gray fallback badge when keyword id is not in settings', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } });
render(<EmailListItem email={email} />);
expect(screen.queryByText('unknown-tag')).not.toBeInTheDocument();
// Unknown tags fall back to the raw id as label with a gray dot
// (see email-list-item.tsx: keywordDefs fallback).
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
});
it('shows custom keyword label', () => {
@@ -119,3 +121,34 @@ describe('EmailListItem tag badge', () => {
expect(container.querySelector('p')).toBeNull();
});
});
describe('EmailListItem shift-range checkbox', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split' });
});
it('shift-clicking the checkbox extends the selection from the anchor', () => {
const e1 = makeEmail({ id: 'e1', threadId: 't1' });
const e2 = makeEmail({ id: 'e2', threadId: 't2' });
const e3 = makeEmail({ id: 'e3', threadId: 't3' });
// selection mode active (so the checkbox renders), anchor on e1
useEmailStore.setState({
emails: [e1, e2, e3],
selectedEmailIds: new Set(['e1']),
lastSelectedEmailId: 'e1',
selectedMailbox: 'inbox',
});
render(<EmailListItem email={e3} />);
// the checkbox is the first button in the row (shown in selection mode)
const checkbox = screen.getAllByRole('button')[0];
act(() => {
checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
});
const sel = useEmailStore.getState().selectedEmailIds;
expect(sel.has('e1')).toBe(true);
expect(sel.has('e2')).toBe(true); // the in-between row got filled in
expect(sel.has('e3')).toBe(true);
});
});
@@ -0,0 +1,343 @@
import { render, screen, act } from '@testing-library/react';
import { fireEvent } from '@testing-library/dom';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
// ─── Heavy component mocks ────────────────────────────────────────────────────
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: ({ onChange }: { onChange?: (html: string) => void }) => (
React.createElement('div', { 'data-testid': 'rich-text-editor', onClick: () => onChange?.('') })
),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ ref: { current: null } }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
// vi.mock factories are hoisted, so all values must be defined inline.
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = { identities: [], defaultIdentityId: null };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/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 }));
// ─── DataTransfer polyfill ────────────────────────────────────────────────────
/** jsdom's built-in DataTransfer doesn't fully support setData/getData in synthetic drag events. */
class MockDataTransfer {
private _data: Record<string, string> = {};
types: string[] = [];
effectAllowed = '';
dropEffect = '';
setData(type: string, data: string) {
this._data[type] = data;
if (!this.types.includes(type)) this.types.push(type);
}
getData(type: string): string {
return this._data[type] ?? '';
}
setDragImage(_image: Element, _x: number, _y: number) {
// no-op: jsdom has no rendering, but the chip drag handler calls this.
}
}
// ─── Shared test data ─────────────────────────────────────────────────────────
const BASE_DATA = {
to: 'alice@example.com, ',
cc: '',
bcc: '',
subject: '',
body: '',
showCc: true,
showBcc: true,
selectedIdentityId: null,
subAddressTag: '',
mode: 'compose' as const,
draftId: null,
};
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('RecipientChipInput drag and drop', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('renders recipient chips with draggable="true"', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
const chipText = await screen.findByText('alice@example.com');
const chipSpan = chipText.closest('[draggable]');
expect(chipSpan).not.toBeNull();
expect(chipSpan).toHaveAttribute('draggable', 'true');
});
it('onDragStart encodes the recipient and source field into dataTransfer', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
const chipText = await screen.findByText('alice@example.com');
const chipSpan = chipText.closest('[draggable]') as HTMLElement;
const dt = new MockDataTransfer();
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to' });
});
it('keeps a display name with a comma in a single chip (array model)', async () => {
render(<EmailComposer initialData={{ ...BASE_DATA, to: '"Doo, John" <john@doo.org>, ' }} />);
// One chip, displayed as "Doo, John (john@doo.org)" — not split on the comma.
const chip = await screen.findByText('Doo, John (john@doo.org)');
const chipSpan = chip.closest('[draggable]') as HTMLElement;
expect(chipSpan).not.toBeNull();
const dt = new MockDataTransfer();
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to' });
});
it('onDragEnd clears the opacity class on the chip', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
const chipText = await screen.findByText('alice@example.com');
const chipSpan = chipText.closest('[draggable]') as HTMLElement;
fireEvent.dragStart(chipSpan, { dataTransfer: new MockDataTransfer() });
expect(chipSpan.className).toContain('opacity-50');
fireEvent.dragEnd(chipSpan);
expect(chipSpan.className).not.toContain('opacity-50');
});
it('dragOver on a different field container adds ring indicator; dragLeave removes it', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
await screen.findByText('alice@example.com');
// The flex-wrap containers are the actual drop zones
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
// To-container has the draggable chip; cc-container doesn't
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
const ccContainer = allContainers.find(el => el !== toContainer) as HTMLElement;
if (!ccContainer) return;
const dt = new MockDataTransfer();
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
fireEvent.dragOver(ccContainer, { dataTransfer: dt });
expect(ccContainer.className).toContain('ring-primary');
fireEvent.dragLeave(ccContainer, { relatedTarget: null });
expect(ccContainer.className).not.toContain('ring-primary');
});
it('drop on a different field container moves the chip', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
await screen.findByText('alice@example.com');
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
const ccContainer = allContainers.find(el => el !== toContainer) as HTMLElement;
if (!toContainer || !ccContainer) return;
const dt = new MockDataTransfer();
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
fireEvent.dragOver(ccContainer, { dataTransfer: dt });
act(() => {
fireEvent.drop(ccContainer, { dataTransfer: dt });
});
// Chip should still appear exactly once (moved, not duplicated or lost)
await screen.findByText('alice@example.com');
expect(screen.getAllByText('alice@example.com')).toHaveLength(1);
// The To container must now be empty
expect(toContainer.querySelectorAll('[draggable]')).toHaveLength(0);
});
it('drop on the same field container is a no-op', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
await screen.findByText('alice@example.com');
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
const dt = new MockDataTransfer();
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
fireEvent.dragOver(toContainer, { dataTransfer: dt });
act(() => {
fireEvent.drop(toContainer, { dataTransfer: dt });
});
// Chip stays present exactly once
expect(screen.getAllByText('alice@example.com')).toHaveLength(1);
});
it('dropping a chip onto the hidden Cc button shows the CC field', async () => {
render(<EmailComposer initialData={{ ...BASE_DATA, showCc: false, showBcc: false }} />);
await screen.findByText('alice@example.com');
const ccButton = screen.getByRole('button', { name: 'Cc' });
const dt = new MockDataTransfer();
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
fireEvent.dragOver(ccButton, { dataTransfer: dt });
act(() => {
fireEvent.drop(ccButton, { dataTransfer: dt });
});
// cc_label is rendered by the mock translation as its key string
const ccLabel = await screen.findByText('cc_label');
expect(ccLabel).toBeInTheDocument();
});
});
@@ -0,0 +1,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,39 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { SelectableAvatar } from '../selectable-avatar';
// Isolate from the real Avatar (image fetching, libravatar hashing) — we only
// care about the selection wrapper behaviour here.
vi.mock('@/components/ui/avatar', () => ({
Avatar: (props: { name?: string }) => <span data-testid="avatar">{props.name}</span>,
}));
describe('SelectableAvatar', () => {
it('renders the wrapped avatar', () => {
render(<SelectableAvatar name="Marta" checked={false} onToggle={() => {}} selectLabel="Select" />);
expect(screen.getByTestId('avatar')).toHaveTextContent('Marta');
});
it('fires onToggle and stops propagation when the avatar is clicked', () => {
const onToggle = vi.fn();
const onRowClick = vi.fn();
render(
<div onClick={onRowClick}>
<SelectableAvatar name="Marta" checked={false} onToggle={onToggle} selectLabel="Select" />
</div>,
);
fireEvent.click(screen.getByRole('checkbox'));
expect(onToggle).toHaveBeenCalledTimes(1);
// Clicking the avatar must not bubble up to open/select the row.
expect(onRowClick).not.toHaveBeenCalled();
});
it('reflects the checked state via aria-checked', () => {
const { rerender } = render(
<SelectableAvatar name="Marta" checked={false} onToggle={() => {}} selectLabel="Select" />,
);
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'false');
rerender(<SelectableAvatar name="Marta" checked onToggle={() => {}} selectLabel="Select" />);
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'true');
});
});
@@ -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();
}
});
});
+12 -40
View File
@@ -553,40 +553,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const replyToForRsvp = parsedEvent.replyTo
|| (parsedEvent.organizerCalendarAddress ? { imip: parsedEvent.organizerCalendarAddress } : null);
const imipStatus = status.toUpperCase() as 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
const organizerEmail = parsedEvent?.replyTo?.imip?.replace('mailto:', '')
|| parsedEvent?.organizerCalendarAddress?.replace('mailto:', '')
|| summary?.organizerEmail
|| null;
// Send the iMIP REPLY email to the organizer (client-side scheduling).
// Called after updating the local calendar event. Best-effort - if it
// fails we still report the RSVP as sent since the calendar was updated.
const sendImipReply = async () => {
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
return;
}
try {
await client.sendImipReply({
organizerEmail,
organizerName: summary?.organizer || undefined,
attendeeEmail: currentUserEmail,
attendeeName: myParticipant?.participant.name || undefined,
uid: parsedEvent.uid,
summary: parsedEvent.title,
dtStart: parsedEvent.start || undefined,
dtEnd: summary?.end || undefined,
timeZone: parsedEvent.timeZone || undefined,
isAllDay: parsedEvent.showWithoutTime || false,
sequence: parsedEvent.sequence,
status: imipStatus,
});
} catch {
// Best-effort: don't block the RSVP success notification
}
};
// The iMIP REPLY to the organizer is sent by the server: rsvpEvent /
// updateEvent below pass sendSchedulingMessages=true to CalendarEvent/set
// and Stalwart queues the iTIP REPLY itself. A manual client-side reply
// here produced duplicate emails.
try {
const eventForRsvp = existingEvent
? await resolveExistingEventForRsvp()
@@ -601,7 +571,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
);
if (eventForRsvp && existingEventParticipant) {
await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp);
await sendImipReply();
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
setState('parsed');
@@ -613,9 +582,13 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
} else {
await updateEvent(client, eventForRsvp.id, {
participants: repairedParticipants,
replyTo: replyToForRsvp ?? undefined,
// Stalwart routes the iTIP REPLY via the stored ORGANIZER
// (organizerCalendarAddress; the RFC 8984 replyTo is retired).
// Only repair a missing organizer - attendees may not modify it.
...(replyToForRsvp?.imip && !eventForRsvp.organizerCalendarAddress
? { organizerCalendarAddress: replyToForRsvp.imip }
: {}),
}, true);
await sendImipReply();
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
setState('parsed');
@@ -632,7 +605,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|| (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null);
if (newEvent && participant) {
await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp);
await sendImipReply();
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
} else {
@@ -1081,7 +1053,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
setShowCalendarPicker(false);
handleImport(cal.id);
}}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2"
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2"
>
<span
className="w-3 h-3 rounded-full flex-shrink-0"
@@ -1123,7 +1095,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
)}
{isProcessing && (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ms-auto" />
)}
</div>
)}
File diff suppressed because it is too large Load Diff
+43 -19
View File
@@ -17,6 +17,8 @@ import {
Mail,
MailOpen,
Star,
Pin,
PinOff,
Trash2,
Archive,
FolderInput,
@@ -34,6 +36,7 @@ import {
XCircle,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
interface Position {
@@ -58,6 +61,7 @@ interface EmailContextMenuProps {
onForward?: () => void;
onMarkAsRead?: (read: boolean) => void;
onToggleStar?: () => void;
onTogglePinned?: () => void;
onDelete?: () => void;
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
@@ -125,6 +129,7 @@ export function EmailContextMenu({
onForward,
onMarkAsRead,
onToggleStar,
onTogglePinned,
onDelete,
onArchive,
onSetColorTag,
@@ -143,14 +148,19 @@ export function EmailContextMenu({
onRescheduleScheduled,
}: EmailContextMenuProps) {
const t = useTranslations("context_menu");
const tSidebar = useTranslations("sidebar");
const _tColor = useTranslations("email_viewer.color_tag");
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isPinned = email.keywords?.['$pinned'] === true;
const isDraft = email.keywords?.['$draft'] === true;
const currentColors = getCurrentColors(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
// Marking your own outgoing mail as spam makes no sense - hide the action
// in Sent, Drafts and Scheduled.
const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '');
const isScheduled = email.isScheduled === true;
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
@@ -302,12 +312,13 @@ export function EmailContextMenu({
return nodes.map((node) => {
const Icon = getMailboxIcon(node.role);
const isTarget = moveTargetIds.has(node.id);
const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
return (
<div key={node.id}>
{isTarget ? (
<ContextMenuItem
icon={Icon}
label={node.name}
label={nodeLabel}
onClick={() =>
handleAction(() =>
showBatchActions
@@ -319,11 +330,11 @@ export function EmailContextMenu({
) : (
<div className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground">
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{node.name}</span>
<span>{nodeLabel}</span>
</div>
)}
{node.children.length > 0 && (
<div className="pl-4">
<div className="ps-4">
{renderNodes(node.children)}
</div>
)}
@@ -346,6 +357,15 @@ export function EmailContextMenu({
/>
)}
{/* Pin/Unpin - only for single email; pinned mails float to the top of the list */}
{!showBatchActions && onTogglePinned && (
<ContextMenuItem
icon={isPinned ? PinOff : Pin}
label={isPinned ? t("unpin") : t("pin")}
onClick={() => handleAction(onTogglePinned)}
/>
)}
{/* Set tag submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
@@ -357,7 +377,7 @@ export function EmailContextMenu({
role="menuitem"
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
className={cn(
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
isActive && "bg-accent font-medium"
)}
>
@@ -382,22 +402,26 @@ export function EmailContextMenu({
</ContextMenuSubMenu>
)}
<ContextMenuSeparator />
{/* Spam - contextual based on folder; pointless on own outgoing mail */}
{spamApplicable && (
<>
<ContextMenuSeparator />
{/* Spam - contextual based on folder */}
<ContextMenuItem
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
onClick={() =>
handleAction(
showBatchActions
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
)
}
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
destructive={!isInJunkFolder}
/>
<ContextMenuItem
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
onClick={() =>
handleAction(
showBatchActions
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
)
}
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
destructive={!isInJunkFolder}
/>
</>
)}
<ContextMenuSeparator />
+32 -12
View File
@@ -4,7 +4,7 @@ import { Email } from "@/lib/jmap/types";
import { useSettingsStore } from "@/stores/settings-store";
import type { HoverAction } from "@/stores/settings-store";
import { cn } from "@/lib/utils";
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react";
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert, ShieldCheck } from "lucide-react";
import { useTranslations } from "next-intl";
import { useIsMobile } from "@/hooks/use-media-query";
@@ -17,6 +17,12 @@ interface EmailHoverActionsProps {
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onMarkAsSpam?: () => void;
// When the email lives in a junk folder (incl. the aggregate "All Junk" view)
// the spam quick-action flips to "not spam".
isInJunk?: boolean;
onUndoSpam?: () => void;
// Hidden where marking spam is meaningless for self-authored mail (Drafts, Sent).
spamApplicable?: boolean;
}
const ACTION_CONFIG: Record<HoverAction, {
@@ -72,6 +78,9 @@ export function EmailHoverActions({
onArchive,
onSetColorTag,
onMarkAsSpam,
isInJunk = false,
onUndoSpam,
spamApplicable = true,
}: EmailHoverActionsProps) {
const hoverActions = useSettingsStore((state) => state.hoverActions);
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
@@ -106,7 +115,8 @@ export function EmailHoverActions({
onSetColorTag?.(null);
break;
case "spam":
onMarkAsSpam?.();
if (isInJunk) onUndoSpam?.();
else onMarkAsSpam?.();
break;
}
};
@@ -114,22 +124,31 @@ export function EmailHoverActions({
const actionButtons = hoverActions.map((actionId) => {
const config = ACTION_CONFIG[actionId];
if (!config) return null;
if (actionId === "spam" && !spamApplicable) return null;
const Icon = config.icon;
// In a junk context the spam action becomes "not spam".
const isNotSpam = actionId === "spam" && isInJunk;
const DisplayIcon = actionId === "markRead"
? (isUnread ? MailOpen : Mail)
: actionId === "star" && isStarred
? Star
: Icon;
: isNotSpam
? ShieldCheck
: Icon;
const title = isNotSpam ? t("not_spam") : t(config.titleKey);
const className = isNotSpam
? "hover:text-green-600 dark:hover:text-green-400"
: config.className;
return (
<button
key={actionId}
onClick={(e) => handleAction(e, actionId)}
title={t(config.titleKey)}
title={title}
className={cn(
"p-1.5 rounded-md transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10",
config.className,
className,
)}
>
<DisplayIcon
@@ -162,16 +181,17 @@ export function EmailHoverActions({
return (
<div
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
className="absolute end-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
>
<div
className={cn("w-8 h-full", hoverBackgroundClassName)}
style={{
WebkitMaskImage: "linear-gradient(to right, transparent, black)",
maskImage: "linear-gradient(to right, transparent, black)",
}}
className={cn(
"w-8 h-full",
"[mask-image:linear-gradient(to_right,transparent,black)] [-webkit-mask-image:linear-gradient(to_right,transparent,black)]",
"rtl:[mask-image:linear-gradient(to_left,transparent,black)] rtl:[-webkit-mask-image:linear-gradient(to_left,transparent,black)]",
hoverBackgroundClassName,
)}
/>
<div className={cn("flex items-center gap-0.5 h-full pr-3 pl-0.5", hoverBackgroundClassName)}>
<div className={cn("flex items-center gap-0.5 h-full pe-3 ps-0.5", hoverBackgroundClassName)}>
{actionButtons}
</div>
</div>
+12 -4
View File
@@ -5,6 +5,7 @@ import { Mail, Tag } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Email, Identity } from '@/lib/jmap/types';
import { parseSubAddress } from '@/lib/sub-addressing';
import { isAuthenticationSpoofed } from '@/lib/email-headers';
import { useSettingsStore } from '@/stores/settings-store';
interface EmailIdentityBadgeProps {
@@ -29,10 +30,17 @@ export function EmailIdentityBadge({
// Parse the from address to check for sub-addressing
const parsedFrom = parseSubAddress(fromAddress, subAddressDelimiter);
// Find matching identity (email sent BY the user)
const matchingIdentity = identities.find(
(identity) => identity.email === fromAddress || identity.email === `${parsedFrom.baseUser}@${parsedFrom.domain}`
);
// Find matching identity (email sent BY the user). When the message is
// likely spoofed, the From address can't be trusted, so we ignore any
// identity match — otherwise a forged From matching one of the user's own
// addresses would render a misleading "via <identity>" badge that implies
// legitimacy.
const spoofed = isAuthenticationSpoofed(email.authenticationResults);
const matchingIdentity = spoofed
? undefined
: identities.find(
(identity) => identity.email === fromAddress || identity.email === `${parsedFrom.baseUser}@${parsedFrom.domain}`
);
// Check if email was sent TO a sub-address (received email)
let receivedToTag: string | null = null;
+36 -12
View File
@@ -5,8 +5,8 @@ import { useCallback } from "react";
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
import { SelectableAvatar } from "@/components/email/selectable-avatar";
import { Paperclip, Star, Pin, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
@@ -29,25 +29,32 @@ interface EmailListItemProps {
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
}
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) {
const t = useTranslations('email_viewer');
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
const tBatch = useTranslations('email_list.batch_actions');
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id);
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isPinned = email.keywords?.['$pinned'] === true;
const isImportant = email.keywords?.["$important"];
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
// In Sent/Drafts folders, show recipient instead of sender (which is always "me").
// In aggregate role-views the selected mailbox is virtual → fall back to the
// unified role so junk-contextual UI (spam ↔ not-spam) and avatar hiding work.
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const isMobile = useUIStore((state) => state.isMobile);
@@ -62,7 +69,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
// Use first tag for background coloring
const keywordDef = keywordDefs[0] ?? null;
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
// Drag and drop functionality
const { dragHandlers, isDragging } = useEmailDrag({
@@ -83,7 +90,14 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
toggleEmailSelection(email.id);
if (e.shiftKey) {
// Shift-click extends the selection from the anchor to here, like
// shift-clicking the row (the checkbox stops propagation, so the
// row's shift handler never runs — replicate it here).
selectRangeEmails(email.id);
} else {
toggleEmailSelection(email.id);
}
};
const handleContextMenu = (e: React.MouseEvent) => {
@@ -162,19 +176,22 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
{/* Unread indicator */}
{isUnread && (
<div className="absolute left-1 top-1/2 -translate-y-1/2">
<div className="absolute start-0.5 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-unread text-unread" />
</div>
)}
{/* Avatar */}
{density !== 'extra-compact' && (
<Avatar
<SelectableAvatar
name={sender?.name}
email={sender?.email}
size={isFocusedMailLayout ? "sm" : "md"}
className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
checked={isChecked}
onToggle={() => toggleEmailSelection(email.id)}
selectLabel={tBatch('select')}
/>
)}
@@ -191,17 +208,18 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={cn(
'shrink-0 truncate',
'min-w-0 truncate',
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
)}>
{email.subject || t('no_subject')}
</span>
{inlinePreview && (
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
)}
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{isPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
@@ -238,6 +256,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isPinned && (
<Pin className="w-3.5 h-3.5 text-primary" />
)}
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
@@ -321,6 +342,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
onArchive={onArchive}
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
/>
</div>
);
+37 -7
View File
@@ -35,6 +35,7 @@ interface EmailListProps {
onForward?: (email: Email) => void;
onMarkAsRead?: (email: Email, read: boolean) => void;
onToggleStar?: (email: Email) => void;
onTogglePinned?: (email: Email) => void;
onDelete?: (email: Email) => void;
onArchive?: (email: Email) => void;
onSetColorTag?: (emailId: string, color: string | null) => void;
@@ -64,6 +65,7 @@ export function EmailList({
onForward,
onMarkAsRead,
onToggleStar,
onTogglePinned,
onDelete,
onArchive,
onSetColorTag,
@@ -100,19 +102,32 @@ export function EmailList({
isLoadingThread,
toggleThreadExpansion,
fetchThreadEmails,
markThreadAsRead,
collapseAllThreads,
threadEmailCounts,
searchFilters,
setSearchFilters,
clearSearchFilters,
advancedSearch,
searchQuery,
isUnifiedView,
unifiedRole,
} = useEmailStore();
// In aggregate role-views (e.g. "All Junk") the selected mailbox is virtual, so
// there is no concrete mailbox to read the role from. Fall back to the unified
// role so contextual actions (e.g. mark-as-spam ↔ not-spam) behave as if inside
// that role's folder.
const effectiveMailboxRole =
mailboxes.find(m => m.id === selectedMailbox)?.role
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
const disableThreading = useSettingsStore((state) => state.disableThreading);
const threadGroups = useMemo(() => {
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView);
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView, threadEmailCounts);
return sortThreadGroups(groups);
}, [emails, disableThreading, isScheduledView]);
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
@@ -250,10 +265,12 @@ export function EmailList({
if (!isExpanded && client) {
toggleThreadExpansion(threadId);
await fetchThreadEmails(client, threadId);
// Mark all unread emails in this thread as read
void markThreadAsRead(client, threadId);
} else {
toggleThreadExpansion(threadId);
}
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead]);
// Range-based load more: trigger when last visible item is near the end.
// Debounce to prevent rapid cascade when thread grouping reduces item
@@ -400,9 +417,9 @@ export function EmailList({
className="text-destructive border-destructive/30 hover:bg-destructive/10 text-xs"
>
{isProcessing ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
<Loader2 className="w-3 h-3 animate-spin me-1" />
) : (
<Trash2 className="w-3 h-3 mr-1" />
<Trash2 className="w-3 h-3 me-1" />
)}
{t('empty_folder.button')}
</Button>
@@ -473,7 +490,18 @@ export function EmailList({
isLoading={isLoadingThread === thread.threadId}
expandedEmails={threadEmailsCache.get(thread.threadId)}
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
onEmailSelect={(email) => onEmailSelect?.(email)}
onCollapseAllThreads={collapseAllThreads}
onEmailSelect={(email) => {
// Collapse expanded threads when selecting an email outside the expanded thread
const currentExpanded = useEmailStore.getState().expandedThreadIds;
if (currentExpanded.size > 0) {
// Check if the selected email belongs to any expanded thread via its threadId
if (!email.threadId || !currentExpanded.has(email.threadId)) {
collapseAllThreads();
}
}
onEmailSelect?.(email);
}}
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
onContextMenu={openContextMenu}
onOpenConversation={onOpenConversation}
@@ -483,6 +511,7 @@ export function EmailList({
onArchive={onArchive ? (email) => onArchive(email) : undefined}
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
/>
</div>
);
@@ -516,7 +545,7 @@ export function EmailList({
menuRef={menuRef}
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
currentMailboxRole={effectiveMailboxRole}
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
selectedCount={selectedEmailIds.size}
onReply={() => onReply?.(contextMenu.data!)}
@@ -524,6 +553,7 @@ export function EmailList({
onForward={() => onForward?.(contextMenu.data!)}
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined}
onDelete={() => onDelete?.(contextMenu.data!)}
onArchive={() => onArchive?.(contextMenu.data!)}
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
File diff suppressed because it is too large Load Diff

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