Compare commits

..
971 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
Shuki VakninandLinus Rath 82be047708 fix(email-viewer): stop shattering table cells with word-break: break-word
The global rule

    td, th { word-break: break-word; }

was breaking HTML-email tables one glyph per row whenever a column was
narrow, especially for Hebrew/Arabic/CJK headers and long English
strings. The non-standard `word-break: break-word` keyword behaves
like `break-all` in some engines, splitting words at arbitrary
character boundaries even when the word would fit if the column auto-
expanded.

`overflow-wrap: break-word` is already set on body/table, so the rule
only needs to add min-content relaxation for cells. `overflow-wrap:
anywhere` does exactly that without re-introducing break-all
behaviour.

Repro: any transactional Hebrew/RTL order-summary email — each header
(`מוצר`, `כמות`, `מחיר`) collapses to one glyph per row. After the fix
they render on a single line.

Closes #341.
2026-05-28 18:32:06 +02:00
Linus Rath 7d1fb73290 fix: scope Ctrl/Cmd+Enter send to focused composer 2026-05-28 18:28:07 +02:00
Shuki VakninandLinus Rath 2818a16f06 feat(composer): Ctrl+Enter / Cmd+Enter sends the open draft
Adds the universal "send with the platform modifier" shortcut every
mainstream mail client (Gmail, Outlook, Apple Mail, Proton, Tutanota,
Fastmail, Thunderbird) supports. Closes #343.

Behaviour:

* Window-level keydown listener registered while the composer is
  mounted. Fires when focus is anywhere inside the composer — chip
  inputs, subject, body textarea, or the rich-text contentEditable.
* Plain Enter is untouched; only Enter + Ctrl (Win/Linux) or Cmd
  (macOS) triggers send. Shift/Alt modifiers are ignored so existing
  autocomplete-confirm / chip-commit Enters are not hijacked.
* Routes through a ref so handleSend's per-render rebind doesn't
  re-register the listener every render.
* All existing send-time validation, attachment-warning, draft-save
  and undo-send flows still apply — the shortcut just calls the
  same handleSend() as the toolbar button.
* Listed in the Keyboard Shortcuts dialog under the existing
  Composer section.

Tested:

* Compose -> type body -> Ctrl+Enter -> Outbox.
* Cc/Bcc autocomplete suggestion + Enter still selects (alt-free
  Enter without Ctrl, so the new listener bails).
* Subject input -> Ctrl+Enter -> sends.
* Body Enter without modifier -> newline.
2026-05-28 18:23:37 +02:00
Shuki VakninandGitHub e93dd44111 fix: report real upload progress; XHR with progress events #333
The Files page UI sat at 0% throughout an upload because uploadBlob()
uses fetch(), which does not surface upload progress events. The store
set loaded=0 before the call and loaded=file.size after it, so users
saw the progress bar jump from 0% straight to 100% on completion --
and on slow connections (or large files) it appeared frozen.

Switch uploadBlob() to XHR when the caller passes onProgress or an
AbortSignal, so progress events from xhr.upload.onprogress can drive
the UI. Callers that don't pass either keep the fetch path so we
preserve the existing 401-retry behaviour in authenticatedFetch().

Wire the file store to pass both onProgress (updates uploadProgress
in real time) and the existing AbortController's signal (so cancel
now actually aborts the network request, not just the post-upload
createFileNode step).

uploadBlob() is part of IJMAPClient so the signature change is also
applied to the demo client (synthesises 0% then 100%).
2026-05-28 18:20:22 +02:00
Chuyen NguyenandLinus Rath e86183b44a Fix bug where editing any field closed the form 2026-05-26 08:39:24 +02:00
Linus Rath ad80aa23ca fix: keep empty viewer pane visible in Pro split layout 2026-05-25 19:07:27 +02:00
Linus Rath 3a8daf8bff feat: allow drag-and-drop into shared mailboxes 2026-05-25 18:53:54 +02:00
Linus Rath f8e7cce85a fix: prevent empty main pane when reordering tabs across panes 2026-05-25 18:25:55 +02:00
Linus Rath 62ebe443f9 fix: align continued multi-week events with week's left edge 2026-05-25 18:13:57 +02:00
Linus Rath 3c0faba837 fix: collapse focus mail layout to multi-line on mobile 2026-05-25 18:05:01 +02:00
Linus Rath 956acb69ce feat: add NEXT_PUBLIC_DEFAULT_LOCALE for fallback UI locale #243 2026-05-25 17:01:52 +02:00
Linus Rath e2abc8dee9 fix: prefix remaining <img>, favicon, and WebDAV URLs with basePath #319 2026-05-25 16:45:13 +02:00
Linus Rath 42ec34be21 fix: show end date in event popover for multi-day events #318 2026-05-25 16:28:03 +02:00
Linus Rath 534894c38d feat: locale-aware date format in email list with preset picker #331 2026-05-25 16:17:32 +02:00
Linus Rath 537707d9ed feat: include group inboxes in unified mailbox view #328 2026-05-23 16:01:22 +02:00
Linus Rath afe1e5a67c fix: restore blob: in object-src and frame-src CSP for PDF/HTML previews 2026-05-23 15:53:58 +02:00
Linus Rath d13934c2a6 fix: match user-avatar treatment on quick reply 2026-05-23 15:35:10 +02:00
Linus Rath acc90eb455 feat: add "Move to Trash and mark as read" delete action #323 2026-05-22 19:08:02 +02:00
Linus Rath 5aa6d7a2f0 docs: document OAUTH_ALLOW_PRIVATE_ENDPOINTS in env/config examples 2026-05-22 17:57:41 +02:00
Linus Rath c46de636e0 fix: keep a gutter on bare-HTML emails on mobile 2026-05-22 17:47:24 +02:00
shukivandLinus Rath 8de8babba5 fix(email): keep a small gutter on plain-text emails on mobile
The <=640px rule zeroes .email-content-text horizontal padding, so
plain-text (prose) emails render flush against the viewport edge on
phones, which hurts readability. Use a reduced 0.75rem gutter instead
of 0 — still maximizes width for wide content but keeps text off the
screen edge.
2026-05-22 17:45:53 +02:00
Linus Rath 63f2169ae7 fix: add OAUTH_ALLOW_PRIVATE_ENDPOINTS for split-DNS setups 2026-05-22 17:22:14 +02:00
Linus Rath e843ef0ebb fix: convert recurrenceRules to singular in batch create 2026-05-22 17:00:53 +02:00
Linus Rath 4b463f9691 fix: prefix hand-written URLs with basePath for subpath deployments 2026-05-22 16:38:55 +02:00
Linus Rath 58e4a3d117 feat: add post-export action setting (keep/archive/trash) 2026-05-22 15:36:15 +02:00
Linus Rath e3f6ae874d feat: add settings template for multi-email .zip filename 2026-05-22 15:30:15 +02:00
Linus Rath 52f5a5b42c feat: support importing emails from .zip archives 2026-05-22 15:22:33 +02:00
Linus Rath e7bded82fb i18n: add missing translation keys across 16 locales 2026-05-22 15:09:51 +02:00
Linus Rath 3ac14ecf38 feat: add filename transform settings 2026-05-22 14:57:25 +02:00
Linus Rath 0dca019fe5 fix: stop URL-encoding drag-out filenames and preserve Unicode letters 2026-05-22 14:49:17 +02:00
Linus Rath ca0d6805cf feat: add Downloads settings tab with template editor for .eml and attachment filenames 2026-05-22 14:46:25 +02:00
Linus Rath 8bcb487442 feat: name dragged/exported .eml files as "date (from-to) subject" with ASCII-only chars 2026-05-22 14:28:36 +02:00
Linus Rath 0245ec67e1 feat: enhance email filename generation and sanitization for drag-and-drop functionality 2026-05-22 14:25:30 +02:00
Linus Rath 8810a63262 feat: drag emails out to file explorer as .eml 2026-05-22 14:04:52 +02:00
Linus Rath d3778e6521 fix: handle malformed event dates in calendar route #316 2026-05-22 14:04:02 +02:00
Linus Rath 1fc6185002 chore: update version to 1.7.1 2026-05-22 12:22:03 +02:00
Linus Rath 4269d0589c feat: collapse empty viewer pane and hide placeholder in Pro mode 2026-05-22 12:19:34 +02:00
Linus Rath 2b1b06abd6 fix: collapse empty viewer pane so mail list fills the space 2026-05-22 12:16:49 +02:00
Linus Rath ac4a89120d fix: preserve inline images when replying #163 2026-05-22 12:06:55 +02:00
Linus Rath 704a259432 feat: hide empty-state placeholder in email viewer pane 2026-05-22 12:04:40 +02:00
Linus Rath 1c02970ae1 fix: use canonical INBOX in Sieve filter paths #313 2026-05-22 11:51:25 +02:00
Linus Rath 66b2036e37 feat: expose PWA branding fields in admin Branding tab 2026-05-22 11:22:07 +02:00
Linus Rath bd2ffab3bc fix: resolve destination account id to local namespace in mailbox drop 2026-05-22 11:20:50 +02:00
Linus Rath 7142627cec chore: update version to 1.7.0 2026-05-22 00:51:38 +02:00
Linus Rath 1e7d2d880c i18n: add missing translation keys across 16 locales 2026-05-22 00:44:00 +02:00
Linus Rath 63efd724d2 fix: trust directory version on marketplace install/update 2026-05-22 00:20:00 +02:00
Linus Rath ba4781910d feat: marketplace update flow for installed plugins/themes 2026-05-22 00:11:10 +02:00
Linus Rath 08c85a42e1 chore: update version to 1.7.0 2026-05-21 23:47:31 +02:00
Linus Rath fc5f6f43d6 feat: expose PWA, app identity, and extension directory keys in JSON config #312 2026-05-21 23:35:58 +02:00
Linus Rath 9b22ef810e fix: allow adding contacts from mail recipient popover on mobile #306 2026-05-21 23:23:45 +02:00
Linus Rath dbc0eea148 feat: group composer From dropdown by account in Pro shell 2026-05-21 23:07:02 +02:00
Linus Rath e80412b6fd feat: show contacts from all logged-in accounts in Pro shell 2026-05-21 22:32:17 +02:00
Linus Rath 2c825af689 fix: show avatars in calendar/address book sharing menu 2026-05-21 18:58:46 +02:00
Linus Rath 4cfee4f672 feat: split owned vs shared calendars per account in sidebar in Pro shell 2026-05-21 18:54:09 +02:00
Linus Rath 7076f1ded8 feat: show calendars from all logged-in accounts in Pro shell 2026-05-21 18:48:55 +02:00
Linus Rath 9fbcdf7a5f fix: hide mail sidebar header in Pro shell 2026-05-21 18:19:30 +02:00
Linus Rath 7f35d792b2 fix: load globals.css and Geist font in plugin sandbox iframe 2026-05-21 18:15:00 +02:00
Linus Rath c9cda3e203 fix: parent navigation detaching account in Pro shell file browser 2026-05-21 18:06:59 +02:00
Linus Rath 3540bf42d9 fix: use Avatar component in Pro shell file account picker 2026-05-21 18:05:03 +02:00
Linus Rath e6782e61a8 fix: sync plugin slot iframe height with reported content height 2026-05-21 17:59:24 +02:00
Linus Rath 33e655bcce feat: narrow-pane sidebars and cross-account file picker in Pro shell 2026-05-21 17:54:03 +02:00
Linus Rath 15a67a14b3 fix: parent dir navigation jumping to root in file browser 2026-05-21 17:27:49 +02:00
Linus Rath b48eef2189 feat: hide files back button in Pro shell 2026-05-21 17:25:01 +02:00
Linus Rath 280f5bc675 feat: support cross-account email moves in Pro shell 2026-05-21 17:22:31 +02:00
Linus Rath 1756f5ac1c feat: enable search in unified mailbox in pro mode 2026-05-21 17:12:06 +02:00
Linus Rath c9435f7580 feat: always show unified mailbox in Pro shell 2026-05-21 17:01:34 +02:00
Linus Rath 426d344aa8 feat: multi-account mail sidebar and client routing for Pro shell 2026-05-21 16:52:03 +02:00
Linus Rath ed90e096b5 feat: add per-account mailbox cache for Pro shell data layer 2026-05-21 16:11:26 +02:00
Linus Rath eb6e5f589e fix: hide redundant account switcher in mail sidebar inside Pro shell 2026-05-21 15:54:33 +02:00
Linus Rath eca837962c fix: hide "Back to Mail" in settings when Pro mode is on 2026-05-21 15:49:09 +02:00
Linus Rath b75bbaa517 feat: auto-redirect to Pro shell when proInterface is on 2026-05-21 15:47:05 +02:00
Linus Rath 9763ffa2a3 fix: keep proInterface per-device instead of syncing it 2026-05-21 15:39:02 +02:00
Linus Rath d854b903e0 fix: anchor unmatched URLs into main so 404 renders 2026-05-20 23:59:10 +02:00
Linus Rath 5008857880 fix: respect server-resolved locale on first visit #309 2026-05-20 23:53:44 +02:00
Linus Rath 628966d3b5 fix: split app into (main)/(sandbox) route groups so plugin iframe hydrates properly 2026-05-20 23:41:49 +02:00
Linus Rath d45c8ef511 feat: list and reorder logged-in accounts in settings #282 2026-05-20 19:22:44 +02:00
Linus Rath ba90ec1f7a feat: warn when setup JMAP URL points at a local-only host 2026-05-20 19:11:46 +02:00
Linus Rath 5023d31202 fix: defer setup wizard HTTP detection to avoid hydration mismatch 2026-05-20 19:05:11 +02:00
Linus Rath 1c44f59ba1 feat: allow setup wizard over plain HTTP with dismissable warning gate 2026-05-20 19:01:46 +02:00
Linus Rath 433a63bf1a fix: normalize malformed contact photo data URIs #307 2026-05-20 18:42:12 +02:00
Linus Rath de847b9e9f i18n: add missing translation keys across 16 locales 2026-05-20 18:30:50 +02:00
Linus Rath d530d9614b fix: serialize draft autosave with send to stop replies stalling in Drafts #303 2026-05-19 23:33:11 +02:00
Linus Rath 9a92271f6f fix: prevent mobile dual-scroll and use full width for mail content 2026-05-19 14:38:28 +02:00
Linus Rath 97ddf935a8 fix: mobile handoff flow for OAuth authentication 2026-05-19 00:45:35 +02:00
Linus Rath 973ce1e5bd feat: add mobile handoff page and JMAP authentication verification 2026-05-19 00:05:40 +02:00
Linus Rath 7003020855 fix: prevent duplication of Bulwark rules with literal braces in values 2026-05-18 23:58:21 +02:00
Linus Rath 5cdc5997af feat: pro: pane-aware responsiveness, scoped sidebar overlay, stable pane keys 2026-05-18 20:42:21 +02:00
Linus Rath c3b4707f85 feat: pro: drop top/bottom split, keep side-by-side only 2026-05-18 19:51:43 +02:00
Linus Rath 2b3094a4ef feat: pro: unify split panes under a single tab bar 2026-05-18 19:39:57 +02:00
Linus Rath ecd0467ffa fix: stop pulling node:dns into client bundle via OAuth discovery 2026-05-18 19:31:38 +02:00
Linus Rath 43ac0725ce feat: pro: drag tabs to reorder, drag to edge to split 2026-05-18 19:30:54 +02:00
Linus Rath 98879802ae feat: add Pro interface 2026-05-18 19:17:39 +02:00
Linus Rath cf9292262d feat: pluggable reply/forward quote header #295 2026-05-18 17:53:23 +02:00
Linus Rath 3d2ed71f3a feat: support multiple flexible event reminders #170 2026-05-18 17:31:44 +02:00
Linus Rath dcd2f4b079 fix: wire orphaned admin policy gates and surface OAuth scope settings 2026-05-18 16:47:20 +02:00
Linus Rath b2d24670ff fix: scope iCal subscriptions per JMAP account and fix refresh/clear 2026-05-18 16:39:02 +02:00
Linus Rath fa261fecfd fix: omit empty cc/bcc from Email/set so server does not emit bare Cc: header #301 2026-05-18 16:36:45 +02:00
Linus Rath 40f36baf94 fix: iCal subscription refresh, rollback, and URL normalization 2026-05-18 16:31:16 +02:00
Linus Rath 400703154e fix: ignore plugin-supplied target in ui.openExternalUrl to block host-frame hijack 2026-05-18 16:18:00 +02:00
Linus Rath 3ceada7b8a fix: tighten HTML sanitization at plain-text email + signature + i18n render sites 2026-05-18 16:07:34 +02:00
Linus Rath eb0643d887 fix: pin parent origin in iframe-bridge to block cross-frame postMessage 2026-05-18 16:01:29 +02:00
Linus Rath 1fc670138b fix: update bundleHash to full SHA-256 for integrity verification and migrate legacy hashes 2026-05-18 15:57:52 +02:00
Linus Rath 313a1fcce9 fix: stop persisting S/MIME passphrases in sessionStorage 2026-05-18 15:32:04 +02:00
Linus Rath 7efd8d59bf fix: escape print-window fields and re-sanitize body to block XSS 2026-05-18 13:24:52 +02:00
Linus Rath c2eb2c081b fix: gate admin routes against cross-origin CSRF 2026-05-18 13:21:01 +02:00
Linus Rath b299a0b602 fix: validate plugin/theme id in marketplace install to block path traversal 2026-05-18 13:03:49 +02:00
Linus Rath f275fbe2e4 fix: bind stalwart auth context to credential, not cookie-claimed username 2026-05-18 13:00:40 +02:00
Linus Rath f134766fd1 fix: validate OAuth discovery endpoints against SSRF 2026-05-18 12:53:43 +02:00
Linus Rath 6ebf720688 fix: block script-bearing MIME types from inline attachment preview 2026-05-18 12:47:44 +02:00
Linus Rath b1eb2b3c9b fix: correct regex for valid API post path validation 2026-05-18 12:44:54 +02:00
Linus Rath 48aa607b56 feat: lock down plugin runtime in sandbox + signing + approval 2026-05-18 12:44:23 +02:00
Linus Rath 088810bd20 feat: harden plugin sandbox and migrate in-tree plugins 2026-05-18 12:17:54 +02:00
Linus Rath e16f572252 fix: use plugin slot offer snapshots for useSyncExternalStore 2026-05-18 11:00:05 +02:00
Linus Rath 9f312aa556 feat: sandbox plugins in null-origin iframes with postMessage RPC 2026-05-18 10:50:49 +02:00
Linus Rath c5ac68e137 fix: prevent plugin config leak to non-admin users 2026-05-18 10:24:29 +02:00
Linus Rath ed6b5d5f33 fix: clear identity signature fields when emptied 2026-05-18 00:53:54 +02:00
Linus Rath 7a72903632 feat: show size cap on identity signature fields 2026-05-18 00:34:01 +02:00
Linus Rath 92127e2f00 fix: allow table-based layouts in HTML signature sanitizer 2026-05-18 00:24:08 +02:00
Linus Rath be5ff96e4d fix: toggle recipient popover when clicking name again 2026-05-17 23:45:57 +02:00
Linus Rath 1f47b7a6a9 fix: remove white halo around photo avatars 2026-05-17 23:44:13 +02:00
Linus RathandGitHub 551984ac44 Bump version from 1.6.6 to 1.6.7 2026-05-17 19:22:07 +02:00
Linus Rath 8c5aec9ca4 chore: update version to 1.6.7 2026-05-17 18:17:13 +02:00
Linus Rath 375220298d i18n: add missing translation keys across 16 locales 2026-05-17 18:12:17 +02:00
Linus Rath 452976ed95 fix: apply dark background to email content wrapper in dark mode 2026-05-17 17:40:35 +02:00
Linus Rath 243a2adfbf fix: improve dark mode background colors in email viewer 2026-05-17 17:39:49 +02:00
Linus Rath 1ba4a13353 fix: show "no body content" instead of infinite skeleton for bodyless emails 2026-05-17 17:33:06 +02:00
Linus Rath 5de12dfb79 perf: speed up calendar invitation banner load
Parallelize ICS parse with raw blob fetch, render the banner as soon
as parsing returns instead of awaiting the existing-event lookup, and
filter that lookup by UID server-side instead of fetching every event
on the calendar.
2026-05-17 17:28:10 +02:00
Linus Rath 689d646c57 fix: show contact popup when clicking sender name in email header 2026-05-17 17:19:43 +02:00
Linus Rath 49cd7f8130 feat: show details toggle and panel on mobile sender info 2026-05-17 17:15:19 +02:00
Linus Rath 4545e212f4 fix: align quick reply with mobile bottom toolbar 2026-05-17 17:06:30 +02:00
Linus Rath b1f4f6eae0 fix: pin quick reply to bottom for short emails 2026-05-17 16:56:54 +02:00
Linus Rath 9a431a873b fix: close attachment preview when clicking outside content 2026-05-17 16:49:13 +02:00
Linus Rath bb7e1c4538 fix: per-account push subscriptions so multi-account notifications work #298 2026-05-16 22:50:01 +02:00
Linus Rath 356abcfc2d fix: redact sensitive config secrets from admin API response 2026-05-16 22:48:06 +02:00
Linus Rath 3099b4801e fix: sandbox thread email HTML in srcDoc iframe with CSP meta 2026-05-16 20:49:59 +02:00
Linus Rath fc641e94ac fix: carry configSchema + settingsSchema through marketplace install 2026-05-16 19:51:59 +02:00
Linus Rath 0e758409ee fix: prevent long addresses from overflowing email details columns #297 2026-05-16 19:47:03 +02:00
Linus Rath 8c93941d8d feat: render app-top-banner slot on every authenticated page 2026-05-16 19:39:45 +02:00
Linus Rath 4221c9a50f fix: strip Stalwart master-user '%' suffix from displayed account 2026-05-16 19:07:10 +02:00
Linus Rath 3a559479bd fix: make impersonation cookies session-only 2026-05-16 18:59:46 +02:00
Linus Rath 482493a10d fix: register app-top-banner in plugin-store SLOT_NAMES 2026-05-16 18:53:06 +02:00
Linus Rath 0e1036eb49 fix: adopt orphan session cookie on first SPA load 2026-05-16 18:45:30 +02:00
Linus Rath 349406723c fix: use relative Location header in redirect 2026-05-16 18:33:44 +02:00
Linus Rath 997bedc91b feat: allow admin password overwrite during setup recovery 2026-05-16 18:21:48 +02:00
Linus Rath 307e6d5d34 fix: warn + block install when app version is below plugin's minAppVersion 2026-05-16 18:11:52 +02:00
Linus Rath ca1108f455 feat: master-user impersonation route + app-top-banner plugin slot 2026-05-16 17:59:07 +02:00
Linus Rath 0ff88f36ed Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-16 16:35:43 +02:00
Linus Rath 285b4e349c fix: add outputFileTracingExcludes to optimize Turbopack memory tracing 2026-05-16 16:35:06 +02:00
Linus Rath 2b4ebb1fbb feat: add HTTPS requirement warning in setup wizard 2026-05-16 16:31:12 +02:00
Timo StreuleandLinus Rath a829c2818f fix: pad safe-area-inset-top 2026-05-16 00:55:24 +02:00
Timo StreuleandLinus Rath c54cf73c3a fix: respect safe-area insets on mobile bottom bars 2026-05-15 23:50:29 +02:00
Timo StreuleandLinus Rath c45ef86924 fix: add viewport export with 'initialScale: 1' 2026-05-15 23:01:39 +02:00
Linus Rath f39366b470 fix: read OAUTH_SCOPES at runtime instead of build time 2026-05-15 20:37:00 +02:00
Linus Rath b725000f4d feat: implement vCard 4.0 parsing and generation support 2026-05-15 20:10:18 +02:00
Linus Rath 105194a8b9 chore: update version to 1.6.6 2026-05-15 15:20:07 +02:00
Linus Rath 8dbb538c98 feat: sync onboarding status across devices #285 2026-05-15 15:09:42 +02:00
Linus Rath e435356c53 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-15 14:49:58 +02:00
Linus Rath 6f9982540c feat: add icons for shared, important, memos, scheduled, snoozed folders #288 2026-05-15 14:48:39 +02:00
Timo StreuleandLinus Rath d0d6632b24 chore: drop redundant '-- ' prefix from dev identity signatures
The signature separator is already controlled by the
signatureSeparatorEnabled setting (lib/email-composer), which prepends
'-- ' at compose time when enabled. Baking it into the fixture
double-prefixed it.
2026-05-15 14:46:43 +02:00
Timo StreuleandLinus Rath 4b7009dfc2 feat: raise HTML signature length cap to 50000 chars
5000 chars is too tight for signatures containing base64-embedded images (even a small PNG can run a few thousand chars).
2026-05-15 14:46:43 +02:00
Timo StreuleandLinus Rath 55a408e810 feat: allow img in HTML identity signatures
- Restricts src to https: URLs or base64-embedded raster data: URIs (png/jpeg/gif/webp).
- SVG is excluded for safety reasons.
- Images with a disallowed src are removed entirely so they don't render as broken-image icons.
2026-05-15 14:46:43 +02:00
Linus Rath d5dddba6df fix: hide Files settings/nav when filesEnabled policy is off #291 2026-05-15 14:41:57 +02:00
Linus Rath d1a0667c79 i18n: clean up Danish locale wiring and sort language lists 286 2026-05-15 14:31:24 +02:00
Jesper OrdrupandLinus Rath e700e4fd04 match any translation 2026-05-15 14:26:33 +02:00
Jesper OrdrupandLinus Rath cf993c1036 adjust flag 2026-05-15 14:26:33 +02:00
Jesper OrdrupandLinus Rath 5fdf226ebe feat(i18n): add danish localization 2026-05-15 14:26:33 +02:00
Linus Rath fae15f073e fix: honor cookieSameSite admin config override #284 2026-05-14 21:49:37 +02:00
Linus Rath c646c87030 fix: standardize punctuation in tooltips and comments across multiple locales and code files 2026-05-14 21:44:24 +02:00
Linus Rath b4a76bc4d1 chore: expand demo fixtures with more emails, contacts, and portrait photos 2026-05-14 15:19:24 +02:00
Linus Rath dfe886636b fix: broaden body font for non Latin script rendering #265 2026-05-13 14:43:07 +02:00
Linus Rath f499e87d2a chore: update version to 1.6.5 2026-05-13 14:38:00 +02:00
Linus Rath 32fe871b70 fix: support HTTP basic auth in iCal subscription URLs #275 2026-05-13 14:27:54 +02:00
Linus Rath aab19379e2 feat: route account avatars through shared Avatar component #278 2026-05-13 00:50:46 +02:00
Linus Rath b46a1a69e8 chore: unblock pre-commit lint hook 2026-05-13 00:34:35 +02:00
Linus Rath ea424cad7e fix: honor admin-uploaded favicon in root metadata #274 2026-05-13 00:33:23 +02:00
Lucas GaitzschandLinus Rath 3f444a8912 Feature/protocol handlers
* Added account selection for protocol links when multiple connected accounts are available, including mailto: links
* Added support for handling mailto: links in an already-open PWA/session instead of always opening a new tab
* Added webcal: protocol handling for calendar links
* Added account selection for webcal: links when multiple calendar-capable accounts are connected
* Added an import-or-subscribe choice for detected webcal calendars
* Added protocol handler settings for registering mail and calendar handlers and choosing the open mode
* Added service worker/session coordination for passing protocol requests between browser/PWA contexts
* Added tests and translations for the new protocol handler flows
2026-05-12 20:49:05 +02:00
Linus Rath 8b0e2052cf fix: honor NEXT_PUBLIC_BASE_PATH in admin sidebar nav links #271 2026-05-12 16:10:29 +02:00
Linus Rath c99934a92c fix: update version to 1.6.4 2026-05-12 16:06:14 +02:00
Linus Rath ce2731cd9d fix: update types for cursor and toRemove 2026-05-12 16:04:46 +02:00
Linus Rath f9f8af2f11 fix: preserve signature styling and reactivity in above-quote mode #272 2026-05-12 16:03:10 +02:00
Linus Rath d8e2a10806 docs: update CONTRIBUTING.md 2026-05-11 20:41:04 +02:00
Linus Rath 869ee07ebc chore: bump next to 16.2.6 for security advisories 2026-05-11 20:11:05 +02:00
Linus Rath 2ad2bb1e09 chore: update version to 1.6.4 2026-05-11 20:00:55 +02:00
Linus Rath 23bc31c661 i18n: add missing translation keys across 15 locales 2026-05-11 19:30:52 +02:00
Linus Rath a2f76037a1 feat: update README and FEATURES.md 2026-05-11 19:24:32 +02:00
Linus Rath 9571f2e185 fix: skip upstream JMAP reverify for trusted URLs #237 2026-05-11 19:22:37 +02:00
Linus Rath 887b9c728c feat: drag attachments out to local file system #267 2026-05-11 17:34:24 +02:00
Linus Rath 8c21f462c2 feat: add signature position to email behavior settings search 2026-05-11 17:07:24 +02:00
Linus Rath 5f3d2d3e4a feat: signature above quoted text option #266 2026-05-11 17:05:59 +02:00
Linus Rath 4bce80b8ba feat: show avatar in Focused list for compact density and above 2026-05-11 16:23:15 +02:00
Linus Rath 1d09f5a623 feat: align Focused list preview with other layout previews 2026-05-11 15:41:25 +02:00
Linus Rath 2c513129f2 feat: add Reading Pane at Bottom mail layout #262 2026-05-11 15:35:43 +02:00
Linus Rath b3dc2e32b8 feat: implement prefetching of initial email data 2026-05-11 15:17:30 +02:00
Augustin MarcinandLinus Rath b0640c9ecc feat(compose): From override + catch-all auto-reply (fixes #246)
Adds an Override toggle in the composer's From row. When enabled, name
and address become free-text inputs. Mail is still submitted through the
selected identity, but the outgoing message's From: header — and the
SMTP envelope MAIL FROM when different — is set from the override.

The existing "Auto-select Reply Address" setting is extended: if the
incoming message was addressed to an alias on a domain that matches one
of your identities but isn't itself an identity (classic domain catch-
all), it now auto-enables Override and pre-fills the alias. Quick reply
honors the same resolution. The setting is relabeled to reflect the
broader behavior.

JMAP: client.sendEmail gains an optional envelopeMailFrom; when set, the
EmailSubmission includes an explicit envelope with that mailFrom and the
to/cc/bcc as rcptTo so header-From and envelope can diverge (JMAP §7.3).

S/MIME: override is incompatible with sign/encrypt and is refused with a
clear error — signing a different visible From from the identity's
certificate Subject would produce messages clients reject.

Tests: resolveReplyFrom covers exact match, sub-address stripping,
catch-all detection, identity preference, and foreign-domain null.
2026-05-11 12:13:47 +02:00
Linus Rath 2d7e24b513 perf: parallelize login round-trips and drop redundant JMAP re-verify 2026-05-11 11:04:13 +02:00
Linus Rath 5b30bacf10 feat: redesign review step with grouped summary and advanced toggle 2026-05-10 01:09:00 +02:00
Linus Rath fe937403f3 style: consistent notice cards for server probe results 2026-05-09 21:43:04 +02:00
Linus Rath 876ea370e4 feat: allow file uploads on the wizard branding step 2026-05-09 21:40:39 +02:00
Linus Rath 1dcdeeae86 style: consistent notice cards for server probe results 2026-05-09 18:12:43 +02:00
Linus Rath 01302a775c feat: require explicit confirmation when JMAP probe finds no session 2026-05-09 17:52:44 +02:00
Linus Rath 76d78ae756 fix: drop redundant first-login banner about removing ADMIN_PASSWORD #222 2026-05-09 17:40:53 +02:00
Linus Rath 51745ea03d feat: web setup wizard + admin config/state dir split (#226) 2026-05-09 17:37:41 +02:00
ChanceandLinus Rath c44a9ce6e0 fix: fall back to primary identity signature on reply
When auto-select picks an alias identity matching the original recipient,
the alias often has no signature configured. The composer was using the
alias's empty signature for both the visual preview and the appended
signature on send, so neither showed up. New mail worked because no
auto-select runs.

Add a signatureIdentity that falls back to the primary when the current
identity has no signature. From address, identity ID, S/MIME, and draft
saves still use currentIdentity so mail goes out from the right address.
2026-05-09 14:21:05 +02:00
Linus Rath 7fa65796f0 fix: show account identity in switcher header instead of sending alias 2026-05-09 13:21:13 +02:00
Linus Rath d09df7e8a3 fix: remove benchmark directory from .gitignore 2026-05-09 13:13:08 +02:00
Linus Rath 090399a308 chore: update version to 1.6.3 2026-05-08 21:10:21 +02:00
Linus Rath c31a58af1a i18n: add missing translation keys across 15 locales 2026-05-08 21:07:14 +02:00
Linus Rath 65aabb943c fix: fall back when only truncation indicator remains in email preview 2026-05-08 20:29:16 +02:00
Linus Rath 9c8739c4bb fix: preserve emoji colors in dark mode email viewer 2026-05-08 20:27:03 +02:00
Linus Rath 48f72be209 fix: preserve emoji colors in dark mode email viewer 2026-05-08 20:26:17 +02:00
Linus Rath 92fb0c63e9 fix: trim leading whitespace from email list preview 2026-05-08 20:19:25 +02:00
Linus Rath 55596556ef feat: import .eml files via folder right-click menu 2026-05-08 20:12:33 +02:00
Linus Rath abd63d124f fix: add benchmark directory to ESLint ignore list 2026-05-08 19:56:21 +02:00
Linus Rath 562080b7a3 fix: request shareWith explicitly so calendar/address book shares survive a re-login #257 2026-05-08 19:53:32 +02:00
Linus Rath 41c9f4926c fix: prevent white-on-white in dark mode for nested bgcolor containers 2026-05-08 02:49:58 +02:00
Linus Rath e7e78072d4 fix: render plain-text-only emails as text, not HTML 2026-05-07 17:49:39 +02:00
Linus Rath cd363b4840 fix: strip leading punctuation when computing avatar initials 2026-05-07 17:43:02 +02:00
Linus Rath 3a350c14a6 fix: hide files/contacts nav items when JMAP server lacks support 2026-05-07 17:21:47 +02:00
Linus Rath b0765bf085 style: redesign quick reply to match sender/banner layout 2026-05-07 17:02:06 +02:00
Linus Rath 9225ba0790 fix: render HTML-only emails and redesign external content prompt 2026-05-07 16:10:26 +02:00
Linus Rath 3b36738192 fix: pad Word/Outlook HTML email rendering 2026-05-07 15:42:47 +02:00
Linus Rath 3edd35ab57 fix: hide email hover actions on mobile 2026-05-07 15:23:05 +02:00
Linus Rath a86a96e390 fix: disable StarterKit's bundled link/underline to avoid duplicate extensions 2026-05-07 12:33:51 +02:00
Linus Rath 5f464d4ee2 feat: lift 5-account cap on HTTP/2 2026-05-07 12:28:33 +02:00
Linus Rath bd72dec98f chore: update version to 1.6.2 2026-05-06 20:11:10 +02:00
Linus Rath 1331a3767c Revert "chore: update version to 1.6.1"
This reverts commit cb200330e7.
2026-05-06 20:10:08 +02:00
Linus Rath cb200330e7 chore: update version to 1.6.1 2026-05-06 20:09:08 +02:00
Linus Rath 178922323d chore: update version to 1.6.2 2026-05-06 20:07:36 +02:00
Linus Rath 91cf125a5d chore: remove experimental tags from themes, plugins, and sender favicons settings 2026-05-06 20:03:19 +02:00
Linus Rath f4b7ef8117 i18n: add missing translation keys across 15 locales 2026-05-06 19:59:14 +02:00
Linus Rath d175fc2983 feat: restrict plugin and theme install/uninstall to admin dashboard 2026-05-06 19:34:21 +02:00
Linus Rath 2f8bbdc636 fix: open plugin card on first click of a setting sub-result 2026-05-06 19:18:07 +02:00
Linus Rath b1573aada1 feat: surface plugin settings as search sub-results 2026-05-06 19:14:47 +02:00
Linus Rath 1cd6cde77c fix: highlight plugin and theme cards in settings search 2026-05-06 19:10:12 +02:00
Linus Rath 802a30508a fix: drop ghost sub-results from account and language search 2026-05-06 19:06:59 +02:00
Linus Rath 70c1ddd48c fix: improve settings search highlight 2026-05-06 18:58:06 +02:00
Linus Rath 2dc8537780 feat: add sub-result rows and highlight to settings search 2026-05-06 18:36:00 +02:00
Linus Rath 128d7d0401 feat: add fulltext search to settings sidebar 2026-05-06 18:29:02 +02:00
Linus Rath d3d79be64c feat: multi-server JMAP support 2026-05-06 17:33:55 +02:00
Linus Rath 43475945bf fix: update comment for external-mail warning 2026-05-06 01:54:28 +02:00
Linus Rath 09302684da fix: propagate settingsSchema 2026-05-06 01:48:49 +02:00
Linus Rath e68fcb4aec feat: redesign external banner above attachments 2026-05-06 01:37:29 +02:00
Linus Rath ded13f02cc fix: show notification banners above attachments 2026-05-06 01:14:01 +02:00
Linus Rath cfdee5e5c1 refactor: rework S/MIME banner to match calendar invitation 2026-05-06 01:05:40 +02:00
Linus Rath e7648eb1ac fix: close PDF preview on Escape before email viewer 2026-05-06 00:58:43 +02:00
Linus Rath e7be3d1e0c fix: render PDF previews via <object> with blob: in object-src CSP #253 2026-05-06 00:54:46 +02:00
Linus Rath 9639a6bb75 feat: expand calendar invitation banner on row click 2026-05-06 00:51:16 +02:00
Linus Rath 904a62ce79 feat: make calendar invitation banner collapsible 2026-05-06 00:49:09 +02:00
Linus Rath 2903e56cf6 fix: align calendar invitation icon with sender avatar column 2026-05-06 00:45:37 +02:00
Linus Rath b5e0189938 fix: adjust toast item border radius and progress bar styles 2026-05-05 21:57:56 +02:00
Linus Rath a44bd7c3e6 fix: add missing body type assertion in createPluginAPI fetch options 2026-05-05 21:52:58 +02:00
Linus Rath 0885d3c13e feat: http:fetch permission + httpOrigins manifest field 2026-05-05 21:50:04 +02:00
Linus Rath ef8eb1d73b fix: read activeAccountId from authStore in account selectors 2026-05-05 21:20:45 +02:00
Linus Rath 9f67bc078a feat: ingest icon/banner/screenshots from source repo 2026-05-05 21:12:23 +02:00
Linus Rath da411af6d3 feat: project EmailReadView for email-banner slot, expose auth results 2026-05-05 20:31:11 +02:00
Linus Rath 265908b05b fix: resolve PLUGIN_DEV_DIR plugins in admin config route 2026-05-05 20:17:45 +02:00
Linus Rath 2a769c2b0a feat: run onBeforeEmailSend hook before send, expose fromEmail on OutgoingEmail 2026-05-05 20:04:00 +02:00
Linus Rath 28054c81ea fix: inline plugin configure panel to avoid dev-mode hang 2026-05-05 19:04:29 +02:00
Linus Rath e7264f521c fix: collapse admin panel into single tabbed page 2026-05-05 18:50:57 +02:00
Linus Rath 1b0ca8967e feat: bundle plugin src/ on demand via esbuild 2026-05-05 18:35:22 +02:00
Linus Rath 3e336d459c feat: plugin hot-reload + dev-folder loading 2026-05-05 18:05:17 +02:00
Linus Rath 94f55afd1f fix: remove fly-in animation from context menu submenus 2026-05-05 17:21:46 +02:00
Linus Rath 7b058ed0ac fix: calendar invitation picker clipping #250 2026-05-05 17:17:01 +02:00
Luis Felipe MarzagaoandLinus Rath 853b0eb855 fix: add missing Czech flag icon 2026-05-05 08:59:28 +02:00
Linus Rath 41a458d872 fix: add Grafana badge to README for dashboard access 2026-05-04 23:56:55 +02:00
Linus Rath d1c5dba7d7 chore: update version to 1.6.1 2026-05-04 12:34:53 +02:00
Linus Rath 8c50abe221 fix: synchronize mobile submenu view with browser history for better navigation 2026-05-04 12:31:51 +02:00
Linus Rath 07367a8a5d fix: update email viewer styles to improve overflow handling 2026-05-04 12:27:44 +02:00
Linus Rath 1a50788c91 fix: ensure cookieSlot consistency during account updates in auth store 2026-05-04 12:09:58 +02:00
Linus Rath 0e06bfe273 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-04 11:25:49 +02:00
Linus Rath f68e41d81a fix: enhance sharing functionality by renaming state 2026-05-04 11:24:05 +02:00
MaxwellandLinus Rath 8b164c556e fix: thread per-account cookie slot through OAuth flows
The multi-account refresh-token cookie slot wiring was half-implemented:
every account's refresh token ended up on slot 0, so "+ Add Account"
silently clobbered the previous account's `jmap_rt` cookie. On page
refresh, only the most-recently-added account had a working refresh
token; the others bounced to login.

Three coordinated changes:

1. `app/[locale]/login/page.tsx` (handleOAuthLogin): write the next-free
   cookie slot to `sessionStorage['oauth_cookie_slot']` before redirecting
   to the IdP. `loginWithOAuth` already reads this key but it was never
   written, so it always defaulted to 0.

2. `stores/auth-store.ts` (loginWithOAuth): distinguish "no value set"
   (`rawSlot === null`) from "value is 0". Previously
   `parseInt(getItem(...) || '0')` collapsed both cases, making the
   `getNextCookieSlot()` fallback unreachable.

3. `stores/auth-store.ts` (loginWithServerSso) +
   `app/api/auth/sso/complete/route.ts`: pass the slot through the body of
   the POST and use it for `refreshTokenCookieName(slot)`. Same pattern as
   the existing `/api/auth/token POST` that already accepts a slot. The
   server defaults to 0 for back-compat with any caller that omits it.

After the fix, signing in with multiple accounts produces distinct
`jmap_rt`, `jmap_rt_1`, `jmap_rt_2`, ... cookies (matching the cookieSlot
field in account-store) and all accounts survive a page refresh.

Repro before the fix:
- Sign in with one account, refresh — works.
- Click "+ Add Account", sign in with a second account, refresh — second
  account vanishes from the dropdown; switching to the first account in
  the dropdown still shows the second account's identity in the From box.
2026-05-04 11:22:45 +02:00
Linus Rath 2e1f53c899 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-03 20:07:20 +02:00
Linus Rath a6d2efaf74 feat: sanitize identity display name to prevent invalid From headers 2026-05-03 20:06:41 +02:00
Luis Felipe MarzagaoandLinus Rath 01cd9644ed i18n: update mailbox context menu across 12 locales 2026-05-03 11:01:39 +02:00
Linus Rath 1521826d37 feat: add functionality to automatically add recipients to trusted senders when replying 2026-05-02 23:50:50 +02:00
Linus Rath 0d218d0d2a fix: square the colored left marker on calendar events 2026-05-02 23:41:08 +02:00
Linus Rath 9777dd655c feat: add share indicators for calendars and contacts, update JMAP capabilities #244 2026-05-02 23:29:21 +02:00
Linus Rath f970fd1822 feat: add plugin hooks for compose, attachments, search, lifecycle, and routing 2026-05-02 21:27:56 +02:00
Linus Rath 5e096240b3 feat: refresh update status on every dev reload 2026-05-02 13:23:58 +02:00
Linus Rath bc97a1ac10 feat: make update notice non-dismissible 2026-05-02 13:07:34 +02:00
Linus Rath 4594fb2572 revert: restore VERSION to correct value 2026-05-02 01:59:27 +02:00
Linus Rath 5319562c94 feat: add update-available detection 2026-05-02 01:58:30 +02:00
Linus Rath 599fa66822 fix: show git commit in About instead of "unknown" 2026-05-02 00:28:08 +02:00
Linus Rath 8041700668 chore: update version to 1.6.0 2026-05-01 22:02:57 +02:00
Linus Rath bade68a8b8 i18n: add missing email viewer detail and authentication translations 2026-05-01 22:02:14 +02:00
Linus Rath 4be7176802 chore: update version to 1.6.0 2026-05-01 21:56:53 +02:00
Linus Rath 8813533958 fix: respect per-email dark mode toggle when always-light setting is on 2026-05-01 21:42:02 +02:00
Linus Rath affa239d75 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-01 21:35:53 +02:00
Linus Rath 5d292fa43f fix: scroll apps list in navigation rail to prevent overflow 2026-05-01 21:35:29 +02:00
Linus Rath 878df6bb49 refactor: enhance path rendering in mailbox context menu 2026-05-01 21:26:03 +02:00
Linus Rath 607a9584fd refactor: implement path shortening for mailbox context menu 2026-05-01 21:25:31 +02:00
Linus Rath b8e2bfd793 fix: show full path in mailbox context menu header 2026-05-01 21:15:47 +02:00
Linus Rath 4ad6d37877 fix: clamp context submenu inside viewport 2026-05-01 21:03:54 +02:00
Linus Rath d7c29b7bec refactor: rework mobile mail viewer toolbar 2026-05-01 21:00:34 +02:00
Luis Felipe MarzagaoandLinus Rath b86bc541ab i18n: add missing keys accross 14 locales 2026-05-01 20:37:51 +02:00
Luis Felipe MarzagaoandLinus Rath 8c74e01a40 fix: add useTranslations for "selected emails" and "cancel" on email list batch operations 2026-05-01 20:37:51 +02:00
Linus Rath 231a9017d2 refactor: make settings panel mobile friendly 2026-05-01 20:30:15 +02:00
Linus Rath 9a5bb78b18 refactor: make admin panel mobile friendly 2026-05-01 20:22:29 +02:00
Linus Rath eecf16daa2 fix: stop silently destroying emails when trash mailbox isnt found #195 2026-05-01 20:01:13 +02:00
Vadim BelovandLinus Rath 210150a02e Fix push preview JMAP query
Resolve the Inbox mailbox id before running Email/query.

The previous query passed a JMAP result reference object directly into the inMailbox filter, which can make the preview endpoint return 502 and cause push notifications to fall back to the generic “New mail” text.
2026-05-01 19:53:25 +02:00
Linus Rath e50691d6c4 fix: navigate tour to mailbox when starting from another page 2026-05-01 18:30:51 +02:00
Linus Rath 089963b1b3 refactor: redesign expanded details panel 2026-05-01 18:22:06 +02:00
Linus Rath 4af952613a fix: prevent context menu jump and animation on open 2026-05-01 17:43:07 +02:00
Linus Rath 0d9fa0285f fix: prevent context menu from clipping below viewport 2026-05-01 17:28:40 +02:00
Linus Rath 683fe75864 i18n: translate SPF/DKIM/DMARC tooltips 2026-05-01 17:25:52 +02:00
Linus Rath e9c9be84ad fix: preserve list scroll position when tagging an email 2026-05-01 17:18:11 +02:00
Linus Rath 7822a363dd fix: render below-header overflow popup outside clipped row 2026-05-01 17:10:17 +02:00
Linus Rath 1e535e96a2 feat: image attachment thumbnails and preview chips 2026-05-01 17:03:45 +02:00
Linus Rath 32135ddb95 fix: collapse below-header attachments to single row with overflow pill 2026-05-01 16:29:38 +02:00
Linus Rath 841513e510 feat: support subpath deployment with NEXT_PUBLIC_BASE_PATH environment variable 2026-05-01 14:57:49 +02:00
Linus RathandGitHub 5964b2e456 Update README 2026-05-01 10:06:49 +02:00
Linus Rath 6a8ad525f1 chore: update version to 1.5.4 2026-05-01 02:17:33 +02:00
Linus Rath 31d17098d6 feat: open mail from push notification clicks 2026-05-01 02:13:25 +02:00
Linus Rath 3f97e6ed8d fix: scope email notifications to genuine inbox deliveries 2026-05-01 02:08:13 +02:00
Linus Rath 2dea33e698 feat: improve new email push notification logic for inbox 2026-05-01 01:57:57 +02:00
Linus Rath 123764f8b8 feat: improve new email notification logic for inbox 2026-05-01 01:55:24 +02:00
Linus Rath ec0f355c13 feat: allow custom sub-addressing delimiter character #239 2026-05-01 01:48:39 +02:00
Linus Rath c555973b6b feat: configurable sub-addressing delimiter #239 2026-05-01 01:42:06 +02:00
Linus Rath a8db02e881 i18n: add missing keys across 15 locales 2026-05-01 01:26:22 +02:00
AbdullahandLinus Rath 7dc5984359 feat(i18n): add Turkish localization 2026-05-01 01:16:30 +02:00
Linus Rath 1c3003421e fix: extend timeout for PushVerification and clean up leftover subscriptions 2026-05-01 00:54:40 +02:00
Linus Rath f3d9115ecd feat: web push notifications for PWA #233 2026-05-01 00:26:48 +02:00
Linus Rath 4400a7abba fix: evict unrecoverable basic-auth accounts on reload 2026-04-30 15:39:11 +02:00
Linus Rath 45a4db1c22 fix: pin JMAP auth verification to configured server URL #237 2026-04-30 15:34:14 +02:00
Linus Rath 65eef4b2b8 fix: persist htmlBody in drafts to preserve rich formatting #236 2026-04-30 15:24:09 +02:00
Linus Rath 25de7d996c feat: add tables to composer #236 2026-04-30 15:16:51 +02:00
Luis Felipe MarzagaoandLinus Rath c406fbb73e fixup! fix: implement useTranslations for start date on event detail popover 2026-04-30 11:08:16 +02:00
Luis Felipe MarzagaoandLinus Rath 31024396e3 fixup! fix: implement useTranslations for start date on event modal 2026-04-30 11:08:16 +02:00
Luis Felipe MarzagaoandLinus Rath f0967f90eb fix: implement useTranslations for start date on event modal 2026-04-30 11:08:16 +02:00
Luis Felipe MarzagaoandLinus Rath a4bb8e0c28 fix: implement useTranslations for start date on event detail popover 2026-04-30 11:08:16 +02:00
Linus Rath 7188abc9bc fix: set In-Reply-To and References on replies #234 2026-04-30 01:25:04 +02:00
Linus Rath 4a91cd0c44 style: drop iframe border-radius 2026-04-29 20:26:40 +02:00
Linus Rath 6abf8a5dd8 fix: detect <style> tag for padding 2026-04-29 20:25:24 +02:00
Linus Rath 3667c842c6 fix: light-mode override to body content only 2026-04-29 20:17:59 +02:00
Linus Rath b64721b43c fix: pad bare HTML emails like plain-text mails 2026-04-29 20:09:00 +02:00
Linus Rath 6b5ca2cb89 fix: smooth out body load, prevent flicker 2026-04-29 20:03:58 +02:00
Linus Rath 0f6e4f995f fix: prevent iframe flash on Load images or Trust sender 2026-04-29 18:29:48 +02:00
Linus Rath 0b6fdcabfb fix: update connect-src to include http protocol in development mode 2026-04-29 13:55:38 +02:00
Linus Rath fc49fe0687 chore: update version to 1.5.3 2026-04-28 18:43:09 +02:00
Linus Rath 419382d25d feat: add contacts feature gate and update telemetry payload 2026-04-28 17:54:11 +02:00
Linus Rath 8935b81f12 chore: update version to 1.5.3 2026-04-28 17:34:06 +02:00
Linus Rath ec581ce53e feat: update Docker configuration 2026-04-28 17:27:47 +02:00
Linus Rath 81d8465a79 fix: block telemetry endpoint from pointing at internal hosts 2026-04-28 17:16:30 +02:00
Linus Rath 0f3b506604 feat: add extension preview page and API for detailed extension information 2026-04-28 15:55:23 +02:00
Linus Rath 1b84547211 feat: add Theme API v2 with token compiler, skin slot 2026-04-28 15:39:13 +02:00
Linus Rath dafc8ace3c feat: track unique logins 2026-04-28 08:19:46 +02:00
Linus Rath 2c419cc4fe feat: add right-click context menu on empty calendar space 2026-04-28 01:56:15 +02:00
Linus Rath 90acf181f3 fix: harden plugin config, TOTP token exchange, and branding file serving 2026-04-28 01:44:37 +02:00
Linus Rath 54af07f2af feat: add anonymous instance telemetry
Adds a once-per-day heartbeat that lets the project see how many
instances run Bulwark, on what platforms, with what features enabled,
and roughly how many accounts they have. No email addresses, hostnames,
IPs, or any end-user data are ever sent.

- lib/telemetry: state file, payload builder, jittered scheduler,
  instance_id persistence at <data-dir>/.telemetry-id (delete to reset)
- app/api/admin/telemetry: admin API for status / set-consent /
  set-endpoint / send-now (all audit-logged)
- app/admin/telemetry: settings page with status, JSON payload preview,
  endpoint editor, send-now button, link to the privacy page
- instrumentation.node.ts: starts the scheduler on boot

Default state is enabled. The first heartbeat fires 1 hour after boot
so an admin who installs and immediately disables produces zero pings.
Disable via the settings UI, BULWARK_TELEMETRY=off (or
BULWARK_TELEMETRY_DISABLED=1), or by clearing the endpoint.

Account counts are bucketed (1, 2-5, 6-10, 11-50, 51-200, 201+) so a
small instance can't be re-identified by exact size. The /.telemetry-id
file can be deleted to mint a fresh instance_id.

Receiving collector is open source at bulwarkmail/dashboard. Self-host
your own and point at it via BULWARK_TELEMETRY_URL. Full schema,
retention (90d raw → aggregates), and lawful basis are documented at
bulwarkmail.org/docs/legal/privacy/telemetry.
2026-04-28 01:28:41 +02:00
Linus Rath 68f1fabc4b fix: batch shortcuts act on multi-selection when present #228 2026-04-28 00:04:29 +02:00
Linus Rath 27451807db chore: update version to 1.5.2 2026-04-27 22:45:23 +02:00
Linus Rath 55099bdcbb fix: disable Stalwart admin check when custom JMAP endpoints allowed 2026-04-27 22:41:51 +02:00
Linus Rath 71eb720065 chore: update version to 1.5.2 2026-04-27 22:30:32 +02:00
Linus Rath 8abb0c8717 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-27 22:24:59 +02:00
Linus Rath 3043639d2d fix: validate URLs before outbound fetch 2026-04-27 22:23:39 +02:00
Linus Rath e9b3eacbb7 fix: prevent drag creation on touch events in useTimeGridInteractions 2026-04-27 13:37:34 +02:00
Linus Rath f37e55e285 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-27 10:36:22 +02:00
c0af2dbdd1 i18n: Fix word 'ficheiro' and its variants.
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 10:17:32 +02:00
Luis Felipe MarzagaoandLinus Rath ae517732f7 i18n: Fix word 'contactos' and its variants. 2026-04-27 10:17:32 +02:00
Linus Rath 3e1de10213 feat: add JMAP sharing for calendars and address books 2026-04-26 20:10:04 +02:00
Roman VanicekandLinus Rath 511740bb6d Additional Czech translations for recent changes. 2026-04-26 15:48:43 +02:00
Roman VanicekandLinus Rath 4a24d2a11d feat: add Czech language support 2026-04-26 15:48:43 +02:00
Linus Rath aadf56c27b fix: emit RFC 9553 name kinds and decode QUOTED-PRINTABLE in vCard import #224 #187 2026-04-26 02:47:19 +02:00
Linus Rath 9f8588eadc fix: hide preview line in compact density to match settings preview (#223) 2026-04-26 02:12:28 +02:00
Linus Rath d657aec391 feat: add support for right-side composer sidebar and update related types 2026-04-25 19:45:45 +02:00
Linus RathandCopilot e683c90404 fix: implement inline matcher for Next.js proxy and remove unnecessary config
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 19:25:15 +02:00
Linus Rath cfb4a23c9d fix: remove unnecessary runtime config for Node.js in proxy settings 2026-04-25 18:54:15 +02:00
Linus Rath fe1d4861bb feat: composer-sidebar slot + plugin-declared frame-src origins 2026-04-25 18:40:54 +02:00
Linus Rath 5aa9b1d5f9 fix: honor SESSION_SECRET_FILE in dashboard warning check #222 2026-04-25 17:21:17 +02:00
Linus Rath 0913dbd3e4 chore: update version to 1.5.1 2026-04-25 16:34:45 +02:00
Linus Rath 29197ea355 feat: replace folder prompt() calls with proper modal dialog 2026-04-25 16:30:18 +02:00
Linus Rath 4788e8a91a feat: add right-click context menu to mail folders sidebar 2026-04-25 16:26:05 +02:00
Linus Rath b80678b00f feat: add 'Today' button to desktop calendar toolbar 2026-04-25 03:11:16 +02:00
Linus Rath 4f7c9c332b feat: enhance OAuth auto-setup with dialog and validation for origin and issuer URLs 2026-04-25 02:53:12 +02:00
Linus Rath da103ff06f feat: implement OAuth auto-setup functionality for Stalwart integration 2026-04-25 01:37:18 +02:00
Linus Rath 2111c77870 fix: restore admin panel after Stalwart v0.16 REST API removal 2026-04-25 01:13:17 +02:00
Linus Rath e5083ec1df fix: restore admin panel after Stalwart v0.16 REST API removal 2026-04-25 01:12:33 +02:00
Linus Rath df8d04e233 fix: restore broken viewer toolbar actions and improve mobile menu #220 2026-04-24 19:31:38 +02:00
Linus RathandGitHub 9a11a18a44 Update README.md 2026-04-24 17:02:00 +02:00
Christian Lepthien ce9f7af330 fix: improve corean flag 2026-04-24 14:56:58 +02:00
Linus Rath 081e8a0310 feat: add setting to show avatars in junk folder, disabled by default 2026-04-23 18:23:48 +02:00
Linus Rath 6c3529b368 fix: stop flicker on background folder refresh 2026-04-23 18:06:40 +02:00
Linus Rath 077a4f03a7 fix: preserve search/filter on batch move and archive 2026-04-23 18:01:21 +02:00
Linus Rath b04dfaf252 fix: preserve search/filter when moving emails via drag-drop 2026-04-23 16:37:17 +02:00
Linus Rath 9c7452e7fd chore: add admin dashboard data directory configuration to environment files 2026-04-23 11:22:14 +02:00
Linus Rath ec5593f567 Merge branch 'dev' into main 2026-04-23 00:10:22 +02:00
Linus Rath c30c38a7af chore: update version to 1.5.0 2026-04-23 00:07:34 +02:00
Linus Rath 7494fc1776 chore: update version to 1.5.0 2026-04-22 23:49:44 +02:00
Linus Rath 16fe92d2e9 chore: update version to 1.5.0 2026-04-22 23:31:32 +02:00
Linus Rath 3544e6a9e7 chore: bump dependencies to latest compatible versions 2026-04-22 23:03:05 +02:00
Linus Rath dd1f3e11e6 fix: retry mailbox fetch on first login to handle lazy provisioning #217 2026-04-22 22:45:49 +02:00
Linus Rath 6503482b55 feat: support resizing events from the top edge 2026-04-22 21:14:28 +02:00
Linus Rath cab57f6cd7 feat: add timezone-aware formatting for event start times and update utcEnd on duration change 2026-04-22 21:05:41 +02:00
Linus Rath 27f4fbdce4 feat: optimize layout of overlapping events in calendar 2026-04-22 20:53:04 +02:00
Linus Rath 39a228b20e feat: streamline mobile email viewer header layout 2026-04-22 20:50:12 +02:00
Linus Rath 4b069808d6 feat: add isMobile prop to calendar views and time grid interactions 2026-04-22 20:37:00 +02:00
Linus Rath 468851ff25 feat: show full folder path in move/drop toast 2026-04-22 00:14:49 +02:00
Linus Rath b7374570c8 feat: implement bulk email moving functionality in email store 2026-04-22 00:10:40 +02:00
Linus Rath 3c9fa5dc25 feat: implement batch archiving of emails 2026-04-22 00:05:38 +02:00
Linus Rath 3ade1c6473 fix: update mailbox retrieval logic in archive handling to use fresh state 2026-04-22 00:00:43 +02:00
Linus Rath 1810a474a2 fix: enhance error message for mailbox creation failure 2026-04-22 00:00:01 +02:00
Linus Rath 40982bc37b fix: align hover styles and selection-toggle target with focused item 2026-04-21 23:50:55 +02:00
Linus Rath 76f6149841 feat: add setting to toggle alphabetical grouping in contacts list 2026-04-21 23:22:18 +02:00
Linus Rath 7d6a3c8c76 feat: group contacts by first letter with sticky section headers 2026-04-21 23:15:13 +02:00
Linus Rath 361ad49f5f fix: read matchMedia synchronously on client to prevent layout flicker 2026-04-21 23:02:21 +02:00
Linus Rath f9aa5cbaee fix: skip checkAuth on route change when already authenticated 2026-04-21 22:54:02 +02:00
Linus Rath 3f36045990 feat: reorganize settings into 6 groups with clearer tabs 2026-04-21 22:22:02 +02:00
Linus Rath 9a44babcf1 feat: add collapsible details to calendar invitation banner and update sanitization config for iframe rendering 2026-04-21 21:21:42 +02:00
Linus Rath f032758303 feat: revamp contact detail, filters, and add photo/print/duplicate 2026-04-21 21:16:54 +02:00
Linus Rath 92c7f74420 feat: add right-click context menu to contacts 2026-04-21 19:33:00 +02:00
Linus Rath c2e4518cfa feat: add account addition button to navigation rail 2026-04-21 19:20:50 +02:00
Linus Rath 00f33afdf9 feat: add contact activity component with recent emails and upcoming events 2026-04-21 19:18:36 +02:00
Linus Rath e566cfe687 feat: add API Keys management and IP allowlist for App Passwords 2026-04-21 18:59:47 +02:00
Linus Rath 6b7c849332 refactor: remove Stalwart API URL configuration 2026-04-21 17:50:18 +02:00
Linus Rath 30c4afb977 i18n: add missing translation keys 2026-04-21 17:42:52 +02:00
Linus Rath 1f60671886 fix: clean up unused imports and improve TOTP QR code rendering 2026-04-21 17:30:00 +02:00
Linus Rath 794001fdbd feat: migrate Stalwart management API to JMAP x: methods (0.16)
Drops the 0.15 REST management API and routes all account/auth/crypto/
principal operations through Stalwart 0.16's schema-driven JMAP
endpoint via a single passthrough (/api/account/stalwart/jmap).

- New client helper `stalwartJmap` + typed `requireResult`
- account-security-store rewritten against x:AccountPassword, x:AppPassword,
  x:AccountSettings, x:Account (with currentSecret for TOTP ops)
- Client-side TOTP setup via `otpauth`; server-generated app password
  secrets shown once on create
- Admin check switched to /api/account permissions
  (sysAccountQuery/sysTenantQuery/sysSystemSettingsGet)
- Removed sieve vacation-overwrite workaround (fixed upstream #1251)
- Deleted old REST routes, StalwartClient, stale tests; added new
  tests for passthrough + store
2026-04-21 17:29:23 +02:00
Linus Rath 9ad2facad3 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-21 14:12:03 +02:00
Linus Rath 89d8282846 fix: include original attachments when forwarding an email #214 2026-04-21 13:58:02 +02:00
Denis TeyssierandLinus Rath c3960a99be fix: removed allow-same-origin to avoid anti-pattern permissions on iframe sandbox #209 2026-04-20 14:55:50 +02:00
Denis TeyssierandLinus Rath e73ffa7449 fix: added sandbox allow-scripts to PDF file preview modal #209 2026-04-20 14:55:50 +02:00
Denis TeyssierandLinus Rath 37bd490072 fix: added media-src CSP rule for media attachments #209 2026-04-20 14:55:50 +02:00
Linus Rath 24c53e5ce7 fix: apply archive context-menu action to all selected emails # 212 2026-04-20 14:35:56 +02:00
Linus Rath aa7f886795 refactor: remove deprecated push notification API routes and related logic 2026-04-20 12:41:38 +02:00
Linus Rath 00dec8c5a0 fix: correct regex escape for hyphen in FCM token validation 2026-04-20 12:08:41 +02:00
Linus Rath 578e60c0bc feat: nevermind, migrate push notification handling from UnifiedPush to FCM 2026-04-20 12:06:30 +02:00
Linus Rath 8b21851353 feat: migrate to UnifiedPush 2026-04-20 10:54:26 +02:00
Linus Rath 15006086d2 feat: implement JMAP push notification handling and subscription management 2026-04-20 08:26:09 +02:00
Linus Rath bc3b923945 fix: apply mobile toolbar spacing fix to email viewer action bar 2026-04-19 17:59:58 +02:00
Linus Rath 76ba9e5f85 fix: retry session fetch with Authorization after redirect strips it #210 2026-04-19 16:40:55 +02:00
Linus Rath 44eb5fced2 fix: stop birthday calendar from re-showing after manual hide #204 2026-04-19 15:04:27 +02:00
Linus Rath 172d8267ef fix: persist typed calendar participant on save without requiring Enter #206 2026-04-19 13:47:17 +02:00
Linus Rath f162f1e3d4 feat: refresh JMAP on reload gestures, fix mobile bottom nav spacing 2026-04-19 13:43:44 +02:00
Linus Rath 6fa0029d0b feat: add right-click context menu on calendar events 2026-04-18 13:46:49 +02:00
nesgarboandLinus Rath 028e78a0c9 fix: serve PWA manifest dynamically to respect runtime APP_NAME env var
Delete static public/manifest.json (hardcoded "Bulwark Webmail") and mark
app/manifest.ts as force-dynamic so Next.js evaluates APP_NAME at request
time instead of build time, fixing the native browser install prompt.

Closes #207
2026-04-18 13:20:14 +02:00
Linus Rath 440a4e919a Revert "perf: make root HTML cacheable by dropping per-request CSP nonce"
This reverts commit 522bf6a019.
2026-04-18 13:10:42 +02:00
Linus Rath b8f39198e1 fix: allow blob: in frame-src CSP for attachment previews #209 2026-04-18 13:04:25 +02:00
Linus Rath 966bbe3957 fix: add public JavaScript files to ESLint ignore list 2026-04-18 13:01:52 +02:00
Linus Rath 522bf6a019 perf: make root HTML cacheable by dropping per-request CSP nonce 2026-04-18 13:01:12 +02:00
Linus Rath 1689315c3a feat: add setting to hide inline images from attachment list 2026-04-18 01:07:34 +02:00
Linus Rath d4f7ae522e fix: use cid references for inline images #163 2026-04-18 00:57:46 +02:00
Linus Rath f05f70a9e5 fix: prevent XSS via quote injection in plain-text email linkifier 2026-04-18 00:35:21 +02:00
Linus Rath 850ee73048 fix: preserve Nextcloud Mail filter markers across saves #201 2026-04-17 02:08:04 +02:00
Linus Rath 5842f3f914 fix: preserve birthday calendar visibility across navigation #204 2026-04-17 01:54:08 +02:00
Linus Rath f303478850 chore: update version to 1.4.14 2026-04-16 19:08:42 +02:00
Linus Rath 8bdadc7ba3 fix: standardize punctuation 2026-04-16 19:07:42 +02:00
Linus Rath 6b57118add chore: update version to 1.4.14 2026-04-16 19:05:20 +02:00
Linus Rath ffb645671c chore: update README 2026-04-16 19:04:18 +02:00
Linus Rath e63ce25f5f chore: update version to 1.4.14 2026-04-16 18:59:23 +02:00
Linus Rath d31b30ba4a Merge branch 'dev' 2026-04-16 18:51:01 +02:00
Linus Rath 31eff96614 feat: Enhance external rule handling in Sieve parser and store #201 2026-04-16 17:22:52 +02:00
nesgarboandLinus Rath 2ea8054240 fix: improve CalDAV task detection for external clients (Thunderbird) #84
Two issues prevented tasks created in Thunderbird (or other CalDAV
clients) from appearing in the task view:

1. percentComplete was not in CALENDAR_TASK_PROPERTIES, so it was
   never requested from the server and the heuristic check for it
   was always false (dead code).

2. The hasTaskFields heuristic used strict value checks:
   - 'progress' in obj && typeof obj.progress === 'string'
     → fails when Stalwart returns progress: null instead of the
       RFC 8984 default "needs-action"
   - 'due' in obj && obj.due != null
     → fails when Stalwart includes due: null for tasks without a
       DUE date (key present, value null)

RFC 8984 §5.2 defines due, progress and percentComplete as Task-only
properties — a VEVENT will never include them in a JMAP response.
Checking for key presence alone (even when null) is therefore a
reliable discriminator, regardless of the actual value.
2026-04-16 16:54:09 +02:00
nesgarboandLinus Rath 5a2e141ed6 fix: send iMIP invitation emails when creating or updating calendar events #192
sendImipInvitation() was fully implemented but never called after
createEvent or updateEvent — only sendImipCancellation was wired up
(in deleteEvent). This meant that even when the "send invitation"
checkbox was checked and participants were correctly saved on the
server, no invitation email was dispatched to attendees.

Apply the same pattern already used by deleteEvent: after a successful
create/update, if sendSchedulingMessages is true and the event has
participants, call sendImipInvitation() in a best-effort try/catch so
that email failures do not roll back the calendar operation.

For createEvent, the raw server response (created) is used directly
since it is already available and matches the CalendarEvent type
expected by sendImipInvitation.

For updateEvent, the updated event is reconstructed by merging the
existing store event with the incoming patch, avoiding an extra API
round-trip.
2026-04-16 16:54:09 +02:00
nesgarboandLinus Rath fa343e0768 fix: hide ICS attachments from email attachment list when invitation banner is shown
When an email contains a calendar invitation, the raw .ics MIME parts
(text/calendar, application/ics, application/icalendar) were showing
up in the attachment list alongside the calendar invitation banner,
which is confusing — the banner already provides the relevant UI.

Filter those MIME types out of the displayed attachment list whenever
the calendar invitation banner is active, reusing the existing
isCalendarMimeType utility from lib/calendar-invitation.ts.
2026-04-16 16:54:09 +02:00
nesgarboandLinus Rath 8969338b2a fix: RFC 5545/6047 compliance for outgoing iMIP calendar emails
Three issues addressed in sendImipReply, sendImipInvitation and
sendImipCancellation:

1. Line folding (RFC 5545 §3.1)
   Add foldIcsLine() helper that wraps iCalendar content lines at
   74 characters, inserting CRLF + SPACE as required by the spec.
   Previously, long lines (e.g. ATTENDEE with a full CN and mailto
   URI) could exceed the 75-octet limit and cause strict parsers to
   silently reject the ICS.

2. MIME wrapper type (RFC 6047 §3 + CalConnect iMIP Best Practices)
   Change bodyStructure from multipart/alternative to multipart/mixed.
   The CalConnect interoperability guide recommends multipart/mixed as
   the outer wrapper for messages carrying a text/calendar part; many
   clients skip iTIP processing when they see multipart/alternative.

3. Calendar part metadata
   Add charset=UTF-8 to the text/calendar Content-Type, disposition
   inline, and a descriptive filename (reply.ics / invite.ics /
   cancel.ics) to each outgoing calendar MIME part.

Note: Gmail-to-Gmail events are handled by Google's internal scheduling
API and cannot be updated via iMIP regardless of MIME structure. This
fix improves interoperability with Outlook, Thunderbird, Fastmail and
standard CalDAV servers.
2026-04-16 16:54:09 +02:00
nesgarboandLinus Rath 4c720d6855 fix: export isCalendarMimeType for use in email attachment filtering
Previously isCalendarMimeType was a module-private function in
lib/calendar-invitation.ts. Exporting it allows the email viewer
to reuse the same MIME type detection logic when filtering out
calendar attachments, avoiding duplication of the type set.
2026-04-16 16:54:09 +02:00
Linus Rath ad175d20e3 feat: enhance email deletion and spam handling with improved parameterization 2026-04-16 16:50:42 +02:00
nesgarboandLinus Rath fb2f0c9158 fix: use 'company' consistently in .env.example branding comments 2026-04-15 11:25:16 +02:00
nesgarboandLinus Rath 7daa46e73e docs: document PWA and branding env vars in .env.example
Reorganize the Branding section with subsections (App identity, Icons &
favicon, PWA appearance, Logos, Login page) and document the new variables
APP_SHORT_NAME, APP_DESCRIPTION, PWA_ICON_URL, PWA_THEME_COLOR and
PWA_BACKGROUND_COLOR.
2026-04-15 11:25:16 +02:00
nesgarboandLinus Rath 195185dc52 feat: show app name and logo in PWA install prompt
Use runtime config (appName, appLogoLightUrl, appLogoDarkUrl, faviconUrl)
instead of the hardcoded 'Bulwark' string and download icon.
2026-04-15 11:25:16 +02:00
nesgarboandLinus Rath 8a9dce1a99 feat: dynamic PWA manifest with configurable name, description and icons
- Add app/manifest.ts to serve /manifest.webmanifest dynamically at runtime
- Name, short_name, description, theme_color and background_color are read
  from env vars (APP_NAME, APP_SHORT_NAME, APP_DESCRIPTION, PWA_THEME_COLOR,
  PWA_BACKGROUND_COLOR) with Bulwark defaults as fallback
- Add /api/pwa-icon/[size] route that auto-generates 192x192 and 512x512 PNG
  icons from PWA_ICON_URL (or FAVICON_URL as fallback) using Sharp; results
  are cached in memory
- Remove static manifest: '/manifest.json' from layout metadata; Next.js
  injects the link automatically from app/manifest.ts
- Fix pre-existing ESLint no-undef on RequestInit in browser-navigation.ts
2026-04-15 11:25:16 +02:00
shukiandLinus Rath c690e8eb76 fix: skip intl middleware for paths already containing a locale prefix
When localePrefix is 'always' (or 'as-needed' with a non-default locale),
paths like /en/settings already have the locale in the URL. Running them
through the next-intl middleware a second time can trigger rewrite loops,
especially when combined with a proxy basePath where the middleware's
detection of the 'current' path conflicts with the rewritten one.

Skip the intl middleware in this case — the path is already in the
canonical locale-prefixed form and no further rewriting is needed.

This makes NEXT_PUBLIC_LOCALE_PREFIX=always reliable for sub-path
deployments.
2026-04-14 18:09:37 +02:00
shukiandLinus Rath 9d867cbff6 feat: configurable localePrefix via NEXT_PUBLIC_LOCALE_PREFIX
Allow the next-intl localePrefix mode to be set via environment
variable at build time, defaulting to the existing 'never' behavior.

This is useful when proxying Bulwark under a sub-path (where
'never' can trigger rewrite loops) or when users prefer
URL-embedded locales (/en/settings vs /settings).

Usage:
  NEXT_PUBLIC_LOCALE_PREFIX=always npm run build

Accepted values: 'never' (default), 'always', 'as-needed'.
2026-04-14 18:09:37 +02:00
Linus Rath f22699fe20 feat: add unified mailbox across accounts and sidebar icons toggle 2026-04-14 17:36:13 +02:00
shukiandLinus Rath a7db3883aa feat: apiFetch helper for mount-prefix-aware API calls
Makes every client-side fetch('/api/...') call respect the mount prefix
when Bulwark is served behind a reverse proxy at a sub-path (e.g.
`/webmail`).

### Problem

`getPathPrefix()` (added in 1.4.13 by #XXX / d762b94) already fixes
router navigation and redirect URIs for reverse-proxy deployments.
Client-side `fetch()` calls, though, still target the browser origin:

    await fetch('/api/foo')
    // Browser at /webmail/en/inbox → hits /api/foo (not proxied → 404)

That means the login flow, session establishment, settings save, plugin
loader, calendar import, etc. all break the moment you front Bulwark
with nginx (or any proxy) at a sub-path.

### Fix

Add `apiFetch(input, init)` next to `getPathPrefix()` in
`lib/browser-navigation.ts`. It prepends the mount prefix to any
absolute path at call time:

    await apiFetch('/api/foo')
    // /webmail/en/inbox → /webmail/api/foo
    // /en/inbox         → /api/foo

Same runtime-detection model as `getPathPrefix()` — the built bundle
works at any mount point without rebuilding or env-var config.
Protocol-relative (`//cdn...`) and absolute (`https://...`) URLs pass
through unchanged. Server-side route handlers are untouched (the mount
prefix is a browser-only concept).

### Migration

Mechanical rewrite of every client-side `fetch('/api/...')` call in
hooks/, lib/, stores/, components/, app/ — 99 call sites across
26 files. `route.ts` handlers and other server-only files are skipped.

### Compat

- No behaviour change when mounted at `/` (the common case): an empty
  prefix + raw path is identical to raw path.
- No new config knobs, env vars, or build flags.
- Supersedes PR #181 (which required a build-time `NEXT_PUBLIC_BASE_PATH`)
  — will close #181 after this lands.

### Testing

Should run the existing suite; smoke-tested by Jabali Panel which
reverse-proxies Bulwark at `/webmail/` (https://github.com/shukiv/jabali-panel).
2026-04-14 14:37:19 +02:00
Linus Rath 7fcefa53c9 Merge branch 'dev' of https://github.com/bulwarkmail/webmail into dev 2026-04-14 14:30:22 +02:00
Linus Rath bdb76c3d90 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-14 14:28:29 +02:00
Linus Rath 168b36d419 fix: add calendarAddress and replyTo to calendar participants for Stalwart compatibility #189 #192 2026-04-14 14:26:28 +02:00
chrilep a4f57e7a5c fix: add missing lang texts, feat: add ukrainian lang, add flags 2026-04-14 09:39:44 +02:00
Linus Rath 6678501501 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-13 00:51:21 +02:00
Linus Rath fa0045e01b fix: use onSuccessUpdateEmail to send before storing in Sent #188 2026-04-13 00:50:13 +02:00
Linus Rath 1b816d3185 fix: standardize tag naming and fix unknown keyword display #184 #185 2026-04-12 15:37:57 +02:00
Linus Rath 24949e183f feat: add i18n API, render hooks, and new intercept hooks to plugin system 2026-04-12 13:57:20 +02:00
Linus Rath 9207ec563c Merge branch 'dev' 2026-04-12 02:19:38 +02:00
Linus Rath 44e0e17203 chore: update version to 1.4.13 2026-04-12 02:17:54 +02:00
Linus Rath b9c9901643 chore: update version to 1.4.13 2026-04-12 02:13:55 +02:00
Linus Rath a201c1617b feat: add onAvatarResolve plugin hook 2026-04-12 01:59:53 +02:00
Linus Rath d24f402b0c fix: style links in plain text emails 2026-04-12 01:38:53 +02:00
Linus Rath 5cfc905f10 fix: seed list history entry when app initializes on an email view 2026-04-12 01:33:28 +02:00
Linus Rath 4531cfe47c fix: resolve TS errors from missing createAddressBook in demo client and client ref in thread view 2026-04-12 01:14:25 +02:00
Linus Rath 927a3b8b11 feat: store trusted senders in a dedicated JMAP address book #176 2026-04-12 01:10:28 +02:00
Linus Rath 4ad8396adc fix: remount composer on draft edit and preserve identity #60 2026-04-12 00:11:36 +02:00
shukiandLinus Rath 6aaeb34272 feat: publish standalone tarball on release
Adds a GitHub Action that builds the Next.js standalone output on
release and attaches architecture-specific tarballs (amd64 + arm64)
as release assets. Downstream projects can download and extract
instead of building from source.

Closes #178
2026-04-11 23:28:39 +02:00
Linus Rath ab57966a94 fix: search all folders when filtering emails by tag #175 2026-04-11 23:21:12 +02:00
Linus Rath 18d9b9adf6 fix: include mount prefix in SSO redirect URI when app is served under a subpath 2026-04-11 22:05:56 +02:00
Linus Rath ed311d79e3 fix: resolve TS error from optional name.components in vcard parser 2026-04-11 20:56:45 +02:00
Linus Rath 457400ceee fix: also display contact names stored in name.full #179 2026-04-11 20:15:49 +02:00
Linus Rath 88d87be685 fix: correct PWA icons with proper sizing, transparency, and dark/light mode support 2026-04-10 19:26:12 +02:00
Linus Rath 5ddb2acfc7 fix: category dropdown blocking Save button in contact form #177 2026-04-10 18:40:32 +02:00
Linus Rath 5f150f039d feat: warn on send when attachment keyword found but no file attached #172 2026-04-10 18:24:52 +02:00
Linus Rath 4f54f768e8 Feat: enable keyword reordering #174 and multi-tag support per email #173 2026-04-10 17:31:13 +02:00
Linus Rath c24762c7a3 feat: separate branch containers into beta and dev packages 2026-04-10 14:51:17 +02:00
Linus Rath 3e85e07363 feat: separate branch containers into beta and dev packages 2026-04-10 14:46:04 +02:00
Linus Rath facef97fcc feat: publish main and dev branches as separate GHCR packages 2026-04-10 14:33:18 +02:00
Linus Rath 89d580b90d feat: add don't remind me again option to install prompt for PWA 2026-04-10 14:20:48 +02:00
Linus Rath 734155c939 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-09 22:22:48 +02:00
Linus Rath 790d7084af fix: restore service worker and add manifest icons 2026-04-09 22:19:56 +02:00
Pascal DietrichandLinus Rath 60efb047d8 docs: add SESSION_SECRET_FILE and OAUTH_CLIENT_SECRET_FILE env vars 2026-04-09 22:13:08 +02:00
Pascal DietrichandLinus Rath f9052eb23f feat: add SESSION_SECRET_FILE and OAUTH_CLIENT_SECRET_FILE env vars 2026-04-09 22:13:08 +02:00
Linus Rath 9c5daa3918 chore: update version to 1.4.12 2026-04-09 20:08:03 +02:00
Linus Rath 23870801fc chore: update version to 1.4.12 2026-04-09 20:04:06 +02:00
Davi MacielandLinus Rath ae793551ba fix(email-list): show recipient in Sent and Drafts folders
When viewing the Sent or Drafts mailbox, the list items always
displayed the sender (email.from[0]) — which is always the logged-in
user — instead of the recipient. This makes it impossible to
identify messages by who they were sent to.

This change detects the current mailbox role from the store and
swaps the displayed person to email.to[0] when the role is 'sent'
or 'drafts'. For multi-email threads, participant names are
computed from the collected recipients of the thread's emails.

Affected components:
- EmailListItem (flat list, threading disabled)
- SingleEmailItem (single-email thread)
- ThreadListItem (multi-email thread header + avatar)

This matches the behaviour of Gmail, Outlook, Apple Mail, and
every other mainstream mail client.
2026-04-09 19:52:09 +02:00
Linus Rath f19aaf6207 fix: embed dropped images as data URLs and prevent duplicate attachment #163 2026-04-09 19:50:42 +02:00
Linus Rath d0cc439fd1 feat: add automatic identity synchronization (#167) 2026-04-09 19:39:04 +02:00
Linus Rath f42f57b8d0 fix: preserve search filters on push-triggered mailbox refresh #164 2026-04-09 19:10:34 +02:00
Linus Rath 0d4b588fd0 feat: add Latvian (lv) locale support 2026-04-08 22:42:56 +02:00
Linus Rath 6a725dde58 feat: add Latvian (lv) locale support 2026-04-08 22:42:15 +02:00
Linus Rath 6499da6281 feat: sync mail view to browser history for back/forward navigation 2026-04-08 22:20:36 +02:00
Linus Rath b2379fb03f fix: align shared account folders with primary folders #151 2026-04-08 13:45:31 +02:00
Linus Rath b29e71124d fix: prune recent files against server nodes on refresh #146 2026-04-08 13:22:51 +02:00
Linus Rath 63593e2146 fix: use fetch param type instead of RequestInit 2026-04-08 13:18:07 +02:00
Linus Rath 10cbe7a637 fix: stream WebDAV PUT uploads to avoid buffering in memory #162 2026-04-08 13:16:28 +02:00
Linus Rath 79418f013a fix: allow blob images in CSP for inline drag-and-drop #163 2026-04-08 13:10:04 +02:00
Linus Rath 4024732696 fix: handle 403 on principal fetch without console error 2026-04-08 13:08:46 +02:00
Linus Rath ffad3ea78b feat: Ability to rename address book #152 2026-04-08 13:05:42 +02:00
Linus Rath a9b9aeb44d fix: clamp Feb 29 birthdays in non-leap years #163 2026-04-08 12:47:24 +02:00
Linus Rath 3d76fe6d75 fix: focused mode expanding to the right of the screen #156 2026-04-08 12:43:48 +02:00
Linus RathandGitHub 58cafe28ad Update CONTRIBUTING.md 2026-04-07 10:17:30 +02:00
Linus RathandGitHub 60f9a3dc4d Update CONTRIBUTING.md 2026-04-07 02:28:06 +02:00
Linus RathandGitHub 11d9db5580 Update CONTRIBUTING.md 2026-04-07 02:27:19 +02:00
lingxhandLinus Rath 05d3f0b469 add zh_cn 2026-04-07 01:11:04 +02:00
Sienna Meridian SatterwhiteandLinus Rath c3f60448ad feat: configurable OAuth scopes and cookie security via env vars
Add three environment variables for deployments with external identity
providers (Keycloak, Authentik, Ory Hydra, etc.):

- OAUTH_EXTRA_SCOPES: append additional scopes to the default
  "openid email profile" (e.g. "offline_access" for refresh tokens)
- OAUTH_SCOPES: full override of the requested OAuth scopes
- COOKIE_SECURE: override the Secure flag on auth cookies (useful
  for reverse proxy setups where the internal hop is HTTP)

Without these, deploying Bulwark with an external OIDC provider that
requires `offline_access` for refresh tokens is impossible — sessions
die on every page refresh because no refresh token is issued.

All three are backwards-compatible: unset = identical to current behavior.
2026-04-06 23:22:06 +02:00
p.kwiatkowskiandLinus Rath 523711cca3 feat: add Polish language support 2026-04-06 23:20:25 +02:00
Linus Rath bcb487810a feat: add Korean language support 2026-04-05 16:46:17 +02:00
rldn12134andLinus Rath 7e1d644f7d feat: Add Korean translation file 2026-04-05 13:51:22 +02:00
Linus Rath 4aac65d7d2 feat: add birthday calendar feature with settings and localization 2026-04-03 22:45:07 +02:00
Linus Rath 14ecae61dc feat: enhance error handling in Stalwart API responses 2026-04-03 15:10:11 +02:00
Linus Rath c53ff5a30a fix: file deletion logic to update recent files and handle errors #146 2026-04-03 14:20:06 +02:00
Linus Rath 5aad97d64e feat: add PWA support with service worker and install prompt 2026-04-03 04:39:19 +02:00
Linus Rath 081c865018 fix: guard against undefined trigger in calendar event alert popover #143 2026-04-03 03:15:55 +02:00
Linus Rath 050f38b1fa fix: use dynamic batch size for calendar event retrieval 2026-04-02 23:29:37 +02:00
Linus Rath d657bdfa75 fix: improve calendar event retrieval by batching requests to avoid server limits #141 2026-04-02 22:55:07 +02:00
Linus Rath bbf724d9e1 fix: correct logic for marking email as read in EmailViewer component 2026-04-02 22:50:04 +02:00
Linus Rath 9d8c6044e3 fix: optimize htmlToPlainText function using DOMParser 2026-04-02 17:32:53 +02:00
Linus Rath 2d17ca71e3 fix: replace random ID generation with generateUUID() 2026-04-02 17:29:00 +02:00
Linus Rath d77dd1e3e1 fix: replace non-UUID fallback 2026-04-02 17:25:10 +02:00
Linus Rath 9d97b74684 fix: adjust max width of calendar invitation banner for better layout 2026-04-02 14:56:04 +02:00
Linus Rath c4673acb65 fix: replace unguarded crypto.randomUUID() with safe generateUUID() utility 2026-04-02 14:51:21 +02:00
Linus Rath 1bdc51dcc7 feat: implement version badge and easter egg in settings 2026-04-02 14:31:25 +02:00
Linus Rath 6ee0f6a40a feat: disable plugins by default, require admin approval 2026-04-02 13:50:02 +02:00
Linus Rath 9ee25c930e fix: compute per-occurrence utcStart/utcEnd in recurrence expansion #116 2026-04-02 13:35:26 +02:00
Linus Rath 1b2c70a90b fix: de-namespace addressBookIds for shared contacts in create and update operations #133 2026-04-02 11:34:26 +02:00
Linus Rath cdea876992 fix: fetch mailboxes on mount in FolderSettings when store is empty 2026-04-02 11:26:21 +02:00
Linus Rath 16557830ae fix: mailbox deletion error handling 2026-04-02 11:24:11 +02:00
Linus Rath 8836f9cdca fix: wrap handleArchive call to prevent MouseEvent leaking as argument 2026-04-02 11:19:36 +02:00
Linus Rath 26bfe9cb6c fix: extend file drop zone to fill remaining viewport height 2026-04-02 11:14:38 +02:00
Linus Rath 05eaaad61f fix: fallback to application/octet-stream for long MIME types in createFileNode 2026-04-02 11:04:59 +02:00
Linus Rath 2e0852b272 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-02 10:57:03 +02:00
Linus Rath 6173a9ad13 fix: resolve settings sync Identity mismatch for OAuth/SSO sessions #127 2026-04-02 10:54:35 +02:00
Linus RathandGitHub e3560d9cb4 Add funding configuration for Bulwark Webmail 2026-04-02 00:37:58 +02:00
Niklas VossandLinus Rath 52326326e2 fix: check plugin http.post url against origin and add regression tests 2026-04-01 11:26:12 +02:00
Niklas VossandLinus Rath 2734fa08b7 feat: instead of exposing auth headers, offer a http proxy api for plugins 2026-04-01 11:26:12 +02:00
Niklas VossandLinus Rath 67a0d622bc feat: add auth:read permission and ability to retrieve auth headers 2026-04-01 11:26:12 +02:00
chrilepandGitHub 58968a1cbf updated to show latest version in badge 2026-04-01 09:49:23 +02:00
Linus Rath 01c8afbfe8 Merge branch 'dev' 2026-03-31 18:34:49 +02:00
Linus Rath be2e0f2b68 chore: update version to 1.4.11 2026-03-31 18:24:25 +02:00
Linus Rath aa40c8be26 fix: harden proxy auth and SSRF defenses 2026-03-31 17:47:09 +02:00
Linus Rath b3d4c9241c i18n: add missing translation keys to all non-English locales 2026-03-31 16:40:36 +02:00
Linus Rath 34dd5122b3 feat: logging to include categories for better log management 2026-03-31 16:31:27 +02:00
Linus Rath dab3606b04 fix: strip server-immutable fields from updates before sending to JMAP #128 2026-03-31 16:15:56 +02:00
Linus Rath 0f7638055c fix: auto-focus input fields in email composer for improved user experience #126 2026-03-31 16:10:14 +02:00
Linus Rath 66fe7fd359 fix: enhance security by blocking plugins with dangerous JS patterns and enforcing strict session secret length 2026-03-31 15:56:32 +02:00
Linus Rath 1b2ee7da3a fix: prevent orphaning of nested mailboxes by restricting deduplication to root-level folders 2026-03-31 15:29:09 +02:00
Linus Rath f6bec519f4 fix: update file feature disabled messages and add stability warnings 2026-03-31 15:18:13 +02:00
Linus Rath 7102add194 fix: add self-signed certificate detection and update status messages for S/MIME signatures 2026-03-31 15:13:48 +02:00
Linus Rath a3d894730b fix: hardened security, CSP enforcement, SSRF redirect validation, reenabled S/MIME chain verify, IP spoofing prevention, PDF iframe sandbox 2026-03-31 15:11:38 +02:00
Linus Rath 79b99ed4f8 Merge branch 'dev' 2026-03-31 00:53:10 +02:00
Linus Rath 68214c3e91 chore: update version to 1.4.10 2026-03-31 00:51:36 +02:00
Linus Rath 5da0e2bdf1 fix: patch critical auth bypass and credential leak vulnerabilities 2026-03-31 00:46:15 +02:00
Linus Rath 92ada0460a Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-03-31 00:27:45 +02:00
Linus Rath 1cce5c3c8a fix: JSCalendar 2.0 recurrenceRule single-object compatibility 116 2026-03-31 00:11:04 +02:00
Linus RathandGitHub 40fd7799e9 Update security vulnerability reporting contact 2026-03-30 23:47:01 +02:00
Linus Rath aaa283357e feat: implemented plugin configuration UI and calendar event action slot
- Add configSchema support to plugin manifest and ServerPlugin registry
- Add schema-driven admin config page (string, secret, boolean, number, select fields)
- Add per-plugin config storage backend (JSON files + REST API)
- Add calendar-event-actions and admin-plugin-page slot names to plugin store
- Add registerCalendarEventAction and registerAdminPage to plugin API
- Add calendarFormHooks (onCalendarEventFormOpen/Save) to hook bus
- Add PluginSlot in calendar event modal for plugin action buttons
- Style calendar event action buttons to match Bulwark outline button design
- Add Configure link per plugin in admin plugins dashboard
- Add Jitsi Meet plugin with tests (repos/plugins/jitsi-meet)
- Exclude data/admin/plugins from ESLint (deployed plugin bundles)
2026-03-30 21:12:05 +02:00
Linus Rath 066ab1bc32 debug: add diagnostic logging to 3DES S/MIME decrypt path
Log legacy key import success/failure, CMS algorithm OIDs (content
encryption + key transport), legacy key availability during decrypt
attempts, and detailed error messages from both RSA-OAEP and
RSAES-PKCS1-v1_5 decrypt paths.
2026-03-30 19:15:02 +02:00
Linus Rath 92fb9bf132 fix: enhance calendar event handling to distinguish between events and tasks 2026-03-30 18:04:49 +02:00
Linus Rath 4c804d5d2b fix: align tag counts with mailbox folder counts in sidebar 2026-03-30 17:31:26 +02:00
Linus Rath 63b464641b fix: link existing events to target calendar during iCal import instead of skipping #113 2026-03-30 16:47:08 +02:00
Linus Rath 67210c9924 fix: handle 2FA/TOTP session expiry with basic auth #117 2026-03-30 16:37:07 +02:00
Linus Rath 8937777bcd fix: support 3DES S/MIME decryption by importing legacy RSAES-PKCS1-v1_5 keys #35 2026-03-30 16:00:27 +02:00
Linus Rath 789d211a71 fix: enhance mailbox handling with logging 2026-03-30 15:46:54 +02:00
Linus Rath c512352f77 fix: add replyToAddresses to email composer 2026-03-30 15:27:50 +02:00
Linus Rath c859230862 feat: add virtual location input #121 2026-03-30 15:19:47 +02:00
Linus Rath 0fb8b81acd fix: deduplicate UIDs during iCal import to prevent mass failures #113 2026-03-30 15:06:28 +02:00
Linus Rath 1eebec292a fix: enhance email loading and deduplication logic in email store #119 2026-03-30 08:32:46 +02:00
Linus Rath 2d983c9853 fix: improve mailbox tree logic #118 2026-03-29 23:47:52 +02:00
Linus Rath a453f7fa67 fix: events disappearing after iCal import/subscription refresh 2026-03-29 18:33:25 +02:00
Linus Rath 4c853f176b feat: add iCal subscription editing and batch event import 2026-03-29 18:27:16 +02:00
Linus Rath 5583d95ecc feat: preserve activation state when updating or creating Sieve scripts to avoid deactivating server-managed vacation scripts 2026-03-29 17:25:15 +02:00
Linus Rath 9d1302ef0a feat: skip server-managed vacation script in Sieve script handling 2026-03-29 17:18:33 +02:00
Linus Rath 75b5d31414 feat: implement client-side recurrence expansion for calendar events 2026-03-29 17:12:30 +02:00
Linus Rath 1e90885f3f i18n: add missing translation keys to all non-English locales 2026-03-29 14:20:41 +02:00
Linus Rath 02577c7502 feat: add calendar hover preview settings and functionality 2026-03-29 14:10:28 +02:00
Linus Rath 375c0a7339 feat: add JMAP server endpoint labels and hints in multiple languages 2026-03-29 00:52:42 +01:00
Linus Rath 99d5276cfa feat: add support for custom JMAP server endpoints in login and settings 2026-03-29 00:47:35 +01:00
Linus Rath 60b2cf5911 fix: adjust account list spacing and remove push connection indicator 2026-03-28 23:33:44 +01:00
Linus Rath 8d7cd26fa9 feat: enhance compose functionality with button integration and translations 2026-03-28 22:53:10 +01:00
Linus Rath 1ba8e2fd47 feat: add folder expansion state management and settings navigation 2026-03-28 22:32:01 +01:00
Linus Rath c7c22bd210 fix: account isolation, auto-import signer certs, and no-key error handling #35 2026-03-28 17:32:06 +01:00
Linus Rath 6e0c79ca2c i18n: add 17 missing translation keys to all non-English locales 2026-03-28 15:35:24 +01:00
Linus Rath 9140110435 feat: add options to hide account switcher and show account avatars on navigation rail 2026-03-28 15:20:25 +01:00
Linus Rath e49f7c1a75 fix: improve dark mode handling for media elements and background images 2026-03-28 14:57:04 +01:00
Linus Rath 098127148e fix(tasks): work around Stalwart not returning Task objects via CalendarEvent/query
Stalwart's CalendarEvent/query ignores Task-type objects and does not
support the 'types' filter (returns unsupportedFilter error).  This
caused tasks  both locally created and from external clients like
Thunderbird  to disappear on reload.

Root causes:
- CalendarEvent/query only returns @type:'Event' objects on Stalwart,
  so tasks were invisible to the query endpoint.
- CALENDAR_EVENT_PROPERTIES lacked Task-specific fields (due, progress,
  progressUpdated, priority), causing garbled data when tasks were
  fetched with Event properties (e.g. utcStart:'32548-12-04T15:30:07Z').

Changes:
- Add CALENDAR_TASK_PROPERTIES with Task-specific fields (due, progress,
  progressUpdated, priority).
- Rewrite getCalendarTasks() to first try CalendarEvent/query with
  types:['Task'] filter, then fall back to CalendarEvent/get ids:null
  which returns all calendar objects regardless of @type per JMAP spec.
- Rewrite createCalendarTask() to fetch back created tasks using
  CALENDAR_TASK_PROPERTIES instead of piggybacking on createCalendarEvent.
- Add comprehensive debug logging throughout the task fetch/create flow
  (TaskStore, JMAP client) visible when Debug Mode is enabled.
- Add 'types' field to CalendarEventFilter interface.
2026-03-28 14:17:54 +01:00
Linus Rath 308adf0101 feat: add mail layout settings and update email list components 2026-03-28 01:40:41 +01:00
Linus Rath 574bd7ecef fix: ensure draft editing function is called correctly in EmailViewer component #60 2026-03-28 00:52:28 +01:00
Linus Rath d6b0714b4e fix: match hover action background to selected row state 2026-03-28 00:42:42 +01:00
Linus Rath af8ea8349e fix: use UTC timestamps for timed event rendering 2026-03-28 00:33:38 +01:00
Linus Rath 8eeabfc995 Fix: nested button in theme card 2026-03-28 00:17:15 +01:00
Linus Rath ddb636ed73 feat: add auto-select reply identity feature with settings and localization 2026-03-27 23:58:42 +01:00
Linus Rath 92ff5fe449 feat: enhance calendar event handling with full-day detection and layout adjustments 2026-03-27 22:04:57 +01:00
Linus Rath edcd5aa2a7 chore: update version to 1.4.9 2026-03-27 19:46:48 +01:00
Linus Rath b5d471e9c8 feat: fix: enforce admin theme locks and repair theme ZIP bundles 2026-03-27 19:42:30 +01:00
Linus Rath f084b484b7 fix: create all-day calendar events with JSCalendar midnight start and full fetch verification 2026-03-27 18:51:34 +01:00
Linus Rath b868ad591d feat: add calendar event normalization and sanitization functions with tests 2026-03-27 18:46:20 +01:00
Linus Rath 42734f16c3 feat: implement vacation responder functionality with UI integration and localization 2026-03-27 18:32:02 +01:00
Linus Rath b70c727bae feat: enhance vacation syncing in filter store with error handling and script preservation 2026-03-27 18:18:41 +01:00
Linus Rath 9b0598b051 fix: ensure unique ID generation for ICalSubscription in useCalendarStore 2026-03-27 18:04:48 +01:00
Linus Rath 66cdfe64ce fix: prevent infinite re-render loop on calendar page 2026-03-27 17:40:28 +01:00
Linus Rath f6536573ae feat: add vacation support to Sieve script generation and parsing 2026-03-27 17:36:30 +01:00
Linus Rath 415c961937 feat: enforce forced/managed plugins and policy. Split user upload permission 2026-03-27 16:33:17 +01:00
Linus Rath ba6b25f3f1 fix: respect markAsReadDelay setting in EmailViewer #95 2026-03-27 02:09:00 +01:00
Linus Rath a9002763e2 feat: show admin panel in sidebar for Stalwart admin users 2026-03-27 01:53:16 +01:00
Linus Rath b75d064f20 feat: add plain text only setting for email composer #105 2026-03-27 00:38:11 +01:00
Linus Rath a46fb9c8af feat: implement rate limiting handling in JMAPClient #104 2026-03-27 00:22:40 +01:00
Linus Rath 6696636df8 feat: implement Stalwart admin authentication and role check across admin routes 2026-03-26 20:21:12 +01:00
Linus Rath 37bc88dbad feat: implement force enable/disable functionality for plugins and themes 2026-03-26 18:59:30 +01:00
Linus Rath 7a191cf78b fix: resolve all 69 ESLint warnings across 40 files
- Remove unused imports (Mail, CalendarIcon, Plus, Circle, cn, Link,
  MessageCircle, User, Tag, FolderUp, useEffect, LogOut, GripVertical,
  Check, X, HoverActionsMode, HoverActionsCorner, asn1js, Convert, etc.)
- Prefix unused variables/params with underscore to satisfy no-unused-vars
- Add missing React hook dependencies (exhaustive-deps)
- Wrap handleNavigateUp in useCallback and selectedGroupMembers in useMemo
- Remove unused eslint-disable directives in jmap/client.ts
- Replace as any with typed casts in filter-store and smime-store tests
- Remove dead code (macOk assignment, unused now variable)
2026-03-26 18:35:56 +01:00
Linus Rath 733e99f094 feat: replace Date constructor with parseISO for improved date handling across calendar components #25 2026-03-26 17:42:44 +01:00
Linus Rath 9b25b6d03e fix: adjust contact and calendar page for mobile layout #103 2026-03-26 16:30:20 +01:00
Linus Rath 05f848ee23 feat: add hover actions display mode and corner selection options in settings #58 2026-03-26 13:04:34 +01:00
Linus Rath 4da1b7d8fc feat: update file upload size handling to use dynamic max size #96 2026-03-26 13:04:34 +01:00
Linus Rath 4ff2bff974 feat: enhance error handling for network-related issues in JMAPClient and auth-store #100 2026-03-26 13:04:34 +01:00
Linus Rath 47918aab1b feat: add option to disable conversation threading #97 2026-03-26 13:04:34 +01:00
Linus Rath e48672cf77 feat: implement drag-and-drop functionality for sidebar apps #101 2026-03-26 13:04:34 +01:00
Norbert KamańczykandLinus Rath 763abf43b9 feat: OAuth app passwords support and Stalwart probe fix
- Fix Stalwart probe and API routes for OAuth by passing Bearer token
  and JMAP headers from the client-side auth store
- Show only App Passwords and Email Client Setup sections for OAuth users,
  hiding Password Change, Display Name, TOTP, and Encryption
- Skip principal/crypto API fetches for OAuth to avoid 403 errors
- Add Email Client Setup section with copyable JMAP username

Made-with: Cursor
2026-03-25 23:33:03 +01:00
Linus RathandGitHub 649261c386 Delete COMMIT_MSG.txt 2026-03-25 23:13:25 +01:00
Linus Rath e02371d2bf Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-03-25 23:11:41 +01:00
Linus Rath 4af20b8dc0 refactor: update styling and color classes across components
- Changed error message styling in ICalImportModal to use new color classes.
- Updated task completion styling in TaskListView to use new success color classes.
- Modified selected styling in ContactListItem tests to reflect new background class.
- Adjusted duplicate warning styling in ContactImportDialog to use new warning color classes.
- Refactored background and border colors in CalendarInvitationBanner for various statuses.
- Updated email list item and viewer components to use new warning and success color classes.
- Refined styling for unread indicators in email components.
- Enhanced error fallback styling in error components to use new warning color classes.
- Updated filter rule modal button hover styles to use new destructive color classes.
- Added experimental feature descriptions in Plugins and Themes settings.
- Refined vacation settings validation warning styling to use new warning color classes.
- Updated toast component styles to use new color classes for different states.
- Introduced a new built-in theme 'Qui' with specific color variables.
- Adjusted email security status colors to use new warning color classes.
2026-03-25 23:11:30 +01:00
Linus Rath b5172802bb refactor: update styling and color classes across components
- Changed error message styling in ICalImportModal to use new color classes.
- Updated task completion styling in TaskListView to use new success color classes.
- Modified selected styling in ContactListItem tests to reflect new background class.
- Adjusted duplicate warning styling in ContactImportDialog to use new warning color classes.
- Refactored background and border colors in CalendarInvitationBanner for various statuses.
- Updated email list item and viewer components to use new warning and success color classes.
- Refined styling for unread indicators in email components.
- Enhanced error fallback styling in error components to use new warning color classes.
- Updated filter rule modal button hover styles to use new destructive color classes.
- Added experimental feature descriptions in Plugins and Themes settings.
- Refined vacation settings validation warning styling to use new warning color classes.
- Updated toast component styles to use new color classes for different states.
- Introduced a new built-in theme 'Qui' with specific color variables.
- Adjusted email security status colors to use new warning color classes.
2026-03-25 23:10:47 +01:00
Linus Rath f64306be5e feat: integrate policy checks for feature enablement in calendar, folder, and plugins settings 2026-03-25 11:06:35 +01:00
Linus Rath 05f6d61cea feat: reorganize admin layout and enhance dashboard with settings sections 2026-03-25 10:58:05 +01:00
Linus Rath 0174051f3e fix: enhance draft editing by fetching full email content when necessary #60 2026-03-25 10:36:47 +01:00
Linus Rath 136686f230 feat(settings): enhance error handling for settings operations and improve logging 2026-03-25 10:21:05 +01:00
Linus Rath 54981950b0 feat(plugins): resizable collapsible detail sidebar, auto-init on refresh
- Add email-detail-sidebar slot type for right-side plugin panels
- Plugin sidebar inside EmailViewer with ResizeHandle (200-500px, drag/dbl-click reset)
- Collapse/expand toggle with PanelRightClose/PanelRightOpen icons
- Call initializePlugins() on mount so plugins survive page refresh
- Fix plugin loader: call exposePluginExternals() before loading
- Fix CSP: add blob: to script-src for plugin bundle loading
- Fix enablePlugin: wire slot registration bridge before loading
- Fix email-banner PluginSlot: pass email via extraProps
- Fix sidebar-widget PluginSlot: move inside scrollable area
- Emit emailHooks.onEmailOpen/onEmailClose from selectEmail()
2026-03-25 00:44:05 +01:00
Linus Rath 29a222eef4 feat: add plugin/theme disable gates and move policy controls to their admin pages
- Add `pluginsEnabled` and `themesEnabled` master feature gates to FeatureGates
- Move theme policy UI (default theme, built-in/admin theme toggles, user uploads toggle) from policy page to themes admin page
- Add plugin policy UI (plugins enabled toggle) to plugins admin page
- Remove theme policy section and plugin/theme gates from policy page (with note directing to respective pages)
- Hide Themes and Plugins settings tabs when their feature gate is disabled
- Fix dark mode visibility of all admin toggle switches (bg-white → bg-background, increase off-state track opacity)
2026-03-25 00:44:04 +01:00
Linus Rath 76b21147e4 feat: add plugin/theme harness and admin dashboard
Plugin & Theme System:
- Add plugin type definitions, permissions (30+), and validation constants
- Add IndexedDB storage layer for plugin code, theme CSS, and previews
- Add theme CSS sanitization, injection, and safety validation
- Add HookBus event system with 130+ hooks across 20 domains
- Add plugin ZIP extraction and manifest validation with JS security checks
- Add sandboxed PluginAPI factory with scoped storage, logging, and permission gating
- Add plugin loader with blob URL dynamic import and auto-disable circuit breaker
- Add 3 built-in themes (Nord, Catppuccin, Solarized)
- Add Zustand plugin store with install/uninstall/enable/disable lifecycle
- Add PluginSlot, PluginSlotRenderer, and PluginErrorBoundary components
- Add plugins and themes settings UI panels
- Integrate plugin slots into email viewer, composer, navigation rail, sidebar, and context menu
- Extend theme store with custom theme installation and activation

Admin Dashboard:
- Add admin authentication with scrypt password hashing and AES-256-GCM sessions
- Add rate-limited login (5 attempts/15min per IP)
- Add config manager with admin override > env var > default priority
- Add settings policy system with feature gates and per-setting restrictions
- Add audit logging with rotation
- Add admin API routes (login, logout, config, policy, audit, password change)
- Add admin UI pages (login, dashboard, config, policy, audit)
- Add policy store for client-side feature gate enforcement
- Wire admin password initialization into server instrumentation

Tests:
- Add 139 tests across 10 test files covering all plugin/theme modules
2026-03-25 00:44:03 +01:00
chrilep 78bcf8db1b added missing language strings for added tasks feature 2026-03-24 15:48:17 +01:00
chrilep 39c43e3b3d Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-03-24 15:14:11 +01:00
chrilep 55ee99e811 adding german translation for tasks feature 2026-03-24 15:14:09 +01:00
Linus Rath 31e7ecceda Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-03-24 14:23:58 +01:00
Linus Rath d666bddbc4 fix: improve error handling for user settings retrieval in GET and settings store 2026-03-24 14:20:10 +01:00
chrilep 1cac71fae7 added russian language #86 2026-03-24 12:53:03 +01:00
chrilep 79ff95565d added russian language #86 2026-03-24 12:49:57 +01:00
chrilep 808328f5a4 added russian language issue #86 2026-03-24 11:35:53 +01:00
Linus RathandGitHub ddbeadd3c8 Merge pull request #93 from jeremiah/main
Update elliptic dependency from git to version 6.6.1
2026-03-23 21:50:45 +01:00
Jeremiah C. FosterandGitHub 501d69500d Update elliptic dependency from git to version 6.6.1
Kept getting  TypeError: Invalid comparator: with the git+URL.
2026-03-23 21:11:30 +01:00
Linus Rath e07365b066 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-03-23 18:05:04 +01:00
Linus Rath 2baf5e84a5 fix: enhance mailbox handling with path mapping and add tests for sieve generator #91 2026-03-23 18:04:07 +01:00
chrilep 024487bf10 chore: update version to 1.4.8 and enhance README with new features and demo mode 2026-03-23 17:43:57 +01:00
Linus Rath 97bc26a332 Merge dev into main - version 1.4.8 2026-03-23 17:08:19 +01:00
Linus Rath 55c8d430ca chore: update version to 1.4.8 2026-03-23 16:59:24 +01:00
Linus Rath ef45140d32 fix: enhance account settings with username and authentication method display #90 2026-03-23 16:37:56 +01:00
Linus Rath 0effb97691 refactor: fix bugs in calendar logic across duration parsing, RFC compliance, and event handling
- Fix buildDuration() trailing "T" producing invalid ISO 8601 durations
- Fix DURATION_RE missing week (W) support in alerts and invitation parsing
- Fix computeFireTime() end fallback when utcEnd is missing
- Fix recurrenceOverrides patch escaping per RFC 6901 (updateEvent/rsvpEvent)
- Fix layoutOverlappingEvents endMin overflow past 1440
- Fix addDurationToDate() to support weeks and use UTC methods for UTC inputs
- Fix getEffectiveAlerts() null guard on calendarIds
- Fix buildAllDayDuration() DST-safe day calculation using differenceInCalendarDays
- Fix participant matching to check calendarAddress and sendTo (not just email)
- Fix buildParticipantMap() using crypto.randomUUID() instead of hardcoded IDs
- Fix overnight preview negative endMin in week view
- Fix sendImipInvitation() to emit DURATION when utcEnd is absent
- Fix sendImipCancellation() to validate status before sending
- Fix createEvent() to remap all calendarIds for shared calendars
- Fix getCalendarTasks() to clone before mutating @type
- Fix importEvents() error matching to include 'duplicate' and 'conflict'
- Fix looksLikeReply() false positive by requiring organizer + responded attendee
- Fix alert offset regex to require T before minutes
- Fix handleDuplicate() to generate new UID
- Fix formatSnapTime() input clamping
- Replace console.log/error with debug.log/error/warn in iMIP functions
2026-03-23 16:22:15 +01:00
Linus Rath f7ee204262 feat: add support for marking emails as answered or forwarded and update UI accordingly 2026-03-23 15:45:37 +01:00
Linus Rath 0c1f182b6b fix: detect tasks created by external CalDAV clients (Thunderbird)
Tasks created in Thunderbird via CalDAV were not visible because
getCalendarTasks() used a strict @type === 'Task' check. Stalwart
may not set @type when converting VTODO from CalDAV to JMAP.

- Use case-insensitive @type matching for server variations
- Add fallback heuristic: detect tasks by presence of 'progress'
  property (exclusive to JSCalendar Task, never on Event objects)
- Normalize @type to 'Task' on detected tasks for consistent
  downstream handling
- Refresh task store on CalendarEvent state changes so tasks
  created externally appear without manual page refresh

Fixes #84
2026-03-24 14:54:37 +01:00
Linus Rath 13010c158d feat: implement path prefix handling for OAuth and login redirects 2026-03-24 14:44:42 +01:00
Linus Rath de26e6da2e feat: enhance identity selection by supporting sub-addressing in email options 2026-03-24 14:33:50 +01:00
Linus Rath ddb3422852 feat: add default mail program settings with localization support 2026-03-23 23:07:19 +01:00
Linus RathandGitHub d9a2529261 Update badge links in README.md 2026-03-22 17:25:29 +01:00
Linus RathandGitHub e70224317d Update Discord badge link in README.md 2026-03-22 17:16:10 +01:00
Linus RathandGitHub a9ecf164ab Add Discord badge to README 2026-03-22 17:15:47 +01:00
Linus RathandGitHub 0db3cbc959 Merge pull request #82 from harrytang/feature/new-email-per-page-option
feat: add 10 as an additional Emails Per Page option
2026-03-22 12:39:15 +01:00
Harry Tang 387273288c feat: add 10 as an additional Emails Per Page option 2026-03-22 13:15:23 +02:00
Linus Rath 8eff9fdfab feat: add notification settings with sound picker and preview 2026-03-22 01:55:21 +01:00
Linus Rath d915e5fb64 feat: add all multi-part TLDs for domain validation #81 2026-03-21 21:33:55 +01:00
Linus Rath 5530cfe7fe Merge dev into main - version 1.4.7 2026-03-21 20:46:33 +01:00
Linus Rath 31eee4bab9 chore: bump version to 1.4.7 2026-03-21 20:45:57 +01:00
Linus Rath f04b97d52a fix: handle updates and deletions for synthetic JMAP IDs in calendar events 2026-03-21 20:45:23 +01:00
Linus Rath df272e38ef feat: add resizable image component and rich text editor with image upload support 2026-03-21 20:45:23 +01:00
Linus Rath a835af2d71 fix: update .env.example to clarify Docker volume mounting for settings data directory 2026-03-21 20:45:22 +01:00
Linus Rath 30284859c7 feat: add task management features to calendar 2026-03-21 20:45:22 +01:00
Linus Rath 64c3d7e384 fix: update getContacts test to use mockFetchOnce 2026-03-21 20:45:20 +01:00
Linus Rath e6d09546b1 fix: extend CryptoEngine to support legacy algorithms and integrate with LinerEngine for decryption 2026-03-21 20:45:20 +01:00
Linus Rath 83a0a1e235 feat: add non-interactive SSO login flow for embedded/iframe deployments (closes #69) 2026-03-21 20:45:19 +01:00
Linus Rath 7c3c3b5f7b fix: handle synthetic ID errors when updating calendar events with fallback to destroy and recreate 2026-03-21 20:45:18 +01:00
Linus Rath 8b1b3ad57b fix: update iframe sandbox attributes to allow popups to escape sandbox 2026-03-21 20:45:18 +01:00
Linus Rath afefbb8d46 feat: add expanded visual view for filter rules
- Add VisualRuleSummary component showing conditions and actions as
  labeled inline pills with IF/THEN flow layout
- Add expandedFilterView toggle to settings store (persisted)
- Fix RuleSummary to allow multi-line wrapping instead of truncating
- Use items-start on rule cards so drag handle and toggle align to top
- Add translations for expanded view keys in all 8 locales
2026-03-21 20:45:17 +01:00
Linus Rath 4c2d185be4 fix: refactor logout to use synchronous flow with full page redirect
- Rewrite logout() from async to synchronous to prevent React re-renders with stale state
- Replace router.push('/login') with redirectToLogin() (window.location.replace) in all page auth guards for reliable navigation in Edge/Safari
- Add performFullLogout() helper that clears auth state, feature stores, and localStorage
- Fix persist middleware partialize to return {} when not authenticated, preventing state resurrection
- Use keepalive fetch for background cookie/token cleanup so redirect fires immediately
- Remove unused useRouter imports from page.tsx and contacts/page.tsx
- Simplify all page logout handlers to directly call logout()

Fixes #63
2026-03-21 20:45:17 +01:00
Linus Rath c73940e22a feat: add option to show week numbers in mini-calendar 2026-03-21 20:45:16 +01:00
Linus Rath 09eda86d3f fix: add missing translation keys across all locales 2026-03-21 20:45:15 +01:00
Linus Rath 0d28d811a8 feat: support uploading folders via drag-and-drop and toolbar button 2026-03-21 20:45:14 +01:00
Linus RathandGitHub 45a485a1fa Merge pull request #71 from xnilsit/workflow-on-release
feat: re-add release workflow
2026-03-21 11:20:58 +01:00
xnilsit bc202498d5 feat: re-add release workflow 2026-03-21 11:06:14 +01:00
Linus RathandGitHub 721556e777 Update version badge to 1.4.6 2026-03-21 03:29:43 +01:00
Linus RathandGitHub 8c9cf3a66b Bump app version from 1.4.3 to 1.4.6 2026-03-21 03:29:17 +01:00
Linus Rath b88026de82 Merge dev into main - version 1.4.6 2026-03-21 03:08:47 +01:00
Linus Rath 6ed8ae5812 chore: bump version to 1.4.6 2026-03-21 03:08:19 +01:00
Linus Rath 089583a9ef feat(contacts): add no-category filter, drag-drop to category, and category combo box
- Add 'No Category' sidebar item to filter uncategorized contacts
- Categories section now always visible (not just when keywords exist)
- Add drag-and-drop support on category items in sidebar to assign keywords
- Fix effectAllowed mismatch (move -> copyMove) for category drop targets
- Replace plain text categories input with combo box in contact edit form
  - Shows existing categories as clickable suggestions
  - Displays assigned categories as removable badges
  - Supports adding new categories inline
- Add translations for all 8 locales
2026-03-21 03:04:36 +01:00
Linus Rath 2d834213ee feat: enhance certificate extraction and legacy PBE support in crypto engine 2026-03-21 02:48:12 +01:00
Linus Rath 439a4dbe8a feat: add hover actions for emails and update settings for quick actions 2026-03-21 02:31:42 +01:00
Linus Rath 8350bad2a6 fix: adjust padding and size of sidebar buttons for improved layout 2026-03-21 02:06:52 +01:00
Linus Rath 2d56cc9be9 feat: implement keyword migration functionality and update related components 2026-03-21 01:58:48 +01:00
Linus Rath 2547c10060 feat: add demo data for emails, files, filters, identities, mailboxes, vacation responses, and JMAP client interface
- Created demo emails with various states (inbox, sent, drafts, trash, etc.) in `emails.ts`.
- Added demo file nodes representing directories and files in `files.ts`.
- Implemented demo Sieve capabilities and scripts in `filters.ts`.
- Defined demo identities for users in `identities.ts`.
- Established demo mailboxes with permissions and counts in `mailboxes.ts`.
- Created a demo vacation response in `vacation.ts`.
- Introduced a comprehensive JMAP client interface in `client-interface.ts` to standardize interactions with the JMAP API.
2026-03-21 01:38:42 +01:00
Linus RathandGitHub 01779fa59e Update SHA tag prefix in docker-publish.yml
Fix the tag prefix format for SHA in Docker publish workflow.
2026-03-21 00:15:01 +01:00
Linus RathandGitHub fc38427ed0 Merge pull request #68 from xnilsit/main
feat: add separate docker build for releases
2026-03-21 00:08:49 +01:00
xnilsit 3cbfb70860 feat: also build images for dev branch 2026-03-21 00:08:32 +01:00
xnilsit c32b740dac chore: rename release workflow 2026-03-20 23:11:41 +01:00
xnilsit a02091a7ad fix: don't tag releases with hash 2026-03-20 22:48:37 +01:00
xnilsit bc311adf6a feat: add separate docker build for releases 2026-03-20 22:35:37 +01:00
Linus Rath 8aca1623f4 Merge dev into main - version 1.4.5 (build fix) 2026-03-20 18:17:53 +01:00
Linus Rath a8be40579e fix: add missing showTimeInMonthView and showOnMobile type definitions to settings store 2026-03-20 18:17:31 +01:00
Linus Rath 705b942800 Merge dev into main - version 1.4.5 2026-03-20 18:10:08 +01:00
Linus Rath d68b81e6b8 chore: bump version to 1.4.5 2026-03-20 18:10:00 +01:00
Linus Rath 616e4d018d fix: expand recurring events in CalendarEvent/query (closes #65)
Add expandRecurrences: true to CalendarEvent/query when a date range
filter is provided, so the JMAP server returns individual occurrences
of recurring events instead of only the master event.
2026-03-20 17:53:30 +01:00
Linus Rath 68e141b787 feat: add mobile visibility toggle for sidebar apps and update related components 2026-03-20 17:44:26 +01:00
Linus Rath 1e6f5e2c8c fix: correct JSX syntax in CalendarToolbar component 2026-03-20 17:25:01 +01:00
Linus Rath 9495b34430 feat: add prev/next navigation buttons and date label to desktop calendar toolbar
Closes #59
2026-03-20 17:23:22 +01:00
Linus Rath 65fc489b9c feat: add pending event preview functionality to calendar views and event modal 2026-03-20 17:21:44 +01:00
Linus Rath bd686c092c fix: validate event start field when fetching calendar events 2026-03-20 17:11:36 +01:00
Linus Rath 6cff98ddb8 feat: implement pagination for fetching contacts and add maxObjectsInGet capability 2026-03-20 16:47:27 +01:00
Linus Rath dcc35335f5 feat: add setting to show event start time in month view 2026-03-20 16:42:07 +01:00
Linus Rath 8a54ae2456 feat: add NotFound component to handle 404 errors and redirect unauthenticated users 2026-03-20 16:31:57 +01:00
Linus Rath e26654a005 fix: enhance account switching logic and clear stores on account change 2026-03-20 16:29:32 +01:00
Linus Rath c1c06c68bb fix: improve draft handling in email composer and enhance session cookie verification logic 2026-03-20 15:45:25 +01:00
Linus Rath 74cf642182 Merge branch 'dev' 2026-03-20 00:16:41 +01:00
Linus Rath c5b1731a63 feat: add attachment position setting in email settings
- Introduced a new setting for attachment position in email settings, allowing users to choose between displaying attachments beside the sender or below the header.
- Updated the settings store to include the new attachment position type and default value.
- Added translations for the new setting in multiple languages (de, en, es, fr, it, ja, nl, pt).
2026-03-19 21:14:18 +01:00
Linus Rath 40cf164df3 fix: update calendar agenda view to auto-scroll to today's events and ensure today's date is included in the groups 2026-03-19 19:25:34 +01:00
Linus Rath ff56245db8 fix: improve account restoration logic and handle stale accounts in auth store 2026-03-19 19:21:08 +01:00
Linus Rath 9b4de4d152 fix: remove claude directory from .dockerignore and .gitignore 2026-03-19 19:06:31 +01:00
Linus Rath 0c9e60db8b fix: update flatted to 3.4.2 2026-03-19 19:01:40 +01:00
Linus Rath def8ee89fa Merge branch 'dev' 2026-03-19 18:57:57 +01:00
Linus Rath e7e07a38d7 fix: use native ARM runners instead of QEMU for Docker builds 2026-03-19 18:57:17 +01:00
Linus Rath a009e5ae32 fix: enhance health check functionality with detailed memory diagnostics and stable liveness probe 2026-03-19 18:50:59 +01:00
687 changed files with 158095 additions and 112953 deletions
-2
View File
@@ -5,9 +5,7 @@ node_modules
.env* .env*
!.env.example !.env.example
!.env.dev.example !.env.dev.example
.claude/
scripts/ scripts/
TODO.md TODO.md
CLAUDE.md
*.md *.md
!README.md !README.md
+8 -3
View File
@@ -1,11 +1,11 @@
# Bulwark Webmail Development Configuration # Bulwark Webmail - Development Configuration
# Copy this file to .env.local to run with the built-in mock JMAP server. # Copy this file to .env.local to run with the built-in mock JMAP server.
# No external mail server required great for UI development and testing. # No external mail server required - great for UI development and testing.
# #
# Usage: # Usage:
# cp .env.dev.example .env.local # cp .env.dev.example .env.local
# npm run dev # npm run dev
# Open http://localhost:3000 log in with any username/password. # Open http://localhost:3000 - log in with any username/password.
# ============================================================================= # =============================================================================
# Mock JMAP Server # Mock JMAP Server
@@ -47,3 +47,8 @@ LOG_LEVEL=debug
# LOGIN_IMPRINT_URL=https://example.com/imprint # LOGIN_IMPRINT_URL=https://example.com/imprint
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy # LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
# LOGIN_WEBSITE_URL=https://example.com # 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"}]
+176 -23
View File
@@ -1,4 +1,4 @@
# Bulwark Webmail Production Configuration # Bulwark Webmail - Production Configuration
# Copy this file to .env.local and fill in your values. # Copy this file to .env.local and fill in your values.
# For development with the built-in mock server, see .env.dev.example instead. # For development with the built-in mock server, see .env.dev.example instead.
@@ -6,12 +6,19 @@
# JMAP Server (required) # JMAP Server (required)
# ============================================================================= # =============================================================================
# App name displayed in the UI # App name displayed in the UI, browser tab title, and PWA manifest.
APP_NAME=Bulwark Webmail APP_NAME=Bulwark Webmail
# URL of your JMAP-compatible mail server (required) # URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
JMAP_SERVER_URL=https://your-jmap-server.com JMAP_SERVER_URL=https://your-jmap-server.com
# Allow users to specify a custom JMAP server URL on the login form.
# When enabled, a "JMAP Server" field appears on the login page.
# Users can connect to any JMAP-compatible server.
# NOTE: External JMAP servers must include this domain in their CORS
# Access-Control-Allow-Origin header, or browser requests will be blocked.
# ALLOW_CUSTOM_JMAP_ENDPOINT=true
# ============================================================================= # =============================================================================
# Stalwart Mail Server Integration # Stalwart Mail Server Integration
# ============================================================================= # =============================================================================
@@ -20,11 +27,6 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Set to "false" to disable if using a non-Stalwart JMAP server. # Set to "false" to disable if using a non-Stalwart JMAP server.
# STALWART_FEATURES=true # STALWART_FEATURES=true
# If your reverse proxy doesn't forward Stalwart management API paths
# (/api/account/*, /api/principal/*), set this to the URL where Stalwart's
# HTTP listener is directly reachable. Defaults to JMAP_SERVER_URL if not set.
# STALWART_API_URL=https://admin.example.com
# ============================================================================= # =============================================================================
# OAuth / OpenID Connect (optional) # OAuth / OpenID Connect (optional)
# ============================================================================= # =============================================================================
@@ -41,10 +43,22 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# OAuth client secret (server-side only, never exposed to the browser) # OAuth client secret (server-side only, never exposed to the browser)
# OAUTH_CLIENT_SECRET=your-client-secret # OAUTH_CLIENT_SECRET=your-client-secret
# Alternatively, you can specify the path to a file containing the OAuth client secret.
# OAUTH_CLIENT_SECRET_FILE=/oauth-client-secret
# OpenID Connect issuer URL for discovery # OpenID Connect issuer URL for discovery
# OAUTH_ISSUER_URL=https://your-idp.example.com # OAUTH_ISSUER_URL=https://your-idp.example.com
# Overrides only the user-facing authorize endpoint (e.g. a per-brand login
# host). Discovery, token exchange and refresh keep using OAUTH_ISSUER_URL.
# Leave unset to use the authorization_endpoint from discovery.
# OAUTH_AUTHORIZE_URL=https://login.your-brand.example.com/application/o/authorize/
# Allow OAuth discovery to resolve to private (RFC-1918 / loopback) addresses.
# Off by default as an SSRF guard. Enable for split-DNS deployments where the
# OAuth issuer's public hostname resolves to an internal IP from this server.
# OAUTH_ALLOW_PRIVATE_ENDPOINTS=true
# ============================================================================= # =============================================================================
# Session & Security # Session & Security
# ============================================================================= # =============================================================================
@@ -53,6 +67,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Required for both "Remember me" and settings sync features. # Required for both "Remember me" and settings sync features.
# Generate with: openssl rand -base64 32 # Generate with: openssl rand -base64 32
# SESSION_SECRET=your-secret-key-here # SESSION_SECRET=your-secret-key-here
# Alternatively, you can specify the path to a file containing the session secret.
# SESSION_SECRET_FILE=/session-secret
# ============================================================================= # =============================================================================
# Settings Sync # Settings Sync
@@ -64,9 +80,58 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# SETTINGS_SYNC_ENABLED=true # SETTINGS_SYNC_ENABLED=true
# Directory for storing encrypted settings files (default: ./data/settings). # Directory for storing encrypted settings files (default: ./data/settings).
# For Docker, mount a persistent volume at this path. # For Docker, the working directory is /app, so the default resolves to
# /app/data/settings - mount a persistent volume there (see docker-compose.yml).
# SETTINGS_DATA_DIR=./data/settings # SETTINGS_DATA_DIR=./data/settings
# =============================================================================
# Admin Dashboard Data
# =============================================================================
# Admin data is split across two directories so the config volume can be
# mounted read-only after the setup wizard completes (see issue #226).
#
# Config dir - operator-authored state. Holds config.json, policy.json,
# admin.json (passwordHash only), plugin-config/, plugins/, themes/, and
# branding uploads. Safe to mount read-only after setup.
# Default: ./data/admin (or ADMIN_DATA_DIR if that legacy variable is set)
# ADMIN_CONFIG_DIR=./data/admin
#
# State dir - runtime mutations. Holds admin-state.json (login timestamps),
# audit.log, and the bootstrap setup token. Always read-write.
# Default: ./data/admin-state (or ADMIN_DATA_DIR/state when ADMIN_DATA_DIR
# is set, for back-compat with single-volume installs)
# ADMIN_STATE_DIR=./data/admin-state
#
# Set to "true" to enforce read-only mode at the application layer (cleaner
# error than a mid-request EROFS). Pair with `:ro` on the config-volume mount.
# ADMIN_CONFIG_READONLY=true
#
# Legacy: a single dir containing both config and state. Honoured if neither
# of the split variables is set. New installs should use the split vars.
# ADMIN_DATA_DIR=./data/admin
# =============================================================================
# Anonymous Telemetry
# =============================================================================
# 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.
#
# 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
# /app/data/telemetry - mount a persistent volume there (see docker-compose.yml)
# so the instance id and consent choice survive upgrades.
# TELEMETRY_DATA_DIR=./data/telemetry
# ============================================================================= # =============================================================================
# Server Listen Address # Server Listen Address
# ============================================================================= # =============================================================================
@@ -92,43 +157,131 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Branding (all optional) # Branding (all optional)
# ============================================================================= # =============================================================================
# Custom favicon for the browser tab. # ---------------------------------------------------------------------------
# App identity
# ---------------------------------------------------------------------------
# Short name for the app, used in contexts where space is limited
# (e.g. home screen label on mobile). Defaults to APP_NAME if not set.
# APP_SHORT_NAME=Bulwark
# Description shown in the PWA manifest (displayed by the OS during install).
# Defaults to a generic Bulwark description if not set.
# APP_DESCRIPTION=Your personal webmail
# ---------------------------------------------------------------------------
# Icons & favicon
# ---------------------------------------------------------------------------
# Custom favicon shown in the browser tab.
# Supported formats: SVG (recommended), PNG, ICO. # Supported formats: SVG (recommended), PNG, ICO.
# Recommended size: 32×32px minimum, 512×512px maximum (or SVG for best scaling). # Can be an absolute URL (https://...) or a path relative to the public/ directory.
# Can be an absolute URL or a path relative to the public/ directory.
# Defaults to the Bulwark favicon if not set. # Defaults to the Bulwark favicon if not set.
# FAVICON_URL=/branding/my-favicon.svg # FAVICON_URL=/branding/my-favicon.svg
# Custom logos for the sidebar (shown in the main app after login). # Source image used to auto-generate PWA icons (192×192 and 512×512 PNG).
# Supported formats: SVG (recommended for best quality) or PNG (≥512×512px recommended).
# Can be an absolute URL (https://...) or a path relative to the public/ directory.
# Falls back to FAVICON_URL if not set, and to the default Bulwark icons if neither is set.
# PWA_ICON_URL=/branding/my-icon.svg
# ---------------------------------------------------------------------------
# PWA appearance
# ---------------------------------------------------------------------------
# Color applied to the browser UI chrome when the app is installed as a PWA
# (address bar, status bar on Android). Default: #ffffff
# PWA_THEME_COLOR=#3b82f6
# Background color shown on the PWA splash screen while the app is loading.
# Should match your app's main background color. Default: #ffffff
# PWA_BACKGROUND_COLOR=#ffffff
# ---------------------------------------------------------------------------
# Logos
# ---------------------------------------------------------------------------
# Logos shown in the sidebar (main app, after login).
# Supported formats: SVG (recommended), PNG, WebP. # Supported formats: SVG (recommended), PNG, WebP.
# Recommended size: min 24×24px, max 128×128px # Recommended size: min 24×24px, max 128×128px.
# Can be absolute URLs or paths relative to the public/ directory. # Can be absolute URLs or paths relative to the public/ directory.
# If not set, no logo is shown in the sidebar. # If not set, no logo is shown in the sidebar.
# APP_LOGO_LIGHT_URL=/branding/my-logo-color.svg # APP_LOGO_LIGHT_URL=/branding/my-logo-color.svg
# APP_LOGO_DARK_URL=/branding/my-logo-white.svg # APP_LOGO_DARK_URL=/branding/my-logo-white.svg
# Custom logo images for the login page. # Logos shown on the login page.
# Supported formats: SVG (recommended), PNG, WebP. # Supported formats: SVG (recommended), PNG, WebP.
# Recommended size: min 32×32px, max 512×512px # Recommended size: min 32×32px, max 512×512px.
# Can be absolute URLs or paths relative to the public/ directory. # Can be absolute URLs or paths relative to the public/ directory.
# Light mode logo (shown on light backgrounds), defaults to Bulwark logo. # Light mode logo (shown on light backgrounds). Defaults to the Bulwark logo.
LOGIN_LOGO_LIGHT_URL=/branding/Bulwark_Logo_Color.svg LOGIN_LOGO_LIGHT_URL=/branding/Bulwark_Logo_Color.svg
# # Dark mode logo (shown on dark backgrounds). Defaults to the Bulwark white logo.
# Dark mode logo (shown on dark backgrounds), defaults to Bulwark white logo.
LOGIN_LOGO_DARK_URL=/branding/Bulwark_Logo_Color.svg LOGIN_LOGO_DARK_URL=/branding/Bulwark_Logo_Color.svg
# Company or organization name displayed above the version on the login page # ---------------------------------------------------------------------------
# Login page
# ---------------------------------------------------------------------------
# Company name shown above the version number on the login page.
LOGIN_COMPANY_NAME=Bulwark Webmail LOGIN_COMPANY_NAME=Bulwark Webmail
# URL for the imprint/legal notice link on the login page # URL for the imprint / legal notice link on the login page.
# LOGIN_IMPRINT_URL=https://example.com/imprint # LOGIN_IMPRINT_URL=https://example.com/imprint
# URL for the privacy policy link on the login page # URL for the privacy policy link on the login page.
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy # LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
# URL for the company website link on the login page # URL for the company website link on the login page.
LOGIN_WEBSITE_URL=https://bulwarkmail.org 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
# =============================================================================
# URL of the BulwarkMail extension directory for the admin marketplace.
# Defaults to https://extensions.bulwarkmail.org. Override only if you run
# your own directory (e.g. http://localhost:3001 for local development).
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
# =============================================================================
# Internationalization
# =============================================================================
# These are build-time variables - to change them with the published Docker
# image, rebuild it with --build-arg (see README "Default UI locale").
#
# Fallback UI locale used when the visitor's Accept-Language header does not
# match any supported locale. Defaults to "en".
# Supported: cs, da, de, en, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh
# NEXT_PUBLIC_DEFAULT_LOCALE=tr
# Locale prefix mode for URLs. Recommended "always" when proxying under a
# subpath (NEXT_PUBLIC_BASE_PATH) to avoid next-intl rewrite loops.
# Values: never (default) | always | as-needed
# NEXT_PUBLIC_LOCALE_PREFIX=always
# ============================================================================= # =============================================================================
# Legacy Build-time Variables (still supported as fallback) # Legacy Build-time Variables (still supported as fallback)
# ============================================================================= # =============================================================================
+4
View File
@@ -0,0 +1,4 @@
# Bulwark Webmail Funding configuration
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
github: [bulwarkmail]
@@ -0,0 +1,120 @@
name: Publish Docker Image on Release
on:
release:
types: [published]
workflow_dispatch:
env:
IMAGE_NAME: ghcr.io/${{ github.repository }}
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_COMMIT=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest
type=semver,pattern=v{{version}}
type=semver,pattern={{version}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern=v{{major}}
type=semver,pattern={{major}}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
+94 -19
View File
@@ -2,7 +2,9 @@ name: Publish Docker Image
on: on:
push: push:
branches: [main] branches:
- main
- dev
paths: paths:
- "Dockerfile" - "Dockerfile"
- ".dockerignore" - ".dockerignore"
@@ -17,12 +19,34 @@ on:
- "package.json" - "package.json"
- "package-lock.json" - "package-lock.json"
- ".github/workflows/docker-publish.yml" - ".github/workflows/docker-publish.yml"
tags: ["v*.*.*"]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build-and-push: prepare:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
image_name: ${{ steps.set.outputs.image_name }}
steps:
- name: Set image name
id: set
run: |
if [ "${{ github.ref_name }}" = "main" ]; then
echo "image_name=ghcr.io/${{ github.repository }}-beta" >> $GITHUB_OUTPUT
else
echo "image_name=ghcr.io/${{ github.repository }}-${{ github.ref_name }}" >> $GITHUB_OUTPUT
fi
build:
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -31,9 +55,6 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -48,21 +69,75 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: | images: ${{ needs.prepare.outputs.image_name }}
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
- name: Build and push - name: Build and push by digest
id: build
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: ${{ matrix.platform }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha build-args: |
cache-to: type=gha,mode=max GIT_COMMIT=${{ github.sha }}
outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: [prepare, build]
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ needs.prepare.outputs.image_name }}
tags: |
type=raw,value=latest
type=sha
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ needs.prepare.outputs.image_name }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ needs.prepare.outputs.image_name }}:${{ steps.meta.outputs.version }}
+68
View File
@@ -0,0 +1,68 @@
name: Publish Standalone Tarball on Release
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
arch: amd64
- os: ubuntu-24.04-arm
arch: arm64
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build standalone
run: npm run build
- name: Package tarball
env:
REF_NAME: ${{ github.ref_name }}
ARCH: ${{ matrix.arch }}
run: |
VERSION="${REF_NAME#v}"
TARBALL="bulwark-standalone-${VERSION}-linux-${ARCH}.tar.gz"
mkdir -p bulwark-standalone
cp -r .next/standalone/. bulwark-standalone/
cp -r .next/static bulwark-standalone/.next/static
cp -r public bulwark-standalone/public
tar -czf "$TARBALL" bulwark-standalone/
echo "TARBALL=$TARBALL" >> "$GITHUB_ENV"
- name: Upload release asset
if: github.event_name == 'release'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG_NAME" "$TARBALL" --clobber
- name: Upload artifact (workflow_dispatch)
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v4
with:
name: standalone-${{ matrix.arch }}
path: ${{ env.TARBALL }}
retention-days: 7
+4 -4
View File
@@ -42,11 +42,11 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# claude code
.claude/
# settings sync data # settings sync data
/data/ /data/
# Local private data # Local private data
/local-data/ /local-data/
# Sibling repos
/repos/
-7
View File
@@ -1,7 +0,0 @@
# Check for AI attribution in commit message
if grep -qi "co-authored-by.*claude\|co-authored-by.*anthropic\|claude code\|claude sonnet\|claude opus" "$1"; then
echo "❌ ERROR: Commit message contains AI attribution (Claude/Anthropic)"
echo " This violates project policy in CLAUDE.md"
echo " Remove 'Co-Authored-By: Claude' and similar references"
exit 1
fi
+1016 -2
View File
File diff suppressed because it is too large Load Diff
+35 -32
View File
@@ -1,16 +1,30 @@
<div align="center"> <div align="center">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20White%20and%20Color.svg" /> <source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" /> <source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
<img src="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" alt="Bulwark Webmail" width="220" /> <img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
</picture> </picture>
</div> </div>
# Contributing to Bulwark Webmail # Contributing to Bulwark Webmail
Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors. We're writing the webmail we wanted in 2026 and didn't find. Modern protocol, modern tooling, modern UI. Not a SaaS. Not a startup. Not for sale.
If that resonates with you, we'd love your help. This guide covers how to get the project running, the conventions we follow, and how to land your first change.
## Join the Community
You don't need to be an expert to contribute. Whether you're setting up your dev environment for the first time, filing a bug, or translating a string, the Discord is the fastest way to get unstuck and meet the people working on this.
- **Get support** - real-time help with development hurdles
- **Share ideas** - feature suggestions, design feedback, doc improvements
- **Collaborate** - meet the team and other contributors
[**Join the Bulwark Discord Server**](https://discord.gg/tYCujymGrT)
---
## Getting Started ## Getting Started
@@ -83,37 +97,31 @@ These checks run automatically on commit via Husky pre-commit hooks.
## Internationalization (i18n) ## Internationalization (i18n)
This project uses **next-intl** for internationalization. Please follow these guidelines: This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 15 additional locales (cs, de, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh).
### Key Rules ### Rules
1. **Never hardcode user-facing text** - Always use translations: 1. **Never hardcode user-facing text** - always use translations:
```tsx ```tsx
const t = useTranslations("namespace"); const t = useTranslations("namespace");
return <div>{t("key")}</div>; return <div>{t("key")}</div>;
``` ```
2. **Translation file locations**: 2. **Add new keys to `en/common.json` first.** Other locales can follow in the same PR or a follow-up - missing keys fall back to English.
- English: `/locales/en/common.json`
- French: `/locales/fr/common.json`
3. **Namespace organization**: 3. **Namespace organization**:
- `login.*` - Login page strings - `login.*` - login page
- `sidebar.*` - Sidebar navigation - `sidebar.*` - sidebar navigation
- `email_list.*` - Email list component - `email_list.*` - email list
- `email_viewer.*` - Email viewer component - `email_viewer.*` - email viewer
- `email_composer.*` - Email composer - `email_composer.*` - composer
- `common.*` - Shared strings - `settings.*` - settings page
- `notifications.*` - Toast/alert messages - `notifications.*` - toasts and alerts
- `settings.*` - Settings page - `common.*` - shared strings
4. **Adding new strings**: 4. **Locale-aware navigation**:
- Add to **both** English and French translation files
- Use descriptive, hierarchical keys
- Keep translations consistent in tone
5. **Locale-aware navigation**:
```tsx ```tsx
router.push(`/${params.locale}/settings`); router.push(`/${params.locale}/settings`);
``` ```
@@ -192,16 +200,11 @@ webmail/
## Security ## Security
- **Never commit sensitive data** (API keys, passwords, etc.) - **Never commit secrets** - API keys, passwords, tokens, `.env*` files
- **Sanitize user input** and email content - **Sanitize user input** and email content
- **Block external content** by default for privacy - **Block external content** by default - privacy is the point
- Report security vulnerabilities privately - **Report vulnerabilities privately** to bulwark@rbm.systems, not via public issues
## Questions? ## Questions?
If you have questions about contributing, feel free to: Open an issue, search existing ones, or ask in Discord. Thanks for helping build the webmail we all wished existed.
- Open an issue for discussion
- Check existing issues and pull requests
Thank you for helping improve Bulwark Webmail!
+14
View File
@@ -4,6 +4,19 @@ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# Optional: serve under a subpath like /webmail. Baked into emitted asset URLs
# at build time, so it cannot be changed without rebuilding.
ARG NEXT_PUBLIC_BASE_PATH=
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
# Optional: fallback UI locale (e.g. tr, de, fr) used when the visitor's
# Accept-Language header does not match any supported locale. Baked in at
# build time because next-intl wires it into client-side routing too.
ARG NEXT_PUBLIC_DEFAULT_LOCALE=
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
# Commit SHA shown in the About screen. .dockerignore excludes .git, so
# `git rev-parse` inside the build can't find it - CI must pass it in.
ARG GIT_COMMIT=unknown
ENV GIT_COMMIT=$GIT_COMMIT
RUN npx next build --webpack RUN npx next build --webpack
FROM node:24-alpine AS runner FROM node:24-alpine AS runner
@@ -26,6 +39,7 @@ RUN apk upgrade --no-cache && \
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN mkdir -p /app/data/settings /app/data/admin /app/data/admin-state /app/data/telemetry && chown -R nextjs:nodejs /app/data
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
+146
View File
@@ -0,0 +1,146 @@
# Features
## Mail
- 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 combined Inbox, Sent, Drafts, Junk, Archive, and Trash, with group/shared accounts optionally merged in
- Cross-account "All accounts" views All unread, All starred, and All mail spanning every account (including shared/group folders); each aggregate list labels the source folder of every message
- "All Mail" view that merges an account's folders (with a configurable folder selection) into a single list
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
- Attachment upload, download, drag-out to local file system, and inline preview images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning
- 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
- Multi-tag support with color labels, reordering, and drag-and-drop assignment
- Star/unstar with configurable mark-as-read delay
- Virtual scrolling for large mailboxes plus prefetching of initial email data on login
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
- Plain-text composer mode and Reply-To support
- Configurable signature position (above or below quoted text) per identity
- From-header override in the composer with optional catch-all auto-reply: replies to an alias on a domain you own auto-fill the alias as the sender even when it isn't a configured identity
- `.eml` file import via folder right-click menu
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
- Folder management with icon picker, subfolders, and sidebar counts
- Print directly from the viewer
- Browser history sync for back/forward navigation
## Calendar
- Month, week, day, and agenda views with a mini-calendar sidebar and task list
- Drag-to-reschedule, click-drag creation, and edge-resize with 15-minute snap
- Recurring events with scoped edit/delete (this / this and following / all)
- iMIP invitations on create and update (RFC 5545 / 6047), organizer/attendee UI, and RSVP with trust assessment
- Inline calendar invitations in the email viewer auto-detect `.ics`, RSVP, import
- iCalendar import with preview, bulk create, and UID deduplication
- iCal / webcal subscriptions with editing and batch import
- 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, multi-account home resolution, and per-viewer colors
- Week numbers, event hover preview, notifications with sound picker
- Real-time sync via JMAP push
## Contacts
- JMAP sync (RFC 9553 / 9610) with local fallback
- Multiple address books with drag-and-drop between books
- Contact groups with member management
- vCard import/export (RFC 6350) with duplicate detection
- Trusted senders stored in a dedicated JMAP address book
- Autocomplete in the composer (To / Cc / Bcc)
## Filters & Templates
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
- 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
- Reusable email templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
## Files
- 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 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
- Optional "Remember me" via AES-256-GCM encrypted httpOnly cookie
- Enforced CSP with per-request nonce, SSRF redirect validation, PDF iframe sandbox, and IP spoofing prevention
- Plugin hardening with dangerous-pattern detection and admin approval
- Newsletter unsubscribe (RFC 2369)
## Interface
- 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
- Interactive guided tour for new users
- Right-click context menus, toast notifications with undo
- Customizable toolbar position, favicon, and login branding
- Pinnable sidebar apps with drag-and-drop reordering
- Encrypted settings sync across devices
- Storage quota display
- WCAG AA contrast, reduced-motion support, focus trap, and screen reader live regions
## Internationalization
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`.
## Identity & Multi-Account
- Multiple simultaneous accounts with instant switching and per-account session persistence; the 5-account cap is lifted on HTTP/2 servers (limited by browser connection pooling on HTTP/1.1)
- Account switcher with connection status and default account selection
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list
- 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`)
## Admin & Extensibility
- Web setup wizard for first launch guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
- Admin policy gates for the aggregate mail views enable or disable the "All Mail" and the cross-account "All unread / starred / all" entries org-wide; each gated view still respects the user's own toggle
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
- File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts
- Admin toggle for search-engine indexing (`robots.txt` / `noindex`)
- 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
- Bundled plugins including Jitsi Meet calendar integration
## Operations
- 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-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
+188 -203
View File
@@ -8,264 +8,153 @@
# Bulwark Webmail # Bulwark Webmail
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/).<br/> A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
Built with Next.js and the JMAP protocol.
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-1.4.3-green.svg)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue)](https://ghcr.io/bulwarkmail/webmail)
[![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.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)
</div> </div>
--- ---
## Installer
New in **1.6.4**: a web-based setup wizard runs on first launch no `.env.local` editing, no shelling into the container.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="screenshots/installer-dark.png" />
<img src="screenshots/installer.png" alt="Setup wizard" width="100%" />
</picture>
Point a browser at the running container and the wizard guides you through:
- **Server** probe one or more JMAP endpoints, optional auto-pick by email domain, Stalwart feature toggle
- **Auth** OAuth2 / OIDC discovery and validation, or basic-auth fallback
- **Security** generate or paste a `SESSION_SECRET`, opt into settings sync
- **Logging** text or JSON, level
- **Branding** upload favicon, app logos, login logos, and company / legal URLs
- **Review** grouped summary with an advanced toggle for the full config
- **Admin** set the initial admin password and optionally drop a `.config-locked` marker so the config volume can be remounted read-only
The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JMAP_SERVER_URL` in the environment skips the wizard and uses env-managed configuration instead.
---
## Screenshots ## Screenshots
<picture>
<source media="(prefers-color-scheme: dark)" srcset="screenshots/mail-dark.png" />
<img src="screenshots/mail-white.png" alt="Mail view" width="100%" />
</picture>
<table> <table>
<tr> <tr>
<td width="50%"> <td width="50%"><img src="screenshots/calendar.png" alt="Calendar" /></td>
<td width="50%"><img src="screenshots/contacts.png" alt="Contacts" /></td>
<img src="screenshots/inbox.png" width="100%" alt="Inbox — three-pane layout with sidebar, email list, and viewer (dark mode)">
**Mail** — Three-pane layout with sidebar, email list, and viewer
</td>
<td width="50%">
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
**Calendar** — Month, week, day, and agenda views with event management
</td>
</tr> </tr>
<tr> <tr>
<td width="50%"> <td><sub><b>Calendar</b> month, week, day, and agenda views with drag-to-reschedule, iMIP invitations, and CalDAV subscriptions.</sub></td>
<td><sub><b>Contacts</b> multiple address books, groups, vCard import/export, and autocomplete in the composer.</sub></td>
<img src="screenshots/contacts.png" width="100%" alt="Contacts"> </tr>
<tr>
**Contacts** — Contact management with groups and vCard support <td><img src="screenshots/theme.png" alt="Themes" /></td>
<td><img src="screenshots/plugins.png" alt="Plugins" /></td>
</td> </tr>
<td width="50%"> <tr>
<td><sub><b>Themes</b> bundled color themes or upload your own as ZIP bundles; admins can enforce presets.</sub></td>
<img src="screenshots/files.png" width="100%" alt="File browser"> <td><sub><b>Plugins</b> extend the client with bundled or third-party plugins installed from a .zip file.</sub></td>
</tr>
**Files** — Cloud file browser with upload, preview, and folder navigation <tr>
<td><img src="screenshots/mail-white.png" alt="Light mode" /></td>
</td> <td><img src="screenshots/settings.png" alt="Settings" /></td>
</tr>
<tr>
<td><sub><b>Light mode</b> full theme support with intelligent color transformation for HTML emails.</sub></td>
<td><sub><b>Settings</b> appearance, identities, filters, templates, security, and more.</sub></td>
</tr> </tr>
</table> </table>
<details> ## Overview
<summary>More screenshots</summary>
<table>
<tr>
<td width="50%">
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox — light mode"> 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:
**Light mode** — Full theme support with intelligent color transformation - **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
</td> 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.
<td width="50%">
<img src="screenshots/settings.png" width="100%" alt="Settings"> Full feature list: **[FEATURES.md](FEATURES.md)**.
**Settings** — Appearance, identities, filters, templates, and more
</td>
</tr>
<tr>
<td width="50%">
<img src="screenshots/login.png" width="100%" alt="Login page">
**Login** — Configurable branding with OAuth2/OIDC and 2FA support
</td>
<td width="50%">
</td>
</tr>
</table>
</details>
## Features
### Mail
- **Read, compose, reply, reply-all, forward** with rich HTML rendering
- **Threading** — Gmail-style inline expansion with thread navigation
- **Draft auto-save** with discard confirmation
- **Attachments** — upload, download, and inline preview
- **Search** — full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
- **Batch operations** — multi-select with checkboxes, archive, delete, move, tag
- **Archive modes** — archive directly or organize archived mail by year or month
- **Print** emails directly from the viewer
- **Color tags/labels** and star/unstar
- **Virtual scrolling** for large mailboxes
- **Quick reply** from the viewer
- **Sender avatars** — favicon-based with negative caching for performance
- **Recipient popover** for quick contact interaction
- **TNEF support** — extract Outlook `winmail.dat` message bodies and attachments automatically
- **Folder management** — create, rename, delete folders with icon picker and subfolder support
- **Tag counts** — unread and total counts displayed in sidebar
### Calendar
- **Month, week, day, and agenda views** with mini-calendar sidebar
- **Event hover preview** popover with details
- **Drag-and-drop rescheduling**, click-drag creation, edge-resize (15-min snap)
- **Recurring events** with edit/delete scope (this / this and following / all)
- **Participant scheduling** — iTIP invitations, organizer/attendee UI, RSVP
- **Inline calendar invitations** in email viewer — auto-detect `.ics`, RSVP, import
- **iCalendar import** with preview and bulk create
- **Notifications** with configurable sound and alert persistence
- **Real-time sync** via JMAP push
### Contacts
- **Contact management** with JMAP sync (RFC 9553/9610) and local fallback
- **Contact groups** with group expansion and member management
- **vCard import/export** (RFC 6350) with duplicate detection
- **Autocomplete** in composer (To/Cc/Bcc)
- **Bulk operations** — multi-select, delete, group add, export
### Filters & Automation
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
- **Visual rule builder** — conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
- **Raw Sieve editor** with syntax validation
- **Vacation responder** with date range scheduling and sidebar indicator
- **Email templates** — reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
### Files
- **File browser** with JMAP FileNode cloud storage (Stalwart native)
- **Upload and download** files with progress tracking and folder upload support
- **Folder navigation** with breadcrumb path and tree sidebar
- **Grid and list views** with sorting by name, size, or date
- **Clipboard operations** — cut, copy, paste, duplicate files
- **File preview** for images, text, audio, video, and more
- **Favorites and recent files** for quick access
- **Bulk operations** — multi-select, delete, move, download
### Security & Privacy
- **External content blocked** by default — trusted senders list for auto-load
- **HTML sanitization** via DOMPurify with XSS prevention
- **S/MIME** — manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures
- **SPF/DKIM/DMARC** status indicators
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode
- **TOTP two-factor authentication**
- **Account security panel** — manage passwords and 2FA via Stalwart admin API
- **"Remember me"** — AES-256-GCM encrypted httpOnly cookie (opt-in)
- **Security headers** — CSP with per-request nonce, X-Frame-Options, Referrer-Policy
- **Newsletter unsubscribe** (RFC 2369)
### Interface
- **Three-pane layout** — sidebar, email list, viewer with resizable columns
- **Dark and light themes** with intelligent email color transformation
- **Always-light email rendering** option for problematic HTML messages in dark theme
- **Responsive** — desktop sidebar + mobile bottom tab bar with tablet support
- **Keyboard shortcuts** — full navigation without a mouse
- **Drag-and-drop** email organization between mailboxes and tag assignment
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
- **Sidebar apps** — pin custom tools to the navigation rail and open them inline or in a new tab
- **Settings sync** — preferences synchronized with the server (encrypted)
- **Storage quota** display
- **Shared folders** — multi-account access
- **Accessibility** — WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
### Internationalization
8 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português
Automatic browser detection with persistent preference.
### Identity Management
- **Multiple sender identities** with per-identity signatures
- **Identity refresh** — keep the identity manager aligned with server-side changes after edits
- **Sub-addressing** — `user+tag@domain.com` with contextual tag suggestions
- **Identity badges** in viewer and email list
### Operations
- **Automatic update check** — server logs when a newer release is available
--- ---
## Quick Start ## Quick Start
### Docker (recommended) ### Docker
```bash ```bash
docker run -d -p 3000:3000 \ docker run -d -p 3000:3000 ghcr.io/bulwarkmail/webmail:latest
-e JMAP_SERVER_URL=https://mail.example.com \
ghcr.io/bulwarkmail/webmail:latest
``` ```
Or with Docker Compose: Or with Docker Compose:
```bash ```bash
cp .env.example .env.local
# Edit .env.local — set JMAP_SERVER_URL
docker compose up -d docker compose up -d
``` ```
On first launch, open `http://localhost:3000` the **web setup wizard** walks you through JMAP server, OAuth, branding, and the admin password. No `.env.local` editing required. Existing installs that already define `JMAP_SERVER_URL` in their environment skip the wizard and keep the env-managed flow described under [Configuration](#configuration).
### From Source ### From Source
```bash ```bash
git clone https://github.com/bulwarkmail/webmail.git git clone https://github.com/bulwarkmail/webmail.git
cd webmail cd webmail
npm install npm install
cp .env.example .env.local
# Edit .env.local — set JMAP_SERVER_URL
npm run build && npm start npm run build && npm start
# Then open http://localhost:3000 to run the setup wizard
``` ```
### Development ### Development
```bash ```bash
npm run dev # Start dev server (mock JMAP server included) npm run dev # Dev server with a mock JMAP server
npm run typecheck # Type checking npm run typecheck
npm run lint # Linting npm run lint
``` ```
## Configuration ## Configuration
Edit `.env.local`: Most deployments are configured through the **setup wizard** (on first launch) and the **admin dashboard** thereafter; values are written to the admin config directory rather than `.env.local`. Environment variables remain supported for operators who prefer file-driven configuration or read-only / immutable infrastructure. When an environment variable is set, it takes precedence over the corresponding admin-managed value, so setting `JMAP_SERVER_URL` will hide that field from the wizard and lock it in the admin UI.
All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
```env ```env
# Required # Optional overrides whatever the wizard writes
JMAP_SERVER_URL=https://mail.example.com JMAP_SERVER_URL=https://mail.example.com
# Optional
APP_NAME=My Webmail APP_NAME=My Webmail
``` ```
All variables are **runtime** — Docker deployments can be configured without rebuilding.
<details> <details>
<summary>Server Listen Address</summary> <summary>Server listen address</summary>
```env ```env
HOSTNAME=0.0.0.0 # Default; use "::" for IPv6 HOSTNAME=0.0.0.0 # Default; use "::" for IPv6
PORT=3000 # Default listen port PORT=3000
``` ```
</details> </details>
<details> <details>
<summary>OAuth2/OIDC (SSO)</summary> <summary>OAuth2 / OIDC</summary>
```env ```env
OAUTH_ENABLED=true OAUTH_ENABLED=true
OAUTH_CLIENT_ID=webmail OAUTH_CLIENT_ID=webmail
OAUTH_CLIENT_SECRET= # optional, for confidential clients OAUTH_CLIENT_SECRET= # optional, for confidential clients
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik) OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
OAUTH_ISSUER_URL= # optional, for external IdPs
``` ```
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`. Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`.
@@ -273,13 +162,116 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
</details> </details>
<details> <details>
<summary>Remember Me</summary> <summary>Session & settings sync</summary>
```env ```env
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32 SESSION_SECRET= # openssl rand -base64 32
SESSION_SECRET_FILE=/session-secret # path to a file containing the secret
SETTINGS_SYNC_ENABLED=true
SETTINGS_DATA_DIR=./data/settings # mount as a volume in Docker
``` ```
Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry). Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry). Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
</details>
<details>
<summary>Custom JMAP endpoint</summary>
```env
ALLOW_CUSTOM_JMAP_ENDPOINT=true
```
Shows a "JMAP Server" field on the login form. External servers must CORS-allow the webmail origin.
</details>
<details>
<summary>Branding & PWA</summary>
```env
APP_NAME=My Webmail
APP_SHORT_NAME=Webmail
APP_DESCRIPTION=Your personal mail
FAVICON_URL=/branding/favicon.svg
PWA_ICON_URL=/branding/icon.svg # falls back to FAVICON_URL
PWA_THEME_COLOR=#3b82f6
PWA_BACKGROUND_COLOR=#ffffff
APP_LOGO_LIGHT_URL=/branding/logo-light.svg
APP_LOGO_DARK_URL=/branding/logo-dark.svg
LOGIN_LOGO_LIGHT_URL=/branding/login-light.svg
LOGIN_LOGO_DARK_URL=/branding/login-dark.svg
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>
<details>
<summary>Extension directory</summary>
```env
EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
```
Enables the admin marketplace for browsing and installing plugins and themes.
</details>
<details>
<summary>Stalwart integration & logging</summary>
```env
STALWART_FEATURES=true # password change, Sieve filters, etc.
LOG_FORMAT=text # "text" or "json"
LOG_LEVEL=info # error | warn | info | debug
```
</details>
<details>
<summary>Admin data directories</summary>
```env
ADMIN_CONFIG_DIR=./data/admin # operator-authored: config.json, policy.json, plugins/, themes/
ADMIN_STATE_DIR=./data/admin-state # runtime: audit log, login timestamps, setup token
ADMIN_CONFIG_READONLY=true # enforce read-only mode at the app layer
```
The split lets you mount the config volume read-only after the setup wizard completes. Legacy installs that pre-date the split keep working through `ADMIN_DATA_DIR`.
</details>
<details>
<summary>Subpath / reverse proxy mount</summary>
To serve the webmail at a subpath (e.g. `https://example.com/webmail`):
```env
NEXT_PUBLIC_BASE_PATH=/webmail
NEXT_PUBLIC_LOCALE_PREFIX=always # avoids next-intl rewrite loops
```
Unlike most other variables, `NEXT_PUBLIC_BASE_PATH` is read at **build time** because Next.js bakes it into emitted asset URLs. To use it with the published Docker image, build your own image with the variable set:
```bash
docker build --build-arg NEXT_PUBLIC_BASE_PATH=/webmail -t bulwark-webmail .
```
Then point your reverse proxy at the container without stripping the prefix - the app expects to receive requests under `/webmail/...` and serves all routes (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, etc.) accordingly.
</details> </details>
@@ -313,23 +305,16 @@ Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day exp
## Why Stalwart? ## Why Stalwart?
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies. [Stalwart](https://github.com/stalwartlabs/mail-server) is a Rust mail server with native JMAP support not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single self-hosted binary with no third-party dependencies.
## Contributing ## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. See [CONTRIBUTING.md](CONTRIBUTING.md).
## Roadmap
See [ROADMAP.md](ROADMAP.md) for planned features and current status.
## License ## License
[GNU AGPL v3](LICENSE) [GNU AGPL v3](LICENSE). This repository preserves the original MIT attribution for the fork lineage in [NOTICE](NOTICE).
This repository also preserves the original MIT attribution notice for the
fork lineage in [NOTICE](NOTICE).
## Acknowledgments ## Acknowledgments
Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for doing most of the groundwork that this project builds upon. Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for the groundwork this project builds upon.
+1 -1
View File
@@ -1 +1 @@
1.4.4 1.7.7
+10
View File
@@ -0,0 +1,10 @@
import { notFound } from 'next/navigation';
// Catch-all that anchors unmatched URLs into the (main) route group so
// Next renders app/(main)/not-found.tsx (wrapped by (main)/layout.tsx)
// instead of the built-in __next_builtin__not-found page. Without this,
// route groups can't pick a root layout for URLs that match nothing, so
// 404s render bare.
export default function CatchAll() {
notFound();
}
+253
View File
@@ -0,0 +1,253 @@
"use client";
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, toRouterPath } from "@/lib/browser-navigation";
import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useParams } from "next/navigation";
function OAuthCallbackInner() {
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
const t = useTranslations("login");
const { loginWithOAuth, loginWithServerSso } = useAuthStore();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const code = searchParams.get("code");
const state = searchParams.get("state");
const errorParam = searchParams.get("error");
if (errorParam) {
setError(errorParam === "access_denied" ? "access_denied" : "token_exchange_failed");
return;
}
if (!code) {
setError("missing_params");
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) {
// Classic flow - sessionStorage has the PKCE state (same-tab OAuth)
if (!state || state !== savedState) {
setError("invalid_state");
return;
}
const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
const serverUrl = sessionStorage.getItem("oauth_server_url");
const serverId = sessionStorage.getItem("oauth_server_id") || undefined;
if (!codeVerifier || !serverUrl) {
setError("missing_params");
return;
}
const prefix = getPathPrefix(params.locale as string);
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri, serverId)
.then((success) => {
if (success) {
sessionStorage.removeItem("oauth_state");
sessionStorage.removeItem("oauth_code_verifier");
sessionStorage.removeItem("oauth_server_url");
sessionStorage.removeItem("oauth_server_id");
sessionStorage.removeItem("oauth_add_account_mode");
let redirectTo = `${prefix}/${params.locale}`;
try {
const saved = sessionStorage.getItem('redirect_after_login');
if (saved) {
sessionStorage.removeItem('redirect_after_login');
redirectTo = saved;
}
} catch { /* sessionStorage may be unavailable */ }
router.push(toRouterPath(redirectTo));
} else {
setError("token_exchange_failed");
}
})
.catch(() => {
setError("token_exchange_failed");
});
} else if (state) {
// Server-side SSO flow - state was stored in encrypted httpOnly cookie.
// Branch on mobile handoff first: the login page left a marker in
// sessionStorage if it kicked this OAuth dance off for the mobile app.
let mobileRedirectUri: string | null = null;
let mobileState: string | null = null;
try {
mobileRedirectUri = sessionStorage.getItem("mobile_redirect_uri");
mobileState = sessionStorage.getItem("mobile_state");
} catch { /* sessionStorage may be unavailable */ }
if (mobileRedirectUri && mobileRedirectUri.startsWith("bulwarkmobile://")) {
// Drive /api/auth/sso/complete directly so we can read the tokens
// out of the response - loginWithServerSso would consume them and
// wire up the webmail auth store, which isn't useful here. The
// server's mobile-flow branch (keyed on the pending cookie) skips
// the refresh-token cookie write for the same reason.
(async () => {
try {
const res = await apiFetch("/api/auth/sso/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ code, state }),
});
if (!res.ok) {
setError("token_exchange_failed");
return;
}
const data = await res.json();
const serverUrl = data.server_url as string | undefined;
const accessToken = data.access_token as string | undefined;
const tokenEndpoint = data.token_endpoint as string | undefined;
const clientId = data.client_id as string | undefined;
if (!serverUrl || !accessToken || !tokenEndpoint || !clientId) {
setError("token_exchange_failed");
return;
}
const fragment = new URLSearchParams({
flow: "oauth",
server_url: serverUrl,
access_token: accessToken,
token_endpoint: tokenEndpoint,
client_id: clientId,
state: mobileState ?? "",
});
if (typeof data.refresh_token === "string") {
fragment.set("refresh_token", data.refresh_token);
}
if (typeof data.expires_in === "number") {
fragment.set("expires_in", String(data.expires_in));
}
try {
sessionStorage.removeItem("mobile_redirect_uri");
sessionStorage.removeItem("mobile_state");
} catch { /* ignore */ }
window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`);
} catch {
setError("token_exchange_failed");
}
})();
return;
}
const ssoPrefix = getPathPrefix(params.locale as string);
loginWithServerSso(code, state)
.then((success) => {
if (success) {
let redirectTo = `${ssoPrefix}/${params.locale}`;
try {
const saved = sessionStorage.getItem('redirect_after_login');
if (saved) {
sessionStorage.removeItem('redirect_after_login');
redirectTo = saved;
}
} catch { /* sessionStorage may be unavailable */ }
router.push(toRouterPath(redirectTo));
} else {
setError("token_exchange_failed");
}
})
.catch(() => {
setError("token_exchange_failed");
});
} else {
setError("invalid_state");
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="w-full max-w-sm mx-auto px-4 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-red-500/10 mb-6">
<AlertCircle className="w-10 h-10 text-red-500" />
</div>
<h1 className="text-xl font-medium text-foreground mb-2">
{t("oauth_error.title")}
</h1>
<p className="text-muted-foreground text-sm mb-6">
{t(`oauth_error.${error}`)}
</p>
<Button
variant="outline"
onClick={() => router.push(toRouterPath(`${getPathPrefix(params.locale as string)}/${params.locale}/login`))}
>
{t("oauth_error.back_to_login")}
</Button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="w-full max-w-sm mx-auto px-4 text-center" role="status">
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto mb-4" />
<p className="text-muted-foreground text-sm">{t("oauth_completing")}</p>
</div>
</div>
);
}
export default function OAuthCallbackPage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="w-full max-w-sm mx-auto px-4 text-center" role="status">
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto mb-4" />
</div>
</div>
}
>
<OAuthCallbackInner />
</Suspense>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,10 @@
"use client"; "use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { ArrowLeft, Users } from "lucide-react"; import { useSearchParams } from "next/navigation";
import { useRouter } from "@/i18n/navigation";
import { ArrowLeft, Users, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
@@ -14,19 +15,28 @@ import { ContactGroupForm } from "@/components/contacts/contact-group-form";
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail"; import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar"; import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog"; import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { RenameDialog } from "@/components/files/rename-dialog";
import { exportContacts } from "@/components/contacts/contact-export"; import { exportContacts } from "@/components/contacts/contact-export";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store"; import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { useAuthStore } from "@/stores/auth-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 { useEmailStore } from "@/stores/email-store";
import { usePolicyStore } from "@/stores/policy-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils"; import { cn, generateUUID } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail"; import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view"; import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useProMultiAccountContacts } from "@/hooks/use-pro-multi-account-contacts";
import { ResizeHandle } from "@/components/layout/resize-handle"; import { ResizeHandle } from "@/components/layout/resize-handle";
import { useIsMobile } from "@/hooks/use-media-query"; import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import type { ContactCard, AddressBook } from "@/lib/jmap/types"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
type View = type View =
| "list" | "list"
@@ -39,8 +49,8 @@ type View =
| "bulk-add-to-group"; | "bulk-add-to-group";
export default function ContactsPage() { export default function ContactsPage() {
const router = useRouter();
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
@@ -74,16 +84,41 @@ export default function ContactsPage() {
bulkDeleteContacts, bulkDeleteContacts,
bulkAddToGroup, bulkAddToGroup,
moveContactToAddressBook, moveContactToAddressBook,
createAddressBook,
renameAddressBook,
removeAddressBook,
shareAddressBook,
renameKeyword,
importContacts, importContacts,
} = useContactStore(); } = useContactStore();
const [view, setView] = useState<View>("list"); const [view, setView] = useState<View>("list");
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all"); const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
const [showImportDialog, setShowImportDialog] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false);
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
const [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);
const [returnToEmail, setReturnToEmail] = useState(false);
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null); const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const hasFetched = useRef(false); const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
const isEmbedded = useIsEmbedded();
const router = useRouter();
const searchParams = useSearchParams();
// One-shot intent flag: only consume the URL params on the first render that
// has them. After applying, we strip the query so a later refresh or
// re-mount doesn't re-trigger the navigation.
const intentAppliedRef = useRef(false);
// Narrow pane (Pro split or small window): the categories sidebar collapses
// into a burger-toggled overlay.
const isNarrow = !isDesktop;
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
// Panel resize state - sidebar (categories) // Panel resize state - sidebar (categories)
const [sidebarWidth, setSidebarWidth] = useState(() => { const [sidebarWidth, setSidebarWidth] = useState(() => {
@@ -94,13 +129,20 @@ export default function ContactsPage() {
// Panel resize state - contact list // Panel resize state - contact list
const [listWidth, setListWidth] = useState(() => { const [listWidth, setListWidth] = useState(() => {
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 320; } catch { return 320; } try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 384; } catch { return 384; }
}); });
const [isListResizing, setIsListResizing] = useState(false); const [isListResizing, setIsListResizing] = useState(false);
const listDragStartWidth = useRef(320); const listDragStartWidth = useRef(384);
// Check auth on mount // Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => { useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => { checkAuth().finally(() => {
setInitialCheckDone(true); setInitialCheckDone(true);
}); });
@@ -109,26 +151,92 @@ export default function ContactsPage() {
useEffect(() => { useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) { if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
router.push("/login"); redirectToLogin();
} }
}, [initialCheckDone, isAuthenticated, authLoading, router]); }, [initialCheckDone, isAuthenticated, authLoading]);
// Pro shell only: aggregate contacts and address books from every
// connected account so the sidebar lists them all. The hook is a no-op
// outside the embedded shell.
const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountContacts();
useEffect(() => { useEffect(() => {
if (isEmbedded) return;
if (client && supportsSync && !hasFetched.current) { if (client && supportsSync && !hasFetched.current) {
hasFetched.current = true; hasFetched.current = true;
fetchContacts(client); fetchContacts(client);
} }
}, [client, supportsSync, fetchContacts]); }, [client, supportsSync, fetchContacts, isEmbedded]);
// Consume one-shot URL params (set by the mobile recipient popover when no
// sidebar is available) and strip them so a refresh doesn't replay the
// intent. `from=email` flips the mobile back button to `router.back()`.
useEffect(() => {
if (intentAppliedRef.current) return;
const contactId = searchParams.get('contactId');
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(viewParam === 'edit' ? 'edit' : 'detail');
} else if (addEmail) {
setCreatePrefill({ email: addEmail, name: addName ?? undefined });
setSelectedContact(null);
setView('create');
}
router.replace('/contacts');
}, [searchParams, router, setSelectedContact]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh contacts via JMAP instead of reloading the page.
useRefreshGesture({
enabled: isAuthenticated && !!client && supportsSync,
onRefresh: async () => {
if (!client) return;
if (multiAccountEnabled && accountClients.length > 0) {
const activeId = useAuthStore.getState().activeAccountId;
if (activeId) {
const { fetchAllAccountsContacts, fetchAllAccountsAddressBooks } = useContactStore.getState();
await Promise.all([
fetchAllAccountsAddressBooks(accountClients, activeId),
fetchAllAccountsContacts(accountClients, activeId),
]);
return;
}
}
await fetchContacts(client);
},
});
const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]); const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]);
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]); const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null; const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null; const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : []; const selectedGroupMembers = useMemo(() => selectedGroupId ? getGroupMembers(selectedGroupId) : [], [selectedGroupId, getGroupMembers]);
// Collect all unique keywords across contacts
const allKeywords = useMemo(() => {
const kws = new Set<string>();
for (const contact of individuals) {
if (!contact.keywords) continue;
for (const [kw, active] of Object.entries(contact.keywords)) {
if (active) kws.add(kw);
}
}
return Array.from(kws).sort((a, b) => a.localeCompare(b));
}, [individuals]);
// Contacts to display based on active category // Contacts to display based on active category
const displayedContacts = useMemo(() => { const displayedContacts = useMemo(() => {
if (activeCategory === "all") return individuals; if (activeCategory === "all") return individuals;
if (activeCategory === "uncategorized") {
return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0);
}
if ("addressBookId" in activeCategory) { if ("addressBookId" in activeCategory) {
const bookId = activeCategory.addressBookId; const bookId = activeCategory.addressBookId;
return individuals.filter(c => { return individuals.filter(c => {
@@ -143,20 +251,6 @@ export default function ContactsPage() {
return getGroupMembers(activeCategory.groupId); return getGroupMembers(activeCategory.groupId);
}, [activeCategory, individuals, getGroupMembers]); }, [activeCategory, individuals, getGroupMembers]);
// Label for the current category
const categoryLabel = useMemo(() => {
if (activeCategory === "all") return t("tabs.all");
if ("addressBookId" in activeCategory) {
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
return book?.name || t("tabs.all");
}
if ("keyword" in activeCategory) {
return activeCategory.keyword;
}
const group = contacts.find(c => c.id === activeCategory.groupId);
return group ? getContactDisplayName(group) : t("tabs.all");
}, [activeCategory, contacts, addressBooks, t]);
const handleSelectCategory = useCallback((category: ContactCategory) => { const handleSelectCategory = useCallback((category: ContactCategory) => {
setActiveCategory(category); setActiveCategory(category);
clearSelection(); clearSelection();
@@ -166,6 +260,7 @@ export default function ContactsPage() {
} else { } else {
setSelectedGroupId(null); setSelectedGroupId(null);
} }
setNarrowSidebarOpen(false);
}, [clearSelection]); }, [clearSelection]);
const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => { const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => {
@@ -182,6 +277,59 @@ export default function ContactsPage() {
} }
}, [client, moveContactToAddressBook, t]); }, [client, moveContactToAddressBook, t]);
const handleDropContactsToCategory = useCallback(async (contactIds: string[], keyword: string) => {
if (!client && supportsSync) return;
try {
for (const contactId of contactIds) {
const contact = contacts.find(c => c.id === contactId);
if (!contact) continue;
const existingKeywords = contact.keywords || {};
if (existingKeywords[keyword]) continue; // already has this keyword
const updatedKeywords = { ...existingKeywords, [keyword]: true };
if (supportsSync && client) {
await updateContact(client, contactId, { keywords: updatedKeywords });
} else {
updateLocalContact(contactId, { keywords: updatedKeywords });
}
}
const msg = contactIds.length === 1
? t("category_added", { name: keyword })
: t("category_added_plural", { count: contactIds.length, name: keyword });
toast.success(msg);
} catch (error) {
console.error('Failed to add contacts to category:', error);
toast.error(t("toast.error_update"));
}
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
// Refresh address books (and contacts) after a structural change, staying
// multi-account aware so a freshly created book lands in the sidebar.
const refreshAddressBooks = useCallback(async () => {
if (!client) return;
if (multiAccountEnabled && accountClients.length > 0) {
const activeId = useAuthStore.getState().activeAccountId;
if (activeId) {
const { fetchAllAccountsAddressBooks } = useContactStore.getState();
await fetchAllAccountsAddressBooks(accountClients, activeId);
return;
}
}
await useContactStore.getState().fetchAddressBooks(client);
}, [client, multiAccountEnabled, accountClients]);
const handleCreateAddressBook = useCallback(async (name: string) => {
if (!client) return;
try {
await createAddressBook(client, name);
await refreshAddressBooks();
toast.success(t("address_books.created"));
setCreatingAddressBook(false);
} catch (error) {
console.error('Failed to create address book:', error);
toast.error(t("address_books.create_failed"));
}
}, [client, createAddressBook, refreshAddressBooks, t]);
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => { const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
return importContacts( return importContacts(
supportsSync && client ? client : null, supportsSync && client ? client : null,
@@ -204,9 +352,7 @@ export default function ContactsPage() {
setView("edit"); setView("edit");
}; };
const handleDelete = async () => { const deleteContactById = useCallback(async (contactId: string) => {
if (!selectedContact) return;
const confirmed = await confirmDialog({ const confirmed = await confirmDialog({
title: t("delete_confirm_title"), title: t("delete_confirm_title"),
message: t("delete_confirm"), message: t("delete_confirm"),
@@ -217,33 +363,82 @@ export default function ContactsPage() {
try { try {
if (supportsSync && client) { if (supportsSync && client) {
await deleteContact(client, selectedContact.id); await deleteContact(client, contactId);
} else { } else {
deleteLocalContact(selectedContact.id); deleteLocalContact(contactId);
} }
toast.success(t("toast.deleted")); toast.success(t("toast.deleted"));
setView("list"); if (selectedContactId === contactId) setView("list");
} catch (error) { } catch (error) {
console.error('Failed to delete contact:', error); console.error('Failed to delete contact:', error);
toast.error(t("toast.error_delete")); toast.error(t("toast.error_delete"));
} }
}, [confirmDialog, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]);
const handleDelete = async () => {
if (!selectedContact) return;
await deleteContactById(selectedContact.id);
}; };
const handleEditContact = useCallback((id: string) => {
setSelectedContact(id);
setView("edit");
}, [setSelectedContact]);
const handleDeleteContact = useCallback((contact: ContactCard) => {
void deleteContactById(contact.id);
}, [deleteContactById]);
const handleAddContactToGroup = useCallback((id: string) => {
clearSelection();
toggleContactSelection(id);
if (groups.length === 0) {
setView("group-create");
return;
}
setView("bulk-add-to-group");
}, [clearSelection, toggleContactSelection, groups.length]);
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
const { id: _id, created: _created, updated: _updated, ...rest } = source;
void _id; void _created; void _updated;
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
if (supportsSync && client) {
await createContact(client, data);
toast.success(t("toast.created"));
} else {
const localContact: ContactCard = {
id: `local-${generateUUID()}`,
addressBookIds: data.addressBookIds || {},
...data,
};
addLocalContact(localContact);
toast.success(t("toast.created"));
}
}, [supportsSync, client, createContact, addLocalContact, t]);
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => { const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
if (supportsSync && client) { if (supportsSync && client) {
await createContact(client, data); await createContact(client, data);
toast.success(t("toast.created")); toast.success(t("toast.created"));
} else { } else {
const localContact: ContactCard = { const localContact: ContactCard = {
id: `local-${crypto.randomUUID()}`, id: `local-${generateUUID()}`,
addressBookIds: {}, addressBookIds: {},
...data, ...data,
}; };
addLocalContact(localContact); addLocalContact(localContact);
toast.success(t("toast.created")); toast.success(t("toast.created"));
} }
setDefaultBookIdForCreate(undefined);
setCreatePrefill(undefined);
if (returnToEmail) {
setReturnToEmail(false);
router.back();
return;
}
setView("list"); setView("list");
}, [supportsSync, client, createContact, addLocalContact, t]); }, [supportsSync, client, createContact, addLocalContact, t, returnToEmail, router]);
const handleSaveEdit = useCallback(async (data: Partial<ContactCard>) => { const handleSaveEdit = useCallback(async (data: Partial<ContactCard>) => {
if (!selectedContact) return; if (!selectedContact) return;
@@ -259,6 +454,15 @@ export default function ContactsPage() {
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]); }, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
const handleCancel = () => { const handleCancel = () => {
setDefaultBookIdForCreate(undefined);
// Came from email → cancel returns to the email instead of the contact list.
if (returnToEmail && view === "create") {
setCreatePrefill(undefined);
setReturnToEmail(false);
router.back();
return;
}
if (view === "create") setCreatePrefill(undefined);
if (view === "group-create" || view === "group-edit") { if (view === "group-create" || view === "group-edit") {
setView(selectedGroup ? "group-detail" : "list"); setView(selectedGroup ? "group-detail" : "list");
} else if (view === "bulk-add-to-group") { } else if (view === "bulk-add-to-group") {
@@ -268,7 +472,7 @@ export default function ContactsPage() {
} }
}; };
const handleSelectGroup = (id: string) => { const _handleSelectGroup = (id: string) => {
setSelectedGroupId(id); setSelectedGroupId(id);
setActiveCategory({ groupId: id }); setActiveCategory({ groupId: id });
setView("group-detail"); setView("group-detail");
@@ -289,6 +493,51 @@ export default function ContactsPage() {
setView("group-edit"); setView("group-edit");
}, []); }, []);
// Open the in-app composer in the current session rather than routing through
// a mailto: URL. `window.location='mailto:'` hands off to the OS handler
// (which may open a different mail app), and the mailto protocol round-trip
// reloads the app - dropping the in-memory per-account JMAP clients of a
// multi-account session, which reads as a logout. Stashing the recipients and
// doing a client-side router.push keeps the session and the active account
// intact; the main route consumes the pending compose and opens the composer
// (see consumePendingMailto in page.tsx).
const openComposeInApp = useCallback((recipients: string[], field: "to" | "cc" | "bcc") => {
savePendingMailto({
to: field === "to" ? recipients : [],
cc: field === "cc" ? recipients : [],
bcc: field === "bcc" ? recipients : [],
subject: "",
body: "",
});
router.push("/");
}, [router]);
const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => {
// Format each member as "Name <email>" so the composer keeps the display
// name (round-trips via formatRecipient -> parseRecipientList). Dedupe by
// email, case-insensitively; members without an email are skipped.
const seen = new Set<string>();
const recipients: string[] = [];
for (const member of getGroupMembers(groupId)) {
const email = getContactPrimaryEmail(member).trim();
const key = email.toLowerCase();
if (!email || seen.has(key)) continue;
seen.add(key);
recipients.push(formatRecipient(getContactDisplayName(member), email));
}
if (recipients.length === 0) {
toast.error(t("groups.no_member_emails"));
return;
}
openComposeInApp(recipients, field);
}, [getGroupMembers, t, openComposeInApp]);
const handleComposeContact = useCallback((contact: ContactCard) => {
const email = getContactPrimaryEmail(contact).trim();
if (!email) return;
openComposeInApp([formatRecipient(getContactDisplayName(contact), email)], "to");
}, [openComposeInApp]);
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => { const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
const confirmed = await confirmDialog({ const confirmed = await confirmDialog({
title: t("groups.delete_confirm_title"), title: t("groups.delete_confirm_title"),
@@ -430,7 +679,7 @@ export default function ContactsPage() {
const renderRightPanel = () => { const renderRightPanel = () => {
switch (view) { switch (view) {
case "create": case "create":
return <ContactForm addressBooks={addressBooks} onSave={handleSaveNew} onCancel={handleCancel} />; return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} prefill={createPrefill} onSave={handleSaveNew} onCancel={handleCancel} />;
case "edit": case "edit":
if (!selectedContact) return null; if (!selectedContact) return null;
@@ -438,6 +687,7 @@ export default function ContactsPage() {
<ContactForm <ContactForm
contact={selectedContact} contact={selectedContact}
addressBooks={addressBooks} addressBooks={addressBooks}
allKeywords={allKeywords}
onSave={handleSaveEdit} onSave={handleSaveEdit}
onCancel={handleCancel} onCancel={handleCancel}
/> />
@@ -452,6 +702,7 @@ export default function ContactsPage() {
onEdit={handleEditGroup} onEdit={handleEditGroup}
onDelete={handleDeleteGroup} onDelete={handleDeleteGroup}
onRemoveMember={handleRemoveGroupMember} onRemoveMember={handleRemoveGroupMember}
onComposeGroup={(field) => handleComposeGroupFromSidebar(selectedGroup.id, field)}
isMobile={isMobile} isMobile={isMobile}
onSelectMember={(id) => { onSelectMember={(id) => {
setSelectedContact(id); setSelectedContact(id);
@@ -500,7 +751,7 @@ export default function ContactsPage() {
<button <button
key={group.id} key={group.id}
onClick={() => handleBulkAddToGroupConfirm(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"> <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" /> <Users className="w-4 h-4 text-primary" />
@@ -529,30 +780,65 @@ export default function ContactsPage() {
contact={selectedContact} contact={selectedContact}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
onCompose={
selectedContact
? () => handleComposeContact(selectedContact)
: undefined
}
onAddToGroup={
selectedContact
? () => handleAddContactToGroup(selectedContact.id)
: undefined
}
onDuplicate={
selectedContact
? () => void handleDuplicateContact(selectedContact)
: undefined
}
isMobile={isMobile} isMobile={isMobile}
/> />
); );
} }
}; };
if (!contactsEnabled) {
return (
<div className="flex h-dvh items-center justify-center bg-background p-6">
<div className="max-w-lg text-center space-y-3">
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
<p className="text-sm font-medium">Contacts feature is disabled by your administrator</p>
<p className="text-xs text-muted-foreground">Please contact your administrator if you need access.</p>
</div>
</div>
);
}
const showListPanel = !isMobile || view === "list"; const showListPanel = !isMobile || view === "list";
const showRightPanel = !isMobile || view !== "list"; const showRightPanel = !isMobile || view !== "list";
const mobileBackToList = () => { const mobileBackToList = () => {
if (returnToEmail) {
setReturnToEmail(false);
setCreatePrefill(undefined);
router.back();
return;
}
setView("list"); setView("list");
clearSelection(); clearSelection();
}; };
return ( return (
<div className="flex h-dvh bg-background overflow-hidden"> <div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
{/* Navigation Rail - desktop only */} <AppTopBannerSlot />
{!isMobile && ( <div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
{/* Navigation Rail - desktop only (hidden when embedded in Pro shell) */}
{!isMobile && !isEmbedded && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}> <div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail <NavigationRail
collapsed collapsed
quota={quota} quota={quota}
isPushConnected={isPushConnected} isPushConnected={isPushConnected}
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }} onLogout={logout}
onManageApps={handleManageApps} onManageApps={handleManageApps}
onInlineApp={handleInlineApp} onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp} onCloseInlineApp={closeInlineApp}
@@ -561,22 +847,37 @@ export default function ContactsPage() {
</div> </div>
)} )}
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0 min-h-0">
{inlineApp && ( {inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} /> <InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
)} )}
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}> <div className={cn("relative flex flex-1 min-h-0", inlineApp && "hidden")}>
{/* Narrow-pane backdrop for the overlay categories sidebar */}
{isNarrow && narrowSidebarOpen && (
<div
className={cn(
"inset-0 bg-black/50 z-40",
isEmbedded ? "absolute" : "fixed"
)}
onClick={() => setNarrowSidebarOpen(false)}
/>
)}
{showListPanel && ( {showListPanel && (
<> <>
{/* Panel 1: Categories sidebar */} {/* Panel 1: Categories sidebar (in-flow on desktop, overlay on narrow) */}
{!isMobile && ( {(!isMobile || isNarrow) && (
<> <>
<div <div
className={cn( className={cn(
"border-r border-border flex flex-col flex-shrink-0", "border-e border-border flex flex-col flex-shrink-0 bg-background",
!isSidebarResizing && "transition-[width] duration-300" !isSidebarResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
"transform transition-transform duration-300 ease-in-out",
!narrowSidebarOpen && "-translate-x-full"
)
)} )}
style={{ width: `${sidebarWidth}px` }} style={isNarrow ? undefined : { width: `${sidebarWidth}px` }}
> >
<ContactsSidebar <ContactsSidebar
groups={groups} groups={groups}
@@ -586,28 +887,57 @@ export default function ContactsPage() {
onSelectCategory={handleSelectCategory} onSelectCategory={handleSelectCategory}
onCreateGroup={handleCreateGroup} onCreateGroup={handleCreateGroup}
onCreateContact={handleCreateNew} onCreateContact={handleCreateNew}
onCreateAddressBook={client ? () => setCreatingAddressBook(true) : undefined}
onImport={() => setShowImportDialog(true)} onImport={() => setShowImportDialog(true)}
onEditGroup={handleEditGroupFromSidebar} onEditGroup={handleEditGroupFromSidebar}
onDeleteGroup={handleDeleteGroupFromSidebar} onDeleteGroup={handleDeleteGroupFromSidebar}
onComposeGroup={handleComposeGroupFromSidebar}
onDropContacts={handleDropContacts} onDropContacts={handleDropContacts}
onDropContactsToCategory={handleDropContactsToCategory}
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
onShareAddressBook={client ? (book) => setSharingAddressBookId(book.id) : undefined}
onCreateContactInBook={(book) => {
setDefaultBookIdForCreate(book.id);
handleCreateNew();
}}
onDeleteAddressBook={client ? async (book) => {
const ok = await confirmDialog({
title: t("address_books.delete"),
message: t("address_books.confirm_delete", { name: book.name }),
variant: "destructive",
confirmText: t("address_books.delete"),
});
if (!ok) return;
try {
await removeAddressBook(client, book);
toast.success(t("address_books.deleted"));
} catch {
toast.error(t("address_books.delete_failed"));
}
} : undefined}
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
/> />
</div> </div>
<ResizeHandle {!isNarrow && (
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }} <ResizeHandle
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))} onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
onResizeEnd={() => { onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
setIsSidebarResizing(false); onResizeEnd={() => {
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth)); setIsSidebarResizing(false);
}} localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }} }}
/> onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
/>
)}
</> </>
)} )}
{/* Panel 2: Contact list */} {/* Panel 2: Contact list */}
<div <div
data-tour="contacts-list"
className={cn( 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" : "", isMobile ? "w-full" : "",
!isListResizing && !isMobile && "transition-[width] duration-300" !isListResizing && !isMobile && "transition-[width] duration-300"
)} )}
@@ -620,7 +950,6 @@ export default function ContactsPage() {
onSearchChange={setSearchQuery} onSearchChange={setSearchQuery}
onSelectContact={handleSelectContact} onSelectContact={handleSelectContact}
onCreateNew={handleCreateNew} onCreateNew={handleCreateNew}
categoryLabel={categoryLabel}
className="flex-1" className="flex-1"
selectedContactIds={selectedContactIds} selectedContactIds={selectedContactIds}
onToggleSelection={toggleContactSelection} onToggleSelection={toggleContactSelection}
@@ -630,6 +959,10 @@ export default function ContactsPage() {
onBulkDelete={handleBulkDelete} onBulkDelete={handleBulkDelete}
onBulkAddToGroup={handleBulkAddToGroup} onBulkAddToGroup={handleBulkAddToGroup}
onBulkExport={handleBulkExport} onBulkExport={handleBulkExport}
onEditContact={handleEditContact}
onDeleteContact={handleDeleteContact}
onAddContactToGroup={handleAddContactToGroup}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
/> />
</div> </div>
@@ -641,7 +974,7 @@ export default function ContactsPage() {
setIsListResizing(false); setIsListResizing(false);
localStorage.setItem("contacts-list-width", String(listWidth)); localStorage.setItem("contacts-list-width", String(listWidth));
}} }}
onDoubleClick={() => { setListWidth(320); localStorage.setItem("contacts-list-width", "320"); }} onDoubleClick={() => { setListWidth(384); localStorage.setItem("contacts-list-width", "384"); }}
/> />
)} )}
</> </>
@@ -658,8 +991,8 @@ export default function ContactsPage() {
onClick={mobileBackToList} onClick={mobileBackToList}
className="touch-manipulation" className="touch-manipulation"
> >
<ArrowLeft className="w-4 h-4 mr-2" /> <ArrowLeft className="w-4 h-4 me-2" />
{t("back_to_mail")} {returnToEmail ? t("back_to_email") : t("back_to_contacts")}
</Button> </Button>
</div> </div>
)} )}
@@ -670,7 +1003,7 @@ export default function ContactsPage() {
)} )}
</div> </div>
{isMobile && ( {isMobile && !isEmbedded && (
<NavigationRail <NavigationRail
orientation="horizontal" orientation="horizontal"
onManageApps={handleManageApps} onManageApps={handleManageApps}
@@ -683,6 +1016,55 @@ export default function ContactsPage() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
{renamingKeyword !== null && (
<RenameDialog
currentName={renamingKeyword}
title={t("rename_category")}
label={t("category_name_label")}
onCancel={() => setRenamingKeyword(null)}
onConfirm={async (newName) => {
try {
await renameKeyword(supportsSync && client ? client : null, renamingKeyword, newName);
toast.success(t("category_renamed"));
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === renamingKeyword) {
setActiveCategory({ keyword: newName.trim() });
}
setRenamingKeyword(null);
} catch (err) {
console.error("Failed to rename category:", err);
toast.error(t("category_rename_failed"));
}
}}
/>
)}
{creatingAddressBook && (
<RenameDialog
currentName=""
title={t("address_books.create")}
label={t("address_books.name_label")}
onCancel={() => setCreatingAddressBook(false)}
onConfirm={handleCreateAddressBook}
/>
)}
{renamingAddressBook && (
<RenameDialog
currentName={renamingAddressBook.name}
title={t("address_books.rename")}
label={t("address_books.name_label")}
onCancel={() => setRenamingAddressBook(null)}
onConfirm={async (newName) => {
if (!client) return;
try {
await renameAddressBook(client, renamingAddressBook, newName);
toast.success(t("address_books.renamed"));
setRenamingAddressBook(null);
} catch (err) {
console.error("Failed to rename address book:", err);
toast.error(t("address_books.rename_failed"));
}
}}
/>
)}
{showImportDialog && ( {showImportDialog && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden"> <div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
@@ -694,6 +1076,24 @@ export default function ContactsPage() {
</div> </div>
</div> </div>
)} )}
{sharingAddressBookId && client && (() => {
const book = addressBooks.find((b) => b.id === sharingAddressBookId);
if (!book) return null;
return (
<ShareCollectionDialog
client={client}
kind="addressBook"
collectionName={book.name}
shareWith={book.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
}}
onClose={() => setSharingAddressBookId(null)}
/>
);
})()}
</div>
</div> </div>
); );
} }
@@ -38,11 +38,11 @@ export default function LocaleError({
</p> </p>
<div className="flex gap-3 justify-center"> <div className="flex gap-3 justify-center">
<Button variant="outline" onClick={() => router.push('/')}> <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")} {t("go_home")}
</Button> </Button>
<Button onClick={reset}> <Button onClick={reset}>
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className="w-4 h-4 me-2" />
{t("try_again")} {t("try_again")}
</Button> </Button>
</div> </div>
@@ -7,26 +7,37 @@ import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail"; import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view"; import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useIsMobile } from "@/hooks/use-media-query"; 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 { FileBrowser } from "@/components/files/file-browser";
import type { FileNodeRights } from "@/lib/jmap/types";
import { ImagePreviewModal } from "@/components/files/image-preview-modal"; import { ImagePreviewModal } from "@/components/files/image-preview-modal";
import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { loadFilesSettings } from "@/components/files/files-settings-dialog"; import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } 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, Loader2 } from "lucide-react";
export default function FilesPage() { export default function FilesPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations("files"); const t = useTranslations("files");
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore(); const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const getClientForAccount = useAuthStore((s) => s.getClientForAccount);
const accounts = useAccountStore((s) => s.accounts);
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore(); const { quota, isPushConnected } = useEmailStore();
@@ -38,14 +49,16 @@ export default function FilesPage() {
supportsFiles, supportsFiles,
selectedResources, selectedResources,
uploadProgress, uploadProgress,
migrationProgress,
clipboard, clipboard,
initClient, initClient,
checkSupport, checkSupport,
migrateLegacyFlatNodes,
navigate, navigate,
navigateByPath, navigateByPath,
refresh, refresh,
createDirectory, createDirectory,
uploadFile, uploadFile: _uploadFile,
uploadFiles, uploadFiles,
uploadFolder, uploadFolder,
deleteResource, deleteResource,
@@ -76,9 +89,11 @@ export default function FilesPage() {
cancelUpload, cancelUpload,
undoLastAction, undoLastAction,
lastAction, lastAction,
shareResource,
} = useFileStore(); } = useFileStore();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isEmbedded = useIsEmbedded();
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout); const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
const hasFetched = useRef(false); const hasFetched = useRef(false);
@@ -101,8 +116,15 @@ export default function FilesPage() {
const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null; const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null;
// Check auth on mount // Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => { useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => { checkAuth().finally(() => {
setInitialCheckDone(true); setInitialCheckDone(true);
}); });
@@ -112,31 +134,59 @@ export default function FilesPage() {
useEffect(() => { useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) { if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
router.push("/login"); redirectToLogin();
} }
}, [initialCheckDone, isAuthenticated, authLoading, router]); }, [initialCheckDone, isAuthenticated, authLoading]);
// Initialize JMAP files client // Initialize JMAP files client. In the Pro shell, all connected accounts
// are surfaced as top-level folders at the root, so we *don't* auto-attach
// to the active account - the user picks one explicitly.
useEffect(() => { useEffect(() => {
if (isAuthenticated && client && !hasFetched.current) { if (!isAuthenticated || !client || hasFetched.current) return;
hasFetched.current = true; hasFetched.current = true;
initClient(client); if (isEmbedded) {
useFileStore.getState().clearClient();
} else {
initClient(client, activeAccountId);
} }
}, [isAuthenticated, client, initClient]); }, [isAuthenticated, client, initClient, activeAccountId, isEmbedded]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh files via JMAP instead of reloading the page.
useRefreshGesture({
enabled: isAuthenticated && !!client && supportsFiles === true,
onRefresh: async () => {
await refresh();
},
});
// Check support and load root after client is initialized // Check support and load root after client is initialized
const storeClient = useFileStore(s => s.client); const storeClient = useFileStore(s => s.client);
useEffect(() => { useEffect(() => {
if (storeClient && supportsFiles === null) { if (storeClient && supportsFiles === null) {
checkSupport().then((supported) => { checkSupport().then(async (supported) => {
if (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); navigate(null);
} }
}); });
} }
}, [storeClient, supportsFiles, checkSupport, navigate]); }, [storeClient, supportsFiles, checkSupport, migrateLegacyFlatNodes, navigate]);
const handleNavigate = useCallback((path: string, resourceId?: string | null) => { const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
// Pro shell only: the Account breadcrumb segment signals "go to this
// account's filesystem root" via a sentinel, distinguishing it from a
// Home click (which detaches the account and returns to the picker).
if (resourceId === '__account_root__') {
void navigate(null);
return;
}
if (isEmbedded && path === '/' && resourceId === undefined) {
useFileStore.getState().clearClient();
return;
}
if (resourceId !== undefined) { if (resourceId !== undefined) {
// Direct ID-based navigation (directory click, breadcrumb dropdown folder) // Direct ID-based navigation (directory click, breadcrumb dropdown folder)
navigate(resourceId, path.split('/').pop() || ''); navigate(resourceId, path.split('/').pop() || '');
@@ -144,7 +194,7 @@ export default function FilesPage() {
// Path-based navigation (breadcrumbs, favorites, recent files) // Path-based navigation (breadcrumbs, favorites, recent files)
navigateByPath(path); navigateByPath(path);
} }
}, [navigate, navigateByPath]); }, [navigate, navigateByPath, isEmbedded]);
const handleCreateFolder = useCallback(async (name: string) => { const handleCreateFolder = useCallback(async (name: string) => {
try { try {
@@ -156,39 +206,43 @@ export default function FilesPage() {
} }
}, [createDirectory, t]); }, [createDirectory, t]);
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB const maxSizeUpload = client?.getMaxSizeUpload() || 0;
const handleUploadFiles = useCallback(async (files: File[]) => { const handleUploadFiles = useCallback(async (files: File[]) => {
const oversized = files.filter(f => f.size > MAX_FILE_SIZE); if (maxSizeUpload > 0) {
const valid = files.filter(f => f.size <= MAX_FILE_SIZE); const oversized = files.filter(f => f.size > maxSizeUpload);
if (oversized.length > 0) { files = files.filter(f => f.size <= maxSizeUpload);
toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" })); if (oversized.length > 0) {
toast.error(t("file_too_large", { name: oversized[0].name, max: formatFileSize(maxSizeUpload) }));
}
} }
if (valid.length === 0) return; if (files.length === 0) return;
try { try {
await uploadFiles(valid); await uploadFiles(files);
toast.success(t("upload_success", { count: valid.length })); toast.success(t("upload_success", { count: files.length }));
} catch (err) { } catch (err) {
console.error("Failed to upload files:", err); console.error("Failed to upload files:", err);
toast.error(t("upload_error")); toast.error(t("upload_error"));
} }
}, [uploadFiles, t]); }, [uploadFiles, t, maxSizeUpload]);
const handleUploadFolder = useCallback(async (files: File[]) => { const handleUploadFolder = useCallback(async (files: File[]) => {
const oversized = files.filter(f => f.size > MAX_FILE_SIZE); if (maxSizeUpload > 0) {
const valid = files.filter(f => f.size <= MAX_FILE_SIZE); const oversized = files.filter(f => f.size > maxSizeUpload);
if (oversized.length > 0) { files = files.filter(f => f.size <= maxSizeUpload);
toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" })); if (oversized.length > 0) {
toast.error(t("file_too_large", { name: oversized[0].name, max: formatFileSize(maxSizeUpload) }));
}
} }
if (valid.length === 0) return; if (files.length === 0) return;
try { try {
await uploadFolder(valid); await uploadFolder(files);
toast.success(t("upload_success", { count: valid.length })); toast.success(t("upload_success", { count: files.length }));
} catch (err) { } catch (err) {
console.error("Failed to upload folder:", err); console.error("Failed to upload folder:", err);
toast.error(t("upload_error")); toast.error(t("upload_error"));
} }
}, [uploadFolder, t]); }, [uploadFolder, t, maxSizeUpload]);
const handleDelete = useCallback(async (name: string) => { const handleDelete = useCallback(async (name: string) => {
const confirmed = await confirmDialog({ const confirmed = await confirmDialog({
@@ -347,17 +401,59 @@ export default function FilesPage() {
setShowDetails(v => !v); setShowDetails(v => !v);
}, []); }, []);
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.
const accountFolders = isEmbedded
? accounts
.filter((a) => a.isConnected)
.map((a) => ({
accountId: a.id,
label: a.label || a.email,
email: a.email,
avatarColor: a.avatarColor,
}))
: [];
const isAccountPicker = isEmbedded && currentFilesAccountId === null;
const currentAccountLabel = isEmbedded && currentFilesAccountId
? (accounts.find((a) => a.id === currentFilesAccountId)?.label
|| accounts.find((a) => a.id === currentFilesAccountId)?.email
|| null)
: null;
const handleSelectAccount = useCallback((accountId: string) => {
const nextClient = getClientForAccount(accountId);
if (!nextClient) return;
const store = useFileStore.getState();
store.initClient(nextClient, accountId);
// Reset supportsFiles so the existing checkSupport effect re-runs for
// the freshly-attached client and triggers the initial navigate(null).
useFileStore.setState({ supportsFiles: null });
}, [getClientForAccount]);
if (!isAuthenticated) return null; if (!isAuthenticated) return null;
return ( return (
<div className="flex h-dvh bg-background overflow-hidden"> <div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
{!isMobile && ( <AppTopBannerSlot />
<div className="flex flex-1 min-h-0 overflow-hidden">
{!isMobile && !isEmbedded && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}> <div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail <NavigationRail
collapsed collapsed
quota={quota} quota={quota}
isPushConnected={isPushConnected} isPushConnected={isPushConnected}
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }} onLogout={logout}
onManageApps={handleManageApps} onManageApps={handleManageApps}
onInlineApp={handleInlineApp} onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp} onCloseInlineApp={closeInlineApp}
@@ -372,7 +468,7 @@ export default function FilesPage() {
)} )}
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}> <div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
<div className="flex-1 min-w-0 flex flex-col"> <div className="flex-1 min-w-0 flex flex-col">
{folderLayout !== "sidebar" && ( {folderLayout !== "sidebar" && !isEmbedded && (
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}> <div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button <Button
@@ -381,19 +477,32 @@ export default function FilesPage() {
onClick={() => router.push("/")} onClick={() => router.push("/")}
className="justify-start" className="justify-start"
> >
<ArrowLeft className="w-4 h-4 mr-2" /> <ArrowLeft className="w-4 h-4 me-2" />
{t("title")} {t("title")}
</Button> </Button>
</div> </div>
</div> </div>
)} )}
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0 flex flex-col">
{supportsFiles === false ? ( {!filesEnabled ? (
<div className="flex items-center justify-center h-full">
<div className="max-w-lg text-center space-y-3 px-4">
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
<p className="text-sm font-medium">{t("disabled_title")}</p>
<p className="text-xs text-muted-foreground">{t("disabled_description")}</p>
</div>
</div>
) : supportsFiles === false ? (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">{t("not_available")}</p> <p className="text-sm text-muted-foreground">{t("not_available")}</p>
</div> </div>
) : ( ) : (
<div className="flex flex-col flex-1 min-h-0">
<div className="mx-4 mt-3 mb-1 flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 px-3 py-2">
<AlertTriangle className="w-4 h-4 text-yellow-500 shrink-0 mt-0.5" />
<p className="text-xs text-yellow-700 dark:text-yellow-400">{t("stability_warning")}</p>
</div>
<FileBrowser <FileBrowser
currentPath={currentPath} currentPath={currentPath}
resources={resources} resources={resources}
@@ -437,13 +546,22 @@ export default function FilesPage() {
showDetails={showDetails} showDetails={showDetails}
onToggleDetails={handleToggleDetails} onToggleDetails={handleToggleDetails}
detailResource={detailResource} detailResource={detailResource}
accountFolders={accountFolders}
onSelectAccount={handleSelectAccount}
accountPickerMode={isAccountPicker}
accountLabel={currentAccountLabel}
client={storeClient}
ownAccountId={filesAccountId}
sharingEnabled={sharingEnabled}
onShare={handleShare}
/> />
</div>
)} )}
</div> </div>
</div> </div>
</div> </div>
{isMobile && ( {isMobile && !isEmbedded && (
<NavigationRail <NavigationRail
orientation="horizontal" orientation="horizontal"
onManageApps={handleManageApps} onManageApps={handleManageApps}
@@ -474,8 +592,35 @@ 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} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
</div>
</div> </div>
); );
} }
+54
View File
@@ -0,0 +1,54 @@
import { notFound } from "next/navigation";
import { IntlProvider } from "@/components/providers/intl-provider";
import { ThemeProvider } from "@/components/providers/theme-provider";
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider";
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
import { TourProvider } from "@/components/tour/tour-provider";
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
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({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!(locales as readonly string[]).includes(locale)) notFound();
let messages;
try {
messages = (await import(`@/locales/${locale}/common.json`)).default;
} catch {
notFound();
}
return (
<IntlProvider locale={locale} messages={messages}>
<ThemeProvider>
<CalendarAlertProvider>
<RateLimitToastProvider>
<EmbeddedBridgeProvider>
<TourProvider>
<ProtocolLaunchHandlerProvider>
<ProInterfaceRedirect />
{children}
<PluginDialogHost />
<PluginConsentDialog />
<PWAInstallPrompt />
</ProtocolLaunchHandlerProvider>
</TourProvider>
</EmbeddedBridgeProvider>
</RateLimitToastProvider>
</CalendarAlertProvider>
</ThemeProvider>
</IntlProvider>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+403
View File
@@ -0,0 +1,403 @@
"use client";
import { useEffect, useMemo, useRef, useState, type ComponentType, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { EmbeddedContext } from "@/hooks/use-is-embedded";
import { PaneSizeContext } from "@/hooks/use-pane-size";
import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar";
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
import { cn } from "@/lib/utils";
import { getPathPrefix } from "@/lib/browser-navigation";
import MailPage from "@/app/(main)/[locale]/page";
import CalendarPage from "@/app/(main)/[locale]/calendar/page";
import ContactsPage from "@/app/(main)/[locale]/contacts/page";
import FilesPage from "@/app/(main)/[locale]/files/page";
import SettingsPage from "@/app/(main)/[locale]/settings/page";
import { ProComposeTabBody } from "@/components/pro/pro-compose-tab-body";
import { ProEmailTabBody } from "@/components/pro/pro-email-tab-body";
const APP_TAB_COMPONENTS: Partial<Record<ProTabKind, ComponentType>> = {
mail: MailPage,
calendar: CalendarPage,
contacts: ContactsPage,
files: FilesPage,
settings: SettingsPage,
};
type DropTarget = 'left' | 'right' | null;
function renderTabBody(tab: ProTab): React.ReactNode {
if (tab.kind === 'compose' && tab.composeData) {
return <ProComposeTabBody tabId={tab.id} data={tab.composeData} />;
}
if (tab.kind === 'email' && tab.emailData) {
return <ProEmailTabBody tabId={tab.id} data={tab.emailData} />;
}
const Component = APP_TAB_COMPONENTS[tab.kind];
return Component ? <Component /> : null;
}
interface PaneProps {
paneId: ProPaneId;
tabs: ProTab[];
activeTabId: string | null;
loadedTabIds: string[];
onPaneFocus: (paneId: ProPaneId) => void;
isFocused: boolean;
}
function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused }: PaneProps) {
const paneRef = useRef<HTMLDivElement | null>(null);
// Measured pane width, published to children via PaneSizeContext so that
// useDeviceDetection / useIsMobile / etc. branch on pane width - not full
// viewport - and inner pages collapse to their mobile/tablet layouts when
// the pane is narrow.
const [paneWidth, setPaneWidth] = useState<number | null>(null);
useEffect(() => {
const el = paneRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const initialRect = el.getBoundingClientRect();
if (initialRect.width > 0) setPaneWidth(initialRect.width);
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const w = entry.contentRect.width;
setPaneWidth((prev) => (prev !== null && Math.abs(prev - w) < 0.5 ? prev : w));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<div
ref={paneRef}
className="relative flex flex-1 flex-col overflow-hidden min-w-0 min-h-0"
onMouseDownCapture={() => { if (!isFocused) onPaneFocus(paneId); }}
>
<PaneSizeContext.Provider value={paneWidth}>
{tabs
.filter((tab) => loadedTabIds.includes(tab.id))
.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")}
aria-hidden={!isActive}
>
{renderTabBody(tab)}
</div>
);
})}
</PaneSizeContext.Provider>
</div>
);
}
export default function ProHome() {
const t = useTranslations();
const { isMobile, isTablet, isDesktop } = useDeviceDetection();
const [initialCheckDone, setInitialCheckDone] = useState(
() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client
);
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
const {
showAppsModal,
inlineApp,
loadedApps,
handleManageApps,
handleInlineApp,
closeInlineApp,
closeAppsModal,
} = useSidebarApps();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const client = useAuthStore((s) => s.client);
const logout = useAuthStore((s) => s.logout);
const checkAuth = useAuthStore((s) => s.checkAuth);
const authLoading = useAuthStore((s) => s.isLoading);
const quota = useEmailStore((s) => s.quota);
const isPushConnected = useEmailStore((s) => s.isPushConnected);
const proInterface = useSettingsStore((s) => s.proInterface);
const tabs = useProTabStore((s) => s.tabs);
const activeMainTabId = useProTabStore((s) => s.activeTabId);
const activeSplitTabId = useProTabStore((s) => s.activeSplitTabId);
const splitOrientation = useProTabStore((s) => s.splitOrientation);
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
const openTab = useProTabStore((s) => s.openTab);
const requestCloseTab = useProTabStore((s) => s.requestCloseTab);
const setActiveTab = useProTabStore((s) => s.setActiveTab);
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
const [isTabDragging, setIsTabDragging] = useState(false);
const [splitDropTarget, setSplitDropTarget] = useState<DropTarget>(null);
/** Whether the split pane visually renders before (true) or after (false) main. */
const [splitLeading, setSplitLeading] = useState(false);
// Auth bootstrap (mirrors standard page)
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
redirectToLogin();
}
}, [initialCheckDone, isAuthenticated, authLoading]);
useEffect(() => {
if (!initialCheckDone || typeof window === "undefined") return;
// Pro is desktop-only, and only used when the user has explicitly
// enabled it. If either precondition stops holding, hand the user back
// to the standard shell.
if (isMobile || isTablet || !proInterface) {
window.location.replace(`${getPathPrefix()}/`);
}
}, [initialCheckDone, isMobile, isTablet, proInterface]);
const mainTabs = useMemo(() => tabs.filter((t) => t.paneId === 'main'), [tabs]);
const splitTabs = useMemo(() => tabs.filter((t) => t.paneId === 'split'), [tabs]);
const focusedActiveTab = useMemo(() => {
const id = focusedPaneId === 'main' ? activeMainTabId : activeSplitTabId;
return tabs.find((t) => t.id === id) ?? null;
}, [tabs, focusedPaneId, activeMainTabId, activeSplitTabId]);
const handleRailNavigate = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => {
openTab(itemId);
return true;
};
const railActiveItemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings' | null =
focusedActiveTab && (
focusedActiveTab.kind === 'mail' || focusedActiveTab.kind === 'calendar'
|| focusedActiveTab.kind === 'contacts' || focusedActiveTab.kind === 'files'
|| focusedActiveTab.kind === 'settings'
) ? focusedActiveTab.kind : null;
const isSplit = splitOrientation !== null && splitTabs.length > 0;
// ---- Body-level drop targets ----
const isProTabDrag = (e: DragEvent) => e.dataTransfer.types.includes(PRO_TAB_DRAG_MIME);
const computeDropTarget = (e: DragEvent<HTMLDivElement>): DropTarget => {
const rect = e.currentTarget.getBoundingClientRect();
const xFrac = (e.clientX - rect.left) / rect.width;
return xFrac < 0.5 ? 'left' : 'right';
};
const targetPaneFromDrop = (target: DropTarget): ProPaneId | null => {
if (!target || !isSplit) return null;
const leftIsSplit = splitLeading;
if (target === 'left') return leftIsSplit ? 'split' : 'main';
return leftIsSplit ? 'main' : 'split';
};
const handleBodyDragOver = (e: DragEvent<HTMLDivElement>) => {
if (!isProTabDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const next = computeDropTarget(e);
if (next !== splitDropTarget) setSplitDropTarget(next);
};
const handleBodyDragLeave = (e: DragEvent<HTMLDivElement>) => {
const next = e.relatedTarget as Node | null;
if (next && e.currentTarget.contains(next)) return;
setSplitDropTarget(null);
};
const handleBodyDrop = (e: DragEvent<HTMLDivElement>) => {
if (!isProTabDrag(e)) return;
const target = computeDropTarget(e);
setSplitDropTarget(null);
setIsTabDragging(false);
if (!target) return;
e.preventDefault();
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
if (!draggedId) return;
if (isSplit) {
// Move tab to whichever pane occupies the dropped side.
const destPane = targetPaneFromDrop(target);
if (destPane) moveTabToPane(draggedId, destPane);
return;
}
// Create a new side-by-side split. `splitLeading` controls which side
// visually hosts the split pane.
moveTabToPane(draggedId, 'split', 'vertical');
setSplitLeading(target === 'left');
};
// Loading state (matches standard page exactly)
if (!initialCheckDone || authLoading || !isAuthenticated || !client) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-foreground mx-auto"></div>
<p className="mt-4 text-sm text-muted-foreground">{t("common.loading")}</p>
</div>
</div>
);
}
if (!isDesktop) return null;
// Stable keys are essential: when the split collapses, the row's child
// list goes from [splitPane, divider, mainPane] (or the leading variant)
// to [mainPane]. Without keys, React would reuse the Pane instance at
// index 0 - repurposing the *split* pane's instance into the main pane,
// which strands the main pane's ResizeObserver/paneWidth on a now-
// unmounted DOM node and reparents the mail tab body (causing remount
// + stale "still-narrow" measurements after the split is closed).
const mainPane = (
<Pane
key="pane-main"
paneId="main"
tabs={mainTabs}
activeTabId={activeMainTabId}
loadedTabIds={loadedTabIds}
onPaneFocus={setFocusedPane}
isFocused={focusedPaneId === 'main'}
/>
);
const splitPane = isSplit ? (
<Pane
key="pane-split"
paneId="split"
tabs={splitTabs}
activeTabId={activeSplitTabId}
loadedTabIds={loadedTabIds}
onPaneFocus={setFocusedPane}
isFocused={focusedPaneId === 'split'}
/>
) : null;
const splitDivider = isSplit ? (
<div
key="pane-divider"
aria-hidden="true"
className="flex-shrink-0 w-px bg-transparent"
style={{ borderLeft: '1px solid rgba(128, 128, 128, 0.3)' }}
/>
) : null;
// Drop-zone overlay: a single half-body preview of where the dragged tab
// would land. The whole body is always a drop target (the entire surface
// maps to one of the four sides), so we only render the active side.
const dropZone = isTabDragging && splitDropTarget ? (
<DropZone side={splitDropTarget} />
) : null;
return (
<EmbeddedContext.Provider value={true}>
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<div className="flex flex-1 overflow-hidden">
{/* Leftmost Navigation Rail - identical to the standard layout */}
<div
className="w-14 bg-secondary flex flex-col flex-shrink-0"
style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}
>
<NavigationRail
collapsed
quota={quota}
isPushConnected={isPushConnected}
onLogout={logout}
onShowShortcuts={() => setShowShortcutsModal(true)}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
onNavigate={handleRailNavigate}
activeItemId={railActiveItemId}
/>
</div>
{inlineApp && (
<InlineAppView
apps={loadedApps}
activeAppId={inlineApp.id}
onClose={closeInlineApp}
className="flex-1"
/>
)}
{!inlineApp && (
<div className="flex flex-1 flex-col overflow-hidden min-w-0">
{/* Single, unified tab bar above both panes. */}
<ProTabBar
tabs={tabs}
activeMainTabId={activeMainTabId}
activeSplitTabId={activeSplitTabId}
onActivate={setActiveTab}
onClose={requestCloseTab}
onDragStateChange={setIsTabDragging}
/>
{/* Panes container - accepts body drops for split/move. */}
<div
className="relative flex flex-row flex-1 overflow-hidden min-w-0"
onDragOver={handleBodyDragOver}
onDragLeave={handleBodyDragLeave}
onDrop={handleBodyDrop}
>
{isSplit
? (splitLeading
? <>{splitPane}{splitDivider}{mainPane}</>
: <>{mainPane}{splitDivider}{splitPane}</>)
: mainPane}
{dropZone}
</div>
</div>
)}
</div>
<KeyboardShortcutsModal
isOpen={showShortcutsModal}
onClose={() => setShowShortcutsModal(false)}
/>
{showAppsModal && (
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
)}
</div>
</EmbeddedContext.Provider>
);
}
function DropZone({ side }: { side: 'left' | 'right' }) {
return (
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute top-0 bottom-0 w-1/2 z-10",
"bg-primary/15 ring-2 ring-primary/40 ring-inset",
side === 'left' ? "left-0" : "right-0",
)}
/>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,259 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
interface Props {
value: JmapServerEntry[];
source?: string;
onChange: (next: JmapServerEntry[]) => void;
onRevert: () => void;
}
interface RowDraft {
id: string;
label: string;
url: string;
domains: string;
oauthClientId: string;
oauthIssuerUrl: string;
oauthClientSecret: string;
oauthExpanded: boolean;
}
function entryToDraft(e: JmapServerEntry): RowDraft {
return {
id: e.id,
label: e.label,
url: e.url,
domains: (e.domains ?? []).join(', '),
oauthClientId: e.oauth?.clientId ?? '',
oauthIssuerUrl: e.oauth?.issuerUrl ?? '',
oauthClientSecret: e.oauth?.clientSecret ?? '',
oauthExpanded: !!(e.oauth && (e.oauth.clientId || e.oauth.issuerUrl || e.oauth.clientSecret)),
};
}
function draftToEntry(d: RowDraft): JmapServerEntry | null {
const id = d.id.trim();
const url = d.url.trim().replace(/\/+$/, '');
if (!id || !url) return null;
const domains = d.domains
.split(/[,\s]+/)
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
const clientId = d.oauthClientId.trim();
const issuerUrl = d.oauthIssuerUrl.trim().replace(/\/+$/, '');
const clientSecret = d.oauthClientSecret;
const oauth = clientId || issuerUrl || clientSecret
? {
...(clientId ? { clientId } : {}),
...(issuerUrl ? { issuerUrl } : {}),
...(clientSecret ? { clientSecret } : {}),
}
: undefined;
return {
id,
label: d.label.trim() || id,
url,
...(domains.length > 0 ? { domains } : {}),
...(oauth ? { oauth } : {}),
};
}
function emptyDraft(): RowDraft {
return {
id: '',
label: '',
url: '',
domains: '',
oauthClientId: '',
oauthIssuerUrl: '',
oauthClientSecret: '',
oauthExpanded: false,
};
}
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
const lastEmittedRef = useRef(value);
useEffect(() => {
if (value === lastEmittedRef.current) return;
setDrafts(value.map(entryToDraft))
}, [value]);
function commit(next: RowDraft[]) {
setDrafts(next);
const entries = next.map(draftToEntry).filter((e): e is JmapServerEntry => e !== null);
lastEmittedRef.current = entries;
onChange(entries);
}
function update(idx: number, patch: Partial<RowDraft>) {
commit(drafts.map((d, i) => (i === idx ? { ...d, ...patch } : d)));
}
function remove(idx: number) {
commit(drafts.filter((_, i) => i !== idx));
}
function add() {
setDrafts((prev) => [...prev, emptyDraft()]);
// Don't commit yet - new row needs id+url before it counts.
}
const ids = new Set<string>();
const duplicateIdx = new Set<number>();
drafts.forEach((d, i) => {
const id = d.id.trim();
if (!id) return;
if (ids.has(id)) duplicateIdx.add(i);
ids.add(id);
});
return (
<div className="px-4 py-3 space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">Servers</span>
{source && source !== 'default' && (
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
{source}
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">
Each entry appears as an option on the login dropdown. Leave the list empty to fall back to the single <code className="text-[11px]">JMAP Server URL</code> above.
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{source === 'admin' && (
<button
onClick={onRevert}
className="text-muted-foreground hover:text-foreground"
title="Revert to default"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={add}
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-xs text-foreground hover:bg-muted transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Add server
</button>
</div>
</div>
{drafts.length === 0 && (
<div className="text-xs text-muted-foreground italic">No servers configured.</div>
)}
{drafts.map((d, i) => {
const isDuplicate = duplicateIdx.has(i);
return (
<div key={i} className="rounded-md border border-border bg-muted/20 p-3 space-y-2">
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 items-start">
<div className="sm:col-span-3">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">ID</label>
<input
type="text"
value={d.id}
onChange={(e) => update(i, { id: e.target.value })}
placeholder="main"
className={`h-8 w-full rounded-md border bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${isDuplicate ? 'border-destructive' : 'border-input'}`}
/>
{isDuplicate && <p className="text-[10px] text-destructive mt-0.5">Duplicate id</p>}
</div>
<div className="sm:col-span-4">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Label</label>
<input
type="text"
value={d.label}
onChange={(e) => update(i, { label: e.target.value })}
placeholder="Main server"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="sm:col-span-5">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">JMAP URL</label>
<div className="flex items-center gap-2">
<input
type="url"
value={d.url}
onChange={(e) => update(i, { url: e.target.value })}
placeholder="https://mail.example.com"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<button
onClick={() => remove(i)}
className="shrink-0 text-muted-foreground hover:text-destructive"
title="Remove server"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
Email domains (comma-separated, used for auto-pick)
</label>
<input
type="text"
value={d.domains}
onChange={(e) => update(i, { domains: e.target.value })}
placeholder="example.com, example.org"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<button
onClick={() => update(i, { oauthExpanded: !d.oauthExpanded })}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
type="button"
>
{d.oauthExpanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
Per-server OAuth (optional, overrides global)
</button>
{d.oauthExpanded && (
<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
type="text"
value={d.oauthClientId}
onChange={(e) => update(i, { oauthClientId: e.target.value })}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Issuer URL</label>
<input
type="url"
value={d.oauthIssuerUrl}
onChange={(e) => update(i, { oauthIssuerUrl: e.target.value })}
placeholder="https://auth.example.com"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client Secret</label>
<input
type="password"
value={d.oauthClientSecret}
onChange={(e) => update(i, { oauthClientSecret: e.target.value })}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
</div>
)}
</div>
);
})}
</div>
);
}
+385
View File
@@ -0,0 +1,385 @@
'use client';
import { useEffect, useState } from 'react';
import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry {
// Sensitive keys (sessionSecret, oauthClientSecret) come back with
// `value` omitted and `hasValue` set instead - the server never echoes
// the raw secret to the client.
value?: unknown;
source: 'admin' | 'env' | 'default';
hasValue?: boolean;
}
export function AuthTab() {
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
const [edits, setEdits] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => { fetchConfig(); }, []);
async function fetchConfig() {
setLoading(true);
const res = await apiFetch('/api/admin/config');
if (res.ok) setConfig(await res.json());
setLoading(false);
}
function handleChange(key: string, value: unknown) {
setEdits(prev => ({ ...prev, [key]: value }));
setMessage(null);
}
function currentValue(key: string): unknown {
if (key in edits) return edits[key];
return config[key]?.value;
}
async function handleSave() {
if (Object.keys(edits).length === 0) return;
setSaving(true);
setMessage(null);
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Authentication settings saved.' });
setEdits({});
await fetchConfig();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save' });
}
setSaving(false);
}
async function handleRevert(key: string) {
const res = await apiFetch('/api/admin/config', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
if (res.ok) {
setEdits(prev => { const next = { ...prev }; delete next[key]; return next; });
await fetchConfig();
}
}
const [setupRunning, setSetupRunning] = useState(false);
const [setupOpen, setSetupOpen] = useState(false);
const [setupOrigin, setSetupOrigin] = useState('');
const [setupIssuer, setSetupIssuer] = useState('');
const [setupOauthOnly, setSetupOauthOnly] = useState(false);
function openSetupDialog() {
if (typeof window === 'undefined') return;
const origin = window.location.origin;
const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || '';
setSetupOrigin(origin);
setSetupIssuer(jmapUrl || origin);
setSetupOauthOnly(currentValue('oauthOnly') === true);
setSetupOpen(true);
}
async function handleAutoSetup() {
setSetupRunning(true);
setMessage(null);
try {
const res = await apiFetch('/api/admin/oauth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
origin: setupOrigin.trim().replace(/\/+$/, ''),
issuerUrl: setupIssuer.trim().replace(/\/+$/, ''),
oauthOnly: setupOauthOnly,
}),
});
const data = await res.json();
if (res.ok) {
setMessage({
type: 'success',
text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`,
});
setEdits({});
setSetupOpen(false);
await fetchConfig();
} else {
const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : '';
setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail });
}
} catch (err) {
setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' });
} finally {
setSetupRunning(false);
}
}
const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, ''));
const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, ''));
const hasEdits = Object.keys(edits).length > 0;
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Authentication</h1>
<p className="text-sm text-muted-foreground mt-1">OAuth, SSO, and session configuration</p>
</div>
{hasEdits && (
<button
onClick={handleSave}
disabled={saving}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save changes
</button>
)}
</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}
</div>
)}
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary shrink-0" />
<h3 className="text-sm font-medium text-foreground">Auto-configure OAuth (Stalwart)</h3>
</div>
<p className="text-xs text-muted-foreground mt-1">
Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here.
Requires your Stalwart account to have admin permissions.
</p>
</div>
<button
onClick={openSetupDialog}
disabled={setupRunning}
className="shrink-0 inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{setupRunning ? 'Configuring…' : 'Set up automagically'}
</button>
</div>
</div>
{setupOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-labelledby="oauth-setup-title"
onClick={(e) => { if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
>
<div className="w-full max-w-md rounded-lg border border-border bg-background shadow-xl">
<div className="px-5 py-4 border-b border-border">
<h3 id="oauth-setup-title" className="text-base font-medium text-foreground">Auto-configure OAuth</h3>
<p className="text-xs text-muted-foreground mt-1">
Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
</p>
</div>
<div className="px-5 py-4 space-y-4">
<div>
<label htmlFor="setup-origin" className="block text-xs font-medium text-foreground mb-1">
Webmail origin
</label>
<input
id="setup-origin"
type="url"
value={setupOrigin}
onChange={(e) => setSetupOrigin(e.target.value)}
disabled={setupRunning}
placeholder="https://webmail.example.com"
className="w-full h-9 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"
/>
<p className="text-[11px] text-muted-foreground mt-1">
Used to register redirect URIs (one per locale: <code>{setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/&lt;locale&gt;/auth/callback</code>) on Stalwart.
</p>
{!setupOriginValid && setupOrigin.length > 0 && (
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
)}
</div>
<div>
<label htmlFor="setup-issuer" className="block text-xs font-medium text-foreground mb-1">
Stalwart issuer URL
</label>
<input
id="setup-issuer"
type="url"
value={setupIssuer}
onChange={(e) => setSetupIssuer(e.target.value)}
disabled={setupRunning}
placeholder="https://mail.example.com"
className="w-full h-9 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"
/>
<p className="text-[11px] text-muted-foreground mt-1">
Where Stalwart serves <code>/.well-known/oauth-authorization-server</code>. Saved as <code>OAUTH_ISSUER_URL</code>. Pre-filled from your JMAP server URL.
</p>
{!setupIssuerValid && setupIssuer.length > 0 && (
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
)}
</div>
<label className="inline-flex items-center gap-2 text-xs text-foreground select-none cursor-pointer">
<input
type="checkbox"
checked={setupOauthOnly}
onChange={(e) => setSetupOauthOnly(e.target.checked)}
className="h-3.5 w-3.5 rounded border-input"
disabled={setupRunning}
/>
Also enable OAuth only (hide password login)
</label>
</div>
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2 bg-muted/30 rounded-b-lg">
<button
onClick={() => setSetupOpen(false)}
disabled={setupRunning}
className="h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
>
Cancel
</button>
<button
onClick={handleAutoSetup}
disabled={setupRunning || !setupOriginValid || !setupIssuerValid}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{setupRunning ? 'Configuring…' : 'Configure'}
</button>
</div>
</div>
</div>
)}
<Section title="OAuth / OpenID Connect">
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved - type to replace)' : undefined} />
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
<Toggle label="Allow private OAuth endpoints" description="Permit discovery to resolve to RFC-1918 / loopback hosts. Enable only for split-DNS deployments where the mail server's public hostname resolves to an internal IP." configKey="oauthAllowPrivateEndpoints" value={currentValue('oauthAllowPrivateEndpoints') as boolean} source={config.oauthAllowPrivateEndpoints?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Scopes" description="Space-separated scopes that replace the defaults. Leave blank to use the built-in scope list." configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="openid email offline_access" />
<Text label="OAuth Extra Scopes" description="Additional space-separated scopes appended to the defaults." configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." />
</Section>
<Section title="Single Sign-On">
<Toggle label="Auto SSO" description="Automatically redirect to SSO provider on load" configKey="autoSsoEnabled" value={currentValue('autoSsoEnabled') as boolean} source={config.autoSsoEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
</Section>
<Section title="Session & Security">
<Select label="Cookie SameSite" configKey="cookieSameSite" value={currentValue('cookieSameSite') as string} source={config.cookieSameSite?.source} options={['lax', 'strict', 'none']} onChange={handleChange} onRevert={handleRevert} />
<Text label="Allowed Frame Ancestors" configKey="allowedFrameAncestors" value={currentValue('allowedFrameAncestors') as string} source={config.allowedFrameAncestors?.source} onChange={handleChange} onRevert={handleRevert} placeholder="'none' or https://..." />
<Text label="Parent Origin" description="For embedded mode communication" configKey="parentOrigin" value={currentValue('parentOrigin') as string} source={config.parentOrigin?.source} onChange={handleChange} onRevert={handleRevert} />
</Section>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<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">{title}</h2>
</div>
<div className="divide-y divide-border">{children}</div>
</div>
);
}
function SourceBadge({ source }: { source?: string }) {
if (!source || source === 'default') return null;
return (
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
{source}
</span>
);
}
function Text({ label, description, configKey, value, source, onChange, onRevert, placeholder, type = 'text' }: {
label: string; description?: string; configKey: string; value: string; source?: string;
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; placeholder?: string; type?: string;
}) {
return (
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input type={type} value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} placeholder={placeholder}
className="h-8 w-full sm: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" />
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
)}
</div>
</div>
);
}
function Toggle({ label, description, configKey, value, source, onChange, onRevert }: {
label: string; description?: string; configKey: string; value: boolean; source?: string;
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
}) {
return (
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => onChange(configKey, !value)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
)}
</div>
</div>
);
}
function Select({ label, configKey, value, source, options, onChange, onRevert }: {
label: string; configKey: string; value: string; source?: string; options: string[];
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
}) {
return (
<div 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">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
<div className="flex items-center gap-2 shrink-0">
<select value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{options.map(o => <option key={o} value={o}>{o}</option>)}
</select>
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
)}
</div>
</div>
);
}
+721
View File
@@ -0,0 +1,721 @@
'use client';
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;
source: 'admin' | 'env' | 'default';
hasValue?: boolean;
}
const IMAGE_FIELDS = [
{ key: 'faviconUrl', label: 'Favicon', accept: '.svg,.png,.ico,.webp' },
{ key: 'appLogoLightUrl', label: 'App Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
{ 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, 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');
if (res.ok) setConfig(await res.json());
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];
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(payload),
});
if (res.ok) {
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 {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save' });
}
setSaving(false);
}
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',
body: formData,
});
if (res.ok) {
const data = await res.json();
setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` });
setEdits(prev => {
const next = { ...prev };
delete next[slot];
return next;
});
// 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' });
}
setUploading(null);
}
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(body),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Uploaded file removed. Reverted to default.' });
setEdits(prev => {
const next = { ...prev };
delete next[slot];
return next;
});
await fetchConfig();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to remove' });
}
}
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' },
body: JSON.stringify({ key }),
});
if (res.ok) {
setEdits(prev => {
const next = { ...prev };
delete next[key];
return next;
});
await fetchConfig();
}
}
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>;
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Branding</h1>
<p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p>
</div>
{hasEdits && (
<button
onClick={handleSave}
disabled={saving}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save changes
</button>
)}
</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}
</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">Images & Logos</h2>
<p className="text-xs text-muted-foreground mt-0.5">Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)</p>
</div>
<div className="divide-y divide-border">
{IMAGE_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3">
<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>
{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' : selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
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
ref={el => { fileInputRefs.current[field.key] = el; }}
type="file"
accept={field.accept}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(field.key, file);
e.target.value = '';
}}
/>
<button
onClick={() => fileInputRefs.current[field.key]?.click()}
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={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>
{isUploadedFile(field.key) && (
<button
onClick={() => handleDeleteUpload(field.key)}
className="text-muted-foreground hover:text-destructive transition-colors"
title="Remove uploaded file"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{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>
)}
</div>
</div>
{currentValue(field.key) && (
<div className="mt-2 flex items-center gap-2">
<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={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'; }}
/>
</div>
</div>
)}
</div>
))}
</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">Progressive Web App</h2>
<p className="text-xs text-muted-foreground mt-0.5">Shown when users install the webmail to their home screen. Leave fields blank to fall back to the favicon and app name.</p>
</div>
<div className="divide-y divide-border">
{PWA_IMAGE_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3">
<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>
{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' : selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
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
ref={el => { fileInputRefs.current[field.key] = el; }}
type="file"
accept={field.accept}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(field.key, file);
e.target.value = '';
}}
/>
<button
onClick={() => fileInputRefs.current[field.key]?.click()}
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={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>
{isUploadedFile(field.key) && (
<button
onClick={() => handleDeleteUpload(field.key)}
className="text-muted-foreground hover:text-destructive transition-colors"
title="Remove uploaded file"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{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>
)}
</div>
</div>
{currentValue(field.key) && (
<div className="mt-2 flex items-center gap-2">
<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={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'; }}
/>
</div>
</div>
)}
</div>
))}
{PWA_TEXT_FIELDS.map(field => (
<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>
{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">
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
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"
/>
{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>
)}
</div>
</div>
))}
{PWA_COLOR_FIELDS.map(field => {
const value = currentValue(field.key) || field.defaultValue;
return (
<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>
{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">
<input
type="color"
value={/^#[0-9a-fA-F]{6}$/.test(value) ? value : field.defaultValue}
onChange={(e) => handleChange(field.key, e.target.value)}
className="h-8 w-10 cursor-pointer rounded-md border border-input bg-background p-0.5"
title="Pick a color"
/>
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
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"
/>
{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>
)}
</div>
</div>
);
})}
</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">Company Information</h2>
</div>
<div className="divide-y divide-border">
{TEXT_FIELDS.map(field => (
<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>
{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">
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
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"
/>
{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>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
'use client';
import { useEffect, useState } from 'react';
import { AlertTriangle } from 'lucide-react';
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
import type { AuditEntry } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
interface AdminStatus {
enabled: boolean;
authenticated: boolean;
lastLogin: string | null;
passwordChangedAt: string | null;
}
interface ConfigData {
appName?: string;
jmapServerUrl?: string;
settingsSyncEnabled?: boolean;
stalwartFeaturesEnabled?: boolean;
oauthEnabled?: boolean;
devMode?: boolean;
}
export function DashboardTab() {
const [status, setStatus] = useState<AdminStatus | null>(null);
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
const [config, setConfig] = useState<ConfigData | null>(null);
const [, setConfigSources] = useState<Record<string, { value?: unknown; source: string; hasValue?: boolean }> | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
const [pluginCount, setPluginCount] = useState(0);
const [themeCount, setThemeCount] = useState(0);
const [policyRuleCount, setPolicyRuleCount] = useState(0);
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
useEffect(() => {
fetchDashboardData();
}, []);
async function fetchDashboardData() {
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes, telemetryRes] = await Promise.all([
apiFetch('/api/admin/auth'),
apiFetch('/api/admin/audit?limit=10'),
apiFetch('/api/config'),
apiFetch('/api/admin/config'),
apiFetch('/api/admin/plugins').catch(() => null),
apiFetch('/api/admin/themes').catch(() => null),
apiFetch('/api/admin/policy').catch(() => null),
apiFetch('/api/admin/telemetry').catch(() => null),
]);
if (statusRes.ok) setStatus(await statusRes.json());
if (auditRes.ok) {
const data = await auditRes.json();
setRecentActivity(data.entries || []);
}
let configData: ConfigData | null = null;
if (configRes.ok) {
configData = await configRes.json();
setConfig(configData);
}
if (pluginRes?.ok) {
const plugins = await pluginRes.json();
setPluginCount(Array.isArray(plugins) ? plugins.length : 0);
}
if (themeRes?.ok) {
const themes = await themeRes.json();
setThemeCount(Array.isArray(themes) ? themes.length : 0);
}
if (policyRes?.ok) {
const policy = await policyRes.json();
const restrictionCount = policy.restrictions ? Object.keys(policy.restrictions).length : 0;
const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0;
setPolicyRuleCount(restrictionCount + disabledGates);
}
if (telemetryRes?.ok) {
const telemetry = await telemetryRes.json();
if (telemetry.accountCounts && typeof telemetry.accountCounts.total === 'number') {
setAccountCounts(telemetry.accountCounts);
}
}
const w: string[] = [];
if (adminConfigRes.ok) {
const sources = await adminConfigRes.json();
setConfigSources(sources);
const sessionSecret = sources?.sessionSecret;
// Server redacts the raw value for sensitive keys; rely on hasValue,
// which is false when unset or matching a known placeholder default.
if (!sessionSecret?.hasValue) {
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
}
const adminPassword = sources?.adminPassword;
if (adminPassword?.value && adminPassword.source === 'env') {
w.push('ADMIN_PASSWORD is still set in environment variables. Remove it now that the hash is stored securely.');
}
}
setWarnings(w);
}
const jmapUrl = config?.jmapServerUrl || '-';
const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-';
return (
<div className="max-w-3xl space-y-8">
{warnings.map((msg, i) => (
<div key={i} className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">{msg}</p>
</div>
))}
<SettingsSection title="Server" description="Application and connection details">
<SettingItem label="Application">
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
</SettingItem>
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
<span className="text-sm text-foreground">{jmapHostname}</span>
</SettingItem>
<SettingItem label="Last Login">
<span className="text-sm text-foreground">
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
</span>
</SettingItem>
</SettingsSection>
<SettingsSection title="Features" description="Enabled integrations and modules">
<SettingItem label="Admin Panel" description="Administrative access to server configuration">
<ToggleSwitch checked={!!status?.enabled} onChange={() => {}} disabled />
</SettingItem>
<SettingItem label="Settings Sync" description="Synchronize user settings across devices">
<ToggleSwitch checked={!!config?.settingsSyncEnabled} onChange={() => {}} disabled />
</SettingItem>
<SettingItem label="OAuth" description="OAuth authentication provider">
<ToggleSwitch checked={!!config?.oauthEnabled} onChange={() => {}} disabled />
</SettingItem>
<SettingItem label="Stalwart Integration" description="Stalwart mail server features">
<ToggleSwitch checked={config?.stalwartFeaturesEnabled !== false} onChange={() => {}} disabled />
</SettingItem>
</SettingsSection>
<SettingsSection title="Accounts" description="Unique logins recorded over the last 90 days">
<SettingItem label="Total accounts" description="Distinct identities seen in the retention window">
<span className="text-sm text-foreground">{accountCounts?.total ?? '-'}</span>
</SettingItem>
<SettingItem label="Active in last 7 days" description="Identities with a login in the past week">
<span className="text-sm text-foreground">{accountCounts?.active7d ?? '-'}</span>
</SettingItem>
</SettingsSection>
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
<SettingItem label="Plugins">
<span className="text-sm text-foreground">{pluginCount}</span>
</SettingItem>
<SettingItem label="Themes">
<span className="text-sm text-foreground">{themeCount}</span>
</SettingItem>
<SettingItem label="Policy Rules">
<span className="text-sm text-foreground">{policyRuleCount}</span>
</SettingItem>
</SettingsSection>
<SettingsSection title="Recent Activity" description="Latest administrative actions">
{recentActivity.length === 0 ? (
<div className="py-4 text-sm text-muted-foreground">
No activity recorded yet
</div>
) : (
recentActivity.map((entry, i) => (
<SettingItem
key={i}
label={entry.action}
description={formatDetail(entry.detail) || undefined}
>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{entry.ip}</span>
<span>{new Date(entry.ts).toLocaleString()}</span>
</div>
</SettingItem>
))
)}
</SettingsSection>
</div>
);
}
function formatDetail(detail: Record<string, unknown>): string {
if (!detail || Object.keys(detail).length === 0) return '';
if (detail.key) return `${detail.key}: ${detail.old}${detail.new}`;
if (detail.reason) return String(detail.reason);
if (detail.changes && Array.isArray(detail.changes)) return `${detail.changes.length} setting(s) changed`;
return JSON.stringify(detail).slice(0, 80);
}
+174
View File
@@ -0,0 +1,174 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { RefreshCw } from 'lucide-react';
import type { AuditEntry } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
export function LogsTab() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [actionFilter, setActionFilter] = useState('');
const limit = 50;
const fetchLogs = useCallback(async () => {
setLoading(true);
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
if (actionFilter) params.set('action', actionFilter);
const res = await apiFetch(`/api/admin/audit?${params}`);
if (res.ok) {
const data = await res.json();
setEntries(data.entries || []);
setTotal(data.total || 0);
}
setLoading(false);
}, [page, actionFilter]);
useEffect(() => { fetchLogs(); }, [fetchLogs]);
const totalPages = Math.max(1, Math.ceil(total / limit));
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Audit Log</h1>
<p className="text-sm text-muted-foreground mt-1">{total} total entries</p>
</div>
<button
onClick={fetchLogs}
className="inline-flex items-center gap-2 h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-accent transition-colors"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
<div className="flex items-center gap-3">
<select
value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
className="h-8 w-full sm:w-auto rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">All actions</option>
<option value="admin.login">Login</option>
<option value="admin.logout">Logout</option>
<option value="admin.login_failed">Login Failed</option>
<option value="admin.login_blocked">Login Blocked</option>
<option value="admin.change-password">Password Change</option>
<option value="config.update">Config Update</option>
<option value="config.revert">Config Revert</option>
<option value="policy.update">Policy Update</option>
</select>
</div>
<div className="sm:hidden space-y-2">
{loading && entries.length === 0 ? (
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">Loading...</div>
) : entries.length === 0 ? (
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">No entries found</div>
) : (
entries.map((entry, i) => (
<div key={i} className="rounded-lg border border-border p-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground truncate">
{entry.action}
</span>
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
{new Date(entry.ts).toLocaleString()}
</span>
</div>
<div className="text-xs text-foreground break-words">
{formatDetail(entry.detail)}
</div>
<div className="text-[11px] text-muted-foreground font-mono">
{entry.ip}
</div>
</div>
))
)}
</div>
<div className="hidden sm:block border border-border rounded-lg overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<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">
{loading && entries.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">Loading...</td>
</tr>
) : entries.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">No entries found</td>
</tr>
) : (
entries.map((entry, i) => (
<tr key={i} className="hover:bg-muted/20">
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">
{new Date(entry.ts).toLocaleString()}
</td>
<td className="px-4 py-2">
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground">
{entry.action}
</span>
</td>
<td className="px-4 py-2 text-xs text-foreground max-w-xs truncate">
{formatDetail(entry.detail)}
</td>
<td className="px-4 py-2 text-xs text-muted-foreground font-mono">
{entry.ip}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Page {page} of {totalPages}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
>
Previous
</button>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
>
Next
</button>
</div>
</div>
)}
</div>
);
}
function formatDetail(detail: Record<string, unknown>): string {
if (!detail || Object.keys(detail).length === 0) return '-';
if (detail.reason) return String(detail.reason);
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)}${JSON.stringify(detail.new)}`;
if (detail.changes && Array.isArray(detail.changes)) {
return detail.changes.map((c: Record<string, unknown>) => `${c.key}`).join(', ');
}
if (detail.restrictionCount !== undefined) return `${detail.restrictionCount} restriction(s)`;
return JSON.stringify(detail).slice(0, 100);
}
+426
View File
@@ -0,0 +1,426 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle, ArrowUpCircle } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
interface Extension {
slug: string;
name: string;
type: 'plugin' | 'theme';
pluginType: string | null;
description: string;
permissions: string[];
tags: string[];
totalDownloads: number;
featured: boolean;
minAppVersion: string | null;
latestVersion: string | null;
installed: boolean;
installedVersion: string | null;
iconUrl: string | null;
bannerUrl: string | null;
author: {
displayName: string;
githubLogin: string;
avatarUrl: string | null;
} | null;
}
interface SearchResult {
data: Extension[];
meta: {
page: number;
perPage: number;
total: number;
};
}
type TypeFilter = 'all' | 'plugin' | 'theme';
export function MarketplaceTab() {
const [extensions, setExtensions] = useState<Extension[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [perPage] = useState(12);
const [installing, setInstalling] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const fetchExtensions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (query) params.set('q', query);
if (typeFilter !== 'all') params.set('type', typeFilter);
params.set('page', String(page));
params.set('perPage', String(perPage));
params.set('sort', 'newest');
const res = await apiFetch(`/api/admin/marketplace?${params}`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.error || 'Failed to connect to extension directory');
setExtensions([]);
return;
}
const data: SearchResult = await res.json();
setExtensions(data.data || []);
setTotal(data.meta?.total || 0);
} catch {
setError('Failed to connect to extension directory. Make sure it is running.');
setExtensions([]);
} finally {
setLoading(false);
}
}, [query, typeFilter, page, perPage]);
useEffect(() => {
fetchExtensions();
}, [fetchExtensions]);
const [searchInput, setSearchInput] = useState('');
useEffect(() => {
const t = setTimeout(() => {
setQuery(searchInput);
setPage(1);
}, 300);
return () => clearTimeout(t);
}, [searchInput]);
async function handleInstall(ext: Extension) {
if (ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion)) {
setMessage({
type: 'error',
text: `"${ext.name}" requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`,
});
return;
}
const isUpdate = ext.installed;
const targetVersion = ext.latestVersion || '1.0.0';
setInstalling(ext.slug);
setMessage(null);
try {
const res = await apiFetch('/api/admin/marketplace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug: ext.slug,
version: targetVersion,
type: ext.type,
}),
});
const data = await res.json();
if (res.ok) {
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
setMessage({
type: 'success',
text: isUpdate
? `"${ext.name}" updated to v${targetVersion}${warnings}`
: `"${ext.name}" installed successfully${warnings}`,
});
setExtensions(prev => prev.map(e =>
e.slug === ext.slug
? { ...e, installed: true, installedVersion: targetVersion }
: e,
));
} else {
setMessage({ type: 'error', text: data.error || (isUpdate ? 'Update failed' : 'Installation failed') });
}
} catch {
setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
} finally {
setInstalling(null);
}
}
const totalPages = Math.ceil(total / perPage);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">Marketplace</h1>
<p className="text-sm text-muted-foreground mt-1">
Browse and install plugins and themes from the BulwarkMail extension directory
</p>
</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}
</div>
)}
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="relative flex-1 min-w-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder="Search extensions..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
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">
{(['all', 'plugin', 'theme'] as const).map((t) => (
<button
key={t}
onClick={() => { setTypeFilter(t); setPage(1); }}
className={`h-8 px-3 rounded text-sm font-medium transition-colors ${
typeFilter === t
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t === 'all' ? 'All' : t === 'plugin' ? 'Plugins' : 'Themes'}
</button>
))}
</div>
</div>
{error && (
<div className="border border-border rounded-lg p-12 text-center">
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">{error}</p>
<p className="text-xs text-muted-foreground mt-1">
Start the extension directory server on the configured port
</p>
<button
onClick={fetchExtensions}
className="mt-4 inline-flex items-center gap-2 h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90"
>
Retry
</button>
</div>
)}
{loading && !error && (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
<span className="ms-2 text-sm text-muted-foreground">Searching extensions...</span>
</div>
)}
{!loading && !error && extensions.length === 0 && (
<div className="border border-border rounded-lg p-12 text-center">
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No extensions found</p>
{query && (
<p className="text-xs text-muted-foreground mt-1">
Try a different search term
</p>
)}
</div>
)}
{!loading && !error && extensions.length > 0 && (
<>
<div className="text-xs text-muted-foreground">
{total} extension{total !== 1 ? 's' : ''} found
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{extensions.map((ext) => (
<ExtensionCard
key={ext.slug}
extension={ext}
installing={installing === ext.slug}
onInstall={() => handleInstall(ext)}
/>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
)}
</>
)}
</div>
);
}
function ExtensionCard({
extension,
installing,
onInstall,
}: {
extension: Extension;
installing: boolean;
onInstall: () => void;
}) {
const isPlugin = extension.type === 'plugin';
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
const versionMismatch = !!extension.minAppVersion
&& !isVersionSatisfied(CURRENT_APP_VERSION, extension.minAppVersion);
const updateAvailable = extension.installed
&& !!extension.installedVersion
&& !!extension.latestVersion
&& compareVersions(extension.latestVersion, extension.installedVersion) > 0
&& !versionMismatch;
return (
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
{extension.bannerUrl && (
<Link href={previewHref} className="block focus:outline-none">
<img
src={extension.bannerUrl}
alt=""
className="block h-24 w-full object-cover border-b border-border"
loading="lazy"
/>
</Link>
)}
<Link href={previewHref} className="block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-lg">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0 overflow-hidden">
{extension.iconUrl ? (
<img
src={extension.iconUrl}
alt=""
className="w-10 h-10 object-cover"
loading="lazy"
/>
) : isPlugin ? (
<Puzzle className="w-5 h-5 text-muted-foreground" />
) : (
<SwatchBook className="w-5 h-5 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground truncate group-hover:underline">
{extension.name}
</span>
{extension.featured && (
<Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" />
)}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
isPlugin
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
}`}>
{isPlugin ? (extension.pluginType || 'plugin') : 'theme'}
</span>
{extension.author && (
<span className="text-xs text-muted-foreground truncate">
by {extension.author.displayName}
</span>
)}
</div>
</div>
</div>
<p className="text-xs text-muted-foreground mt-3 line-clamp-2">
{extension.description}
</p>
{extension.tags && extension.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{extension.tags.slice(0, 3).map(tag => (
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{tag}
</span>
))}
</div>
)}
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Download className="w-3 h-3" />
{extension.totalDownloads.toLocaleString()}
</span>
{extension.permissions && extension.permissions.length > 0 && (
<span title={extension.permissions.join(', ')}>
{extension.permissions.length} permission{extension.permissions.length !== 1 ? 's' : ''}
</span>
)}
</div>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground">
<Eye className="w-3 h-3" />
Preview
</span>
</div>
</Link>
<div className="px-4 pb-4 -mt-1 flex items-center gap-2 flex-wrap">
{extension.installed && updateAvailable ? (
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
disabled={installing}
title={`Update from v${extension.installedVersion} to v${extension.latestVersion}`}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{installing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<ArrowUpCircle className="w-3 h-3" />
)}
Update to v{extension.latestVersion}
</button>
) : extension.installed ? (
<span
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium"
title={extension.installedVersion ? `Installed: v${extension.installedVersion}` : undefined}
>
<Check className="w-3 h-3" />
Installed
</span>
) : versionMismatch ? (
<span
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300 text-xs font-medium"
title={`Requires app v${extension.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`}
>
<AlertTriangle className="w-3 h-3" />
Requires v{extension.minAppVersion}+
</span>
) : (
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
disabled={installing}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{installing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Download className="w-3 h-3" />
)}
Quick install
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,314 @@
'use client';
import { useEffect, useState } from 'react';
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
interface ConfigField {
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
label: string;
description?: string;
required?: boolean;
default?: unknown;
placeholder?: string;
options?: { label: string; value: string }[];
}
interface PluginConfig {
[key: string]: unknown;
}
interface PluginInfo {
id: string;
name: string;
description: string;
version: string;
author: string;
type: string;
permissions: string[];
enabled: boolean;
configSchema?: Record<string, ConfigField>;
}
interface Props {
pluginId: string;
onBack: () => void;
}
export function PluginConfigPanel({ pluginId, onBack }: Props) {
const [plugin, setPlugin] = useState<PluginInfo | null>(null);
const [config, setConfig] = useState<PluginConfig>({});
const [formValues, setFormValues] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [revealSecrets, setRevealSecrets] = useState<Record<string, boolean>>({});
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => {
let cancelled = false;
async function fetchData() {
setLoading(true);
try {
const [pluginsRes, configRes] = await Promise.all([
apiFetch('/api/admin/plugins'),
apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
]);
if (cancelled) return;
if (pluginsRes.ok) {
const plugins: PluginInfo[] = await pluginsRes.json();
setPlugin(plugins.find(p => p.id === pluginId) || null);
}
if (configRes.ok) {
setConfig(await configRes.json());
}
} finally {
if (!cancelled) setLoading(false);
}
}
fetchData();
return () => { cancelled = true; };
}, [pluginId]);
useEffect(() => {
if (!plugin?.configSchema) return;
const initial: Record<string, string> = {};
for (const [key, field] of Object.entries(plugin.configSchema)) {
const stored = config[key];
if (stored !== undefined && stored !== null) {
initial[key] = String(stored);
} else if (field.default !== undefined) {
initial[key] = String(field.default);
} else {
initial[key] = '';
}
}
setFormValues(initial);
}, [plugin, config]);
async function handleSaveAll() {
if (!plugin?.configSchema) return;
setSaving(true);
setMessage(null);
for (const [key, field] of Object.entries(plugin.configSchema)) {
if (field.required && !formValues[key]?.trim()) {
setMessage({ type: 'error', text: `"${field.label}" is required` });
setSaving(false);
return;
}
}
try {
let hasError = false;
for (const [key, field] of Object.entries(plugin.configSchema)) {
const newVal = formValues[key] ?? '';
const oldVal = config[key] !== undefined ? String(config[key]) : '';
if (newVal === oldVal) continue;
if (field.type === 'secret' && !newVal && config[key]) continue;
let value: unknown = newVal;
if (field.type === 'boolean') value = newVal === 'true';
else if (field.type === 'number') value = Number(newVal);
if (!newVal && !field.required) {
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
if (res.ok) {
setConfig(prev => { const next = { ...prev }; delete next[key]; return next; });
} else {
hasError = true;
}
continue;
}
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
});
if (res.ok) {
setConfig(prev => ({ ...prev, [key]: value }));
} else {
hasError = true;
}
}
setMessage(hasError
? { type: 'error', text: 'Some settings failed to save' }
: { type: 'success', text: 'Configuration saved' }
);
} catch {
setMessage({ type: 'error', text: 'Failed to save configuration' });
} finally {
setSaving(false);
}
}
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 me-2" />
Loading...
</div>
);
}
if (!plugin) {
return (
<div className="space-y-4">
<button
type="button"
onClick={onBack}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4" /> Back to Plugins
</button>
<p className="text-sm text-destructive">Plugin not found: {pluginId}</p>
</div>
);
}
const schema = plugin.configSchema;
const hasSchema = schema && Object.keys(schema).length > 0;
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<button
type="button"
onClick={onBack}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
aria-label="Back to Plugins"
>
<ArrowLeft className="w-4 h-4" />
</button>
<div>
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
<Puzzle className="w-5 h-5" />
{plugin.name} Configuration
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
v{plugin.version} by {plugin.author}
</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}
</div>
)}
{hasSchema ? (
<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">Settings</h2>
</div>
<div className="p-4 space-y-5">
{Object.entries(schema).map(([key, field]) => (
<div key={key}>
<label className="text-sm font-medium text-foreground block mb-1">
{field.label}
{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>
)}
{field.type === 'boolean' ? (
<select
value={formValues[key] ?? String(field.default ?? 'false')}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
) : field.type === 'select' && field.options ? (
<select
value={formValues[key] ?? ''}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">- Select -</option>
{field.options.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
) : field.type === 'secret' ? (
<div className="relative">
<input
type={revealSecrets[key] ? 'text' : 'password'}
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 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"
onClick={() => setRevealSecrets(prev => ({ ...prev, [key]: !prev[key] }))}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
aria-label={revealSecrets[key] ? 'Hide' : 'Show'}
>
{revealSecrets[key] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
) : (
<input
type={field.type === 'number' ? 'number' : 'text'}
value={formValues[key] ?? ''}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
placeholder={field.placeholder || ''}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
)}
</div>
))}
<button
onClick={handleSaveAll}
disabled={saving}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
Save Configuration
</button>
</div>
</div>
) : (
<div className="border border-border rounded-lg p-8 text-center">
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
</div>
)}
<PluginAdminSection pluginId={pluginId} />
</div>
);
}
/**
* Renders the plugin's own `admin-plugin-page` slot, if the plugin offers
* one. Sandboxed plugins ship a React component under `slots['admin-plugin-page']`
* and the host gives it a dedicated iframe inside the admin panel.
*/
function PluginAdminSection({ pluginId }: { pluginId: string }) {
const offers = usePluginSlotOffers('admin-plugin-page');
const offer = offers.find((o) => o.pluginId === pluginId);
if (!offer) return null;
return (
<div className="border border-border rounded-lg overflow-hidden">
<div className="bg-muted/40 px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Plugin admin panel
</div>
<PluginIframeSlot pluginId={pluginId} slot="admin-plugin-page" />
</div>
);
}
+459
View File
@@ -0,0 +1,459 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
import { PluginConfigPanel } from './plugin-config-panel';
interface PluginEntry {
id: string;
name: string;
version: string;
author: string;
description: string;
type: string;
enabled: boolean;
forceEnabled?: boolean;
permissions: string[];
installedAt: string;
updatedAt: string;
}
export function PluginsTab() {
const [plugins, setPlugins] = useState<PluginEntry[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [policyDirty, setPolicyDirty] = useState(false);
const [savingPolicy, setSavingPolicy] = useState(false);
const [configuringId, setConfiguringId] = useState<string | null>(null);
useEffect(() => { fetchPlugins(); fetchPolicy(); }, []);
async function fetchPolicy() {
try {
const res = await apiFetch('/api/admin/policy');
if (res.ok) {
const data = await res.json();
setPolicy(data);
}
} catch { /* ignore */ }
}
function togglePluginsEnabled() {
setPolicy(prev => ({
...prev,
features: { ...prev.features, pluginsEnabled: !prev.features.pluginsEnabled },
}));
setPolicyDirty(true);
setMessage(null);
}
function togglePluginsUploadEnabled() {
setPolicy(prev => ({
...prev,
features: { ...prev.features, pluginsUploadEnabled: !prev.features.pluginsUploadEnabled },
}));
setPolicyDirty(true);
setMessage(null);
}
function toggleRequirePluginApproval() {
setPolicy(prev => ({
...prev,
features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval },
}));
setPolicyDirty(true);
setMessage(null);
}
async function handleSavePolicy() {
setSavingPolicy(true);
setMessage(null);
try {
const res = await apiFetch('/api/admin/policy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policy),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Plugin policy saved. Users will see changes on next login.' });
setPolicyDirty(false);
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
}
} catch {
setMessage({ type: 'error', text: 'Failed to save policy' });
} finally {
setSavingPolicy(false);
}
}
async function fetchPlugins() {
setLoading(true);
try {
const res = await apiFetch('/api/admin/plugins');
if (res.ok) setPlugins(await res.json());
} finally {
setLoading(false);
}
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setMessage(null);
const formData = new FormData();
formData.append('file', file);
try {
const res = await apiFetch('/api/admin/plugins', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (res.ok) {
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` });
await fetchPlugins();
} else {
setMessage({ type: 'error', text: data.error || 'Upload failed' });
}
} catch {
setMessage({ type: 'error', text: 'Upload failed' });
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
}
async function togglePlugin(id: string, enabled: boolean) {
setMessage(null);
const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, enabled }),
});
if (res.ok) {
setPlugins(prev => prev.map(p => p.id === id ? { ...p, enabled } : p));
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Update failed' });
}
}
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
setMessage(null);
const body: Record<string, unknown> = { id, forceEnabled };
if (forceEnabled) body.enabled = true;
const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) {
setPlugins(prev => prev.map(p => p.id === id ? { ...p, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : p));
setPolicy(prev => {
const current = prev.forceEnabledPlugins || [];
return {
...prev,
forceEnabledPlugins: forceEnabled
? [...current.filter(pid => pid !== id), id]
: current.filter(pid => pid !== id),
};
});
setPolicyDirty(true);
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Update failed' });
}
}
async function forceEnableAll() {
setMessage(null);
const disabled = plugins.filter(p => !p.enabled);
if (disabled.length === 0) {
setMessage({ type: 'success', text: 'All plugins are already enabled' });
return;
}
let failed = 0;
for (const p of disabled) {
const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: p.id, enabled: true }),
});
if (!res.ok) failed++;
}
setPlugins(prev => prev.map(p => failed === 0 ? { ...p, enabled: true } : p));
if (failed === 0) {
await fetchPlugins();
setMessage({ type: 'success', text: `All ${disabled.length} plugin(s) enabled` });
} else {
await fetchPlugins();
setMessage({ type: 'error', text: `${failed} plugin(s) failed to enable` });
}
}
async function forceDisableAll() {
setMessage(null);
const enabled = plugins.filter(p => p.enabled);
if (enabled.length === 0) {
setMessage({ type: 'success', text: 'All plugins are already disabled' });
return;
}
let failed = 0;
for (const p of enabled) {
const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: p.id, enabled: false }),
});
if (!res.ok) failed++;
}
if (failed === 0) {
await fetchPlugins();
setMessage({ type: 'success', text: `All ${enabled.length} plugin(s) disabled` });
} else {
await fetchPlugins();
setMessage({ type: 'error', text: `${failed} plugin(s) failed to disable` });
}
}
async function deletePlugin(id: string, name: string) {
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
setMessage(null);
const res = await apiFetch('/api/admin/plugins', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
if (res.ok) {
setPlugins(prev => prev.filter(p => p.id !== id));
setMessage({ type: 'success', text: `Plugin "${name}" removed` });
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Delete failed' });
}
}
if (configuringId) {
return <PluginConfigPanel pluginId={configuringId} onBack={() => { setConfiguringId(null); fetchPlugins(); }} />;
}
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
const requirePluginApproval = policy.features.requirePluginApproval ?? true;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Plugins</h1>
<p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{policyDirty && (
<button
onClick={handleSavePolicy}
disabled={savingPolicy}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save Policy
</button>
)}
<label className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 cursor-pointer transition-all shadow-sm">
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
Upload Plugin
<input
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleUpload}
disabled={uploading}
className="sr-only"
/>
</label>
</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}
</div>
)}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Plugin Policy</h2>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p>
</div>
<div className="divide-y divide-border">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<span className="text-sm text-foreground">Plugins Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p>
</div>
<button onClick={togglePluginsEnabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<span className="text-sm text-foreground">User Plugin Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p>
</div>
<button onClick={togglePluginsUploadEnabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsUploadEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsUploadEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<span className="text-sm text-foreground">Require Admin Approval</span>
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
</div>
<button onClick={toggleRequirePluginApproval}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${requirePluginApproval ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${requirePluginApproval ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
{plugins.length > 0 && (
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<span className="text-sm text-foreground">Force Enable / Disable All</span>
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed plugins at once</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={forceEnableAll}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
>
<Power className="w-3.5 h-3.5" />
Enable All
</button>
<button
onClick={forceDisableAll}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
>
<PowerOff className="w-3.5 h-3.5" />
Disable All
</button>
</div>
</div>
)}
</div>
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Deployed Plugins</h2>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded plugins for all users</p>
</div>
{plugins.length === 0 ? (
<div className="p-12 text-center">
<Package className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No plugins installed</p>
<p className="text-xs text-muted-foreground mt-1">Upload a plugin ZIP file to get started</p>
</div>
) : (
<div className="divide-y divide-border">
{plugins.map(plugin => (
<div key={plugin.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">{plugin.name}</span>
<span className="text-xs text-muted-foreground">v{plugin.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
{plugin.enabled ? 'Enabled' : 'Disabled'}
</span>
{plugin.forceEnabled && (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
<Lock className="w-3 h-3" /> Forced
</span>
)}
</div>
{plugin.description && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{plugin.description}</p>
)}
<div className="text-xs text-muted-foreground mt-1">
by {plugin.author} &middot; {plugin.type} &middot; installed {new Date(plugin.installedAt).toLocaleDateString()}
</div>
{plugin.permissions.length > 0 && (
<div className="flex items-center gap-1 mt-1">
<AlertTriangle className="w-3 h-3 text-warning" />
<span className="text-xs text-warning">
Permissions: {plugin.permissions.join(', ')}
</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setConfiguringId(plugin.id)}
title="Configure"
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<Settings className="w-4 h-4" />
</button>
<button
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
className={`p-2 rounded-md transition-colors ${plugin.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
>
{plugin.forceEnabled ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
</button>
<button
onClick={() => togglePlugin(plugin.id, !plugin.enabled)}
title={plugin.enabled ? 'Disable' : 'Enable'}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<Power className="w-4 h-4" />
</button>
<button
onClick={() => deletePlugin(plugin.id, plugin.name)}
title="Remove"
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
+262
View File
@@ -0,0 +1,262 @@
'use client';
import { useEffect, useState } from 'react';
import { Save, Loader2, Lock } from 'lucide-react';
import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
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' },
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
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 = [
{ key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'] },
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] },
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
{ key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
{ key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' },
{ key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' },
];
export function PolicyTab() {
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [dirty, setDirty] = useState(false);
useEffect(() => { fetchPolicy(); }, []);
async function fetchPolicy() {
setLoading(true);
try {
const res = await apiFetch('/api/admin/policy');
if (res.ok) {
const data = await res.json();
setPolicy(data);
}
} finally {
setLoading(false);
}
}
function toggleFeature(key: keyof FeatureGates) {
setPolicy(prev => ({
...prev,
features: { ...prev.features, [key]: !prev.features[key] },
}));
setDirty(true);
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] || {};
const newRestrictions = { ...prev.restrictions };
if (existing.locked) {
delete newRestrictions[settingKey];
} else {
newRestrictions[settingKey] = { ...existing, locked: true };
}
return { ...prev, restrictions: newRestrictions };
});
setDirty(true);
setMessage(null);
}
function toggleHidden(settingKey: string) {
setPolicy(prev => {
const existing = prev.restrictions[settingKey] || {};
const newRestrictions = { ...prev.restrictions };
newRestrictions[settingKey] = { ...existing, hidden: !existing.hidden };
if (!newRestrictions[settingKey].hidden && !newRestrictions[settingKey].locked) {
delete newRestrictions[settingKey];
}
return { ...prev, restrictions: newRestrictions };
});
setDirty(true);
setMessage(null);
}
async function handleSave() {
setSaving(true);
setMessage(null);
const res = await apiFetch('/api/admin/policy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policy),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Policy saved. Users will see changes on next login.' });
setDirty(false);
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save' });
}
setSaving(false);
}
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))];
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">User Policy</h1>
<p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</p>
</div>
{dirty && (
<button
onClick={handleSave}
disabled={saving}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save policy
</button>
)}
</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}
</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">Feature Gates</h2>
<p className="text-xs text-muted-foreground mt-0.5">Toggle entire features on or off for all users. Plugin and theme gates are on their respective admin pages.</p>
</div>
<div className="divide-y divide-border">
{(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[])
.filter(key => !EXCLUDED_FEATURE_GATES.includes(key))
.map(key => {
const meta = FEATURE_GATE_LABELS[key];
if (!meta) return null;
const { label, description } = meta;
const enabled = policy.features[key];
return (
<div key={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="min-w-0">
<span className="text-sm text-foreground">{label}</span>
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
</div>
<button onClick={() => toggleFeature(key)}
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
);
})}
</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">
<h2 className="text-sm font-medium text-foreground">{category}</h2>
</div>
<div className="divide-y divide-border">
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
const restriction = policy.restrictions[setting.key] || {};
return (
<div key={setting.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<span className="text-sm text-foreground">{setting.label}</span>
<div className="flex items-center gap-3 shrink-0">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
className="rounded border-input" />
<Lock className="w-3 h-3" /> Lock
</label>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input type="checkbox" checked={!!restriction.hidden} onChange={() => toggleHidden(setting.key)}
className="rounded border-input" />
Hide
</label>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
);
}
+274
View File
@@ -0,0 +1,274 @@
'use client';
import { useEffect, useState } from 'react';
import { Save, RotateCcw, Loader2 } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
import { JmapServersSection } from './_jmap-servers-section';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
interface ConfigEntry {
value?: unknown;
source: 'admin' | 'env' | 'default';
hasValue?: boolean;
}
export function SettingsTab() {
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
const [edits, setEdits] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => {
fetchConfig();
}, []);
async function fetchConfig() {
setLoading(true);
const res = await apiFetch('/api/admin/config');
if (res.ok) {
setConfig(await res.json());
}
setLoading(false);
}
function handleChange(key: string, value: unknown) {
setEdits(prev => ({ ...prev, [key]: value }));
setMessage(null);
}
function currentValue(key: string): unknown {
if (key in edits) return edits[key];
return config[key]?.value;
}
async function handleSave() {
if (Object.keys(edits).length === 0) return;
setSaving(true);
setMessage(null);
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Settings saved. Changes take effect on next page load.' });
setEdits({});
await fetchConfig();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save' });
}
setSaving(false);
}
async function handleRevert(key: string) {
const res = await apiFetch('/api/admin/config', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
if (res.ok) {
setEdits(prev => {
const next = { ...prev };
delete next[key];
return next;
});
await fetchConfig();
setMessage({ type: 'success', text: `${key} reverted to default` });
}
}
const hasEdits = Object.keys(edits).length > 0;
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Server Settings</h1>
<p className="text-sm text-muted-foreground mt-1">General server configuration</p>
</div>
{hasEdits && (
<button
onClick={handleSave}
disabled={saving}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save changes
</button>
)}
</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}
</div>
)}
<SettingsSection title="General">
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
<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-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>
</div>
)}
<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)">
<ToggleSetting
label="Auto-pick server by email domain"
description="When users type their email, automatically select the matching server from the list below."
configKey="jmapServerAutoPickByDomain"
value={currentValue('jmapServerAutoPickByDomain') as boolean}
source={config.jmapServerAutoPickByDomain?.source}
onChange={handleChange}
onRevert={handleRevert}
/>
<JmapServersSection
value={(currentValue('jmapServers') as JmapServerEntry[]) ?? []}
source={config.jmapServers?.source}
onChange={(next) => handleChange('jmapServers', next)}
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-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>
</div>
)}
</SettingsSection>
<SettingsSection title="Logging">
<SelectSetting label="Log Format" configKey="logFormat" value={currentValue('logFormat') as string} source={config.logFormat?.source} options={['text', 'json']} onChange={handleChange} onRevert={handleRevert} />
<SelectSetting label="Log Level" configKey="logLevel" value={currentValue('logLevel') as string} source={config.logLevel?.source} options={['error', 'warn', 'info', 'debug']} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
<SettingsSection title="Settings Sync">
<ToggleSetting label="Settings Sync Enabled" description="Requires SESSION_SECRET to be set" configKey="settingsSyncEnabled" value={currentValue('settingsSyncEnabled') as boolean} source={config.settingsSyncEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
</div>
);
}
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<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">{title}</h2>
</div>
<div className="divide-y divide-border">
{children}
</div>
</div>
);
}
function SourceBadge({ source }: { source?: string }) {
if (!source || source === 'default') return null;
return (
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
{source}
</span>
);
}
function TextSetting({ label, configKey, value, source, onChange, onRevert, placeholder }: {
label: string; configKey: string; value: string; source?: string;
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string;
}) {
return (
<div 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">{label}</label>
<SourceBadge source={source} />
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="text"
value={value ?? ''}
onChange={(e) => onChange(configKey, e.target.value)}
placeholder={placeholder}
className="h-8 w-full sm: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"
/>
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
}
function ToggleSetting({ label, description, configKey, value, source, onChange, onRevert }: {
label: string; description?: string; configKey: string; value: boolean; source?: string;
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
}) {
return (
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => onChange(configKey, !value)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}
>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
}
function SelectSetting({ label, configKey, value, source, options, onChange, onRevert }: {
label: string; configKey: string; value: string; source?: string; options: string[];
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
}) {
return (
<div 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">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
<div className="flex items-center gap-2 shrink-0">
<select
value={value ?? ''}
onChange={(e) => onChange(configKey, e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
}
+251
View File
@@ -0,0 +1,251 @@
'use client';
import { useEffect, useState } from 'react';
import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface TelemetryStatus {
consent: 'pending' | 'on' | 'off';
consentSource: 'env' | 'file';
endpoint: string;
defaultEndpoint: string;
consentedAt: string | null;
lastSentAt: string | null;
nextScheduledAt: string | null;
payloadPreview: Record<string, unknown>;
accountCounts: { total: number; active7d: number };
}
function timeAgo(iso: string | null): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
if (d < 0) return new Date(iso).toLocaleString();
const m = Math.floor(d / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m} min ago`;
const h = Math.floor(m / 60);
if (h < 48) return `${h} hours ago`;
const days = Math.floor(h / 24);
return `${days} days ago`;
}
export function TelemetryTab() {
const [status, setStatus] = useState<TelemetryStatus | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [endpointDraft, setEndpointDraft] = useState('');
const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null);
async function refresh(): Promise<void> {
setLoading(true);
try {
const r = await apiFetch('/api/admin/telemetry');
if (!r.ok) throw new Error('failed to load');
const data = (await r.json()) as TelemetryStatus;
setStatus(data);
setEndpointDraft(data.endpoint);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
useEffect(() => { void refresh(); }, []);
async function setConsent(consent: 'on' | 'off'): Promise<void> {
setBusy('consent');
try {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'set-consent', consent }),
});
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
alert(j.error ?? 'failed');
}
await refresh();
} finally { setBusy(null); }
}
async function saveEndpoint(): Promise<void> {
setBusy('endpoint');
try {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }),
});
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
alert(j.error ?? 'failed');
}
await refresh();
} finally { setBusy(null); }
}
async function sendNow(): Promise<void> {
setBusy('send');
setSendResult(null);
try {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'send-now' }),
});
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; status?: number; error?: string };
setSendResult({
ok: !!j.ok,
msg: j.ok ? `sent (HTTP ${j.status ?? '?'})` : `failed: ${j.error ?? 'unknown'}`,
});
await refresh();
} finally { setBusy(null); }
}
if (loading || !status) {
return (
<div className="p-8 flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> loading
</div>
);
}
const envOverridden = status.consentSource === 'env';
const isOn = status.consent === 'on';
return (
<div className="space-y-6">
<header className="space-y-2">
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
<p className="text-sm text-muted-foreground">
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"
rel="noreferrer"
className="underline inline-flex items-center gap-1"
>
Full schema and policy <ExternalLink className="h-3 w-3" />
</a>
</p>
</header>
<section className="rounded-lg border p-4 space-y-3">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="min-w-0">
<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. Thanks for helping us improve!'}
{status.consent === 'off' && 'Heartbeats are off (default).'}
{envOverridden && (
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
)}
</div>
</div>
<div className="flex gap-2">
<button
type="button"
disabled={busy === 'consent' || envOverridden || isOn}
onClick={() => void setConsent('on')}
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Enable
</button>
<button
type="button"
disabled={busy === 'consent' || envOverridden || status.consent === 'off'}
onClick={() => void setConsent('off')}
className="px-3 py-1.5 rounded-md border hover:bg-accent disabled:opacity-50"
>
Disable
</button>
</div>
</div>
<dl className="grid grid-cols-2 gap-2 text-sm pt-2 border-t">
<dt className="text-muted-foreground">Last sent</dt>
<dd>{timeAgo(status.lastSentAt)}</dd>
<dt className="text-muted-foreground">Next scheduled</dt>
<dd>{timeAgo(status.nextScheduledAt)}</dd>
<dt className="text-muted-foreground">Consented at</dt>
<dd>{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}</dd>
</dl>
</section>
<section className="rounded-lg border p-4 space-y-2">
<div className="font-medium">Account activity</div>
<p className="text-sm text-muted-foreground">
Unique accounts that have logged in over the last 90 days. Identities are stored as a
per-instance HMAC, never as plaintext usernames. These are the numbers reported in the
heartbeat as bucketed ranges.
</p>
<dl className="grid grid-cols-2 gap-2 text-sm pt-1">
<dt className="text-muted-foreground">Total (90d)</dt>
<dd className="font-mono">{status.accountCounts?.total ?? 0}</dd>
<dt className="text-muted-foreground">Active (7d)</dt>
<dd className="font-mono">{status.accountCounts?.active7d ?? 0}</dd>
</dl>
</section>
<section className="rounded-lg border p-4 space-y-3">
<div className="font-medium">Endpoint</div>
<p className="text-sm text-muted-foreground">
Where heartbeats are sent. Defaults to the project&apos;s collector. Point at your own collector
(open source at <code>bulwarkmail/dashboard</code>) or clear this field to disable sending.
</p>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="url"
value={endpointDraft}
onChange={(e) => setEndpointDraft(e.target.value)}
placeholder={status.defaultEndpoint}
className="flex-1 min-w-0 px-3 py-1.5 rounded-md border bg-background"
/>
<button
type="button"
disabled={busy === 'endpoint' || endpointDraft === status.endpoint}
onClick={() => void saveEndpoint()}
className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center justify-center gap-1"
>
<Save className="h-4 w-4" /> Save
</button>
</div>
</section>
<section className="rounded-lg border p-4 space-y-3">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="min-w-0">
<div className="font-medium">Payload preview</div>
<div className="text-sm text-muted-foreground">
Exactly what the next heartbeat would send from this install, right now.
</div>
</div>
<button
type="button"
disabled={busy === 'send' || !isOn}
onClick={() => void sendNow()}
className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
>
<Send className="h-4 w-4" /> Send now
</button>
</div>
{sendResult && (
<div
className={`text-sm flex items-center gap-2 ${
sendResult.ok ? 'text-emerald-600' : 'text-red-600'
}`}
>
{sendResult.ok ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
{sendResult.msg}
</div>
)}
<pre className="text-xs bg-muted/50 rounded-md p-3 overflow-x-auto max-h-96">
{JSON.stringify(status.payloadPreview, null, 2)}
</pre>
</section>
</div>
);
}
+544
View File
@@ -0,0 +1,544 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
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';
// 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;
name: string;
version: string;
author: string;
description: string;
variants: string[];
enabled: boolean;
forceEnabled?: boolean;
installedAt: string;
updatedAt: string;
}
export function ThemesTab() {
const [themes, setThemes] = useState<ThemeEntry[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [policyDirty, setPolicyDirty] = useState(false);
const [savingPolicy, setSavingPolicy] = useState(false);
useEffect(() => { fetchThemes(); fetchPolicy(); }, []);
async function fetchPolicy() {
try {
const res = await apiFetch('/api/admin/policy');
if (res.ok) {
const data = await res.json();
setPolicy({
...data,
themePolicy: { ...DEFAULT_THEME_POLICY, ...(data.themePolicy || {}) },
});
}
} catch { /* ignore */ }
}
function toggleThemesEnabled() {
setPolicy(prev => ({
...prev,
features: { ...prev.features, themesEnabled: !prev.features.themesEnabled },
}));
setPolicyDirty(true);
setMessage(null);
}
function toggleUserThemeUploads() {
setPolicy(prev => ({
...prev,
features: { ...prev.features, userThemesEnabled: !prev.features.userThemesEnabled },
}));
setPolicyDirty(true);
setMessage(null);
}
function toggleBuiltinTheme(themeId: string) {
setPolicy(prev => {
const disabled = prev.themePolicy?.disabledBuiltinThemes || [];
const isDisabled = disabled.includes(themeId);
return {
...prev,
themePolicy: {
...DEFAULT_THEME_POLICY,
...prev.themePolicy,
disabledBuiltinThemes: isDisabled
? disabled.filter((id: string) => id !== themeId)
: [...disabled, themeId],
},
};
});
setPolicyDirty(true);
setMessage(null);
}
function toggleAdminTheme(themeId: string) {
setPolicy(prev => {
const disabled = prev.themePolicy?.disabledThemes || [];
const isDisabled = disabled.includes(themeId);
return {
...prev,
themePolicy: {
...DEFAULT_THEME_POLICY,
...prev.themePolicy,
disabledThemes: isDisabled
? disabled.filter((id: string) => id !== themeId)
: [...disabled, themeId],
},
};
});
setPolicyDirty(true);
setMessage(null);
}
function setDefaultTheme(themeId: string | null) {
setPolicy(prev => ({
...prev,
themePolicy: {
...DEFAULT_THEME_POLICY,
...prev.themePolicy,
defaultThemeId: themeId,
},
}));
setPolicyDirty(true);
setMessage(null);
}
async function handleSavePolicy() {
setSavingPolicy(true);
setMessage(null);
try {
const res = await apiFetch('/api/admin/policy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policy),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Theme policy saved. Users will see changes on next login.' });
setPolicyDirty(false);
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
}
} catch {
setMessage({ type: 'error', text: 'Failed to save policy' });
} finally {
setSavingPolicy(false);
}
}
async function fetchThemes() {
setLoading(true);
try {
const res = await apiFetch('/api/admin/themes');
if (res.ok) setThemes(await res.json());
} finally {
setLoading(false);
}
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setMessage(null);
const formData = new FormData();
formData.append('file', file);
try {
const res = await apiFetch('/api/admin/themes', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (res.ok) {
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `Theme "${data.theme.name}" installed${warnings}` });
await fetchThemes();
} else {
setMessage({ type: 'error', text: data.error || 'Upload failed' });
}
} catch {
setMessage({ type: 'error', text: 'Upload failed' });
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
}
async function toggleTheme(id: string, enabled: boolean) {
setMessage(null);
const res = await apiFetch('/api/admin/themes', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, enabled }),
});
if (res.ok) {
setThemes(prev => prev.map(t => t.id === id ? { ...t, enabled } : t));
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Update failed' });
}
}
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
setMessage(null);
const body: Record<string, unknown> = { id, forceEnabled };
if (forceEnabled) body.enabled = true;
const res = await apiFetch('/api/admin/themes', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) {
setThemes(prev => prev.map(t => t.id === id ? { ...t, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : t));
setPolicy(prev => {
const current = prev.forceEnabledThemes || [];
return {
...prev,
forceEnabledThemes: forceEnabled
? [...current.filter(tid => tid !== id), id]
: current.filter(tid => tid !== id),
};
});
setPolicyDirty(true);
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Update failed' });
}
}
async function forceEnableAll() {
setMessage(null);
const disabled = themes.filter(t => !t.enabled);
if (disabled.length === 0) {
setMessage({ type: 'success', text: 'All themes are already enabled' });
return;
}
let failed = 0;
for (const t of disabled) {
const res = await apiFetch('/api/admin/themes', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: t.id, enabled: true }),
});
if (!res.ok) failed++;
}
if (failed === 0) {
await fetchThemes();
setMessage({ type: 'success', text: `All ${disabled.length} theme(s) enabled` });
} else {
await fetchThemes();
setMessage({ type: 'error', text: `${failed} theme(s) failed to enable` });
}
}
async function forceDisableAll() {
setMessage(null);
const enabled = themes.filter(t => t.enabled);
if (enabled.length === 0) {
setMessage({ type: 'success', text: 'All themes are already disabled' });
return;
}
let failed = 0;
for (const t of enabled) {
const res = await apiFetch('/api/admin/themes', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: t.id, enabled: false }),
});
if (!res.ok) failed++;
}
if (failed === 0) {
await fetchThemes();
setMessage({ type: 'success', text: `All ${enabled.length} theme(s) disabled` });
} else {
await fetchThemes();
setMessage({ type: 'error', text: `${failed} theme(s) failed to disable` });
}
}
async function deleteTheme(id: string, name: string) {
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
setMessage(null);
const res = await apiFetch('/api/admin/themes', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
if (res.ok) {
setThemes(prev => prev.filter(t => t.id !== id));
setMessage({ type: 'success', text: `Theme "${name}" removed` });
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Delete failed' });
}
}
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
const themesEnabled = policy.features.themesEnabled ?? true;
const userThemesEnabled = policy.features.userThemesEnabled ?? true;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Themes</h1>
<p className="text-sm text-muted-foreground mt-1">Manage themes and theme policy for all users</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{policyDirty && (
<button
onClick={handleSavePolicy}
disabled={savingPolicy}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save Policy
</button>
)}
<label className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 cursor-pointer transition-all shadow-sm">
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
Upload Theme
<input
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleUpload}
disabled={uploading}
className="sr-only"
/>
</label>
</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}
</div>
)}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Theme Policy</h2>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Control theme availability and defaults for users</p>
</div>
<div className="divide-y divide-border">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">Themes Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p>
</div>
<button onClick={toggleThemesEnabled}
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${themesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">User Theme Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p>
</div>
<button onClick={toggleUserThemeUploads}
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${userThemesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
{themes.length > 0 && (
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">Force Enable / Disable All</span>
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed themes at once</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={forceEnableAll}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
>
<Power className="w-3.5 h-3.5" />
Enable All
</button>
<button
onClick={forceDisableAll}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
>
<PowerOff className="w-3.5 h-3.5" />
Disable All
</button>
</div>
</div>
)}
<div className="px-4 py-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">Default Theme</span>
<p className="text-xs text-muted-foreground mt-0.5">Theme applied when users have not chosen one</p>
</div>
<select
value={policy.themePolicy?.defaultThemeId || ''}
onChange={(e) => setDefaultTheme(e.target.value || null)}
className="h-8 px-2 w-full sm:w-auto shrink-0 rounded-md border border-input bg-background text-sm text-foreground"
>
<option value="">System Default</option>
<optgroup label="Built-in">
{BUILTIN_THEME_OPTIONS
.filter(t => !(policy.themePolicy?.disabledBuiltinThemes || []).includes(t.id))
.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</optgroup>
{themes.length > 0 && (
<optgroup label="Admin-deployed">
{themes
.filter(t => !(policy.themePolicy?.disabledThemes || []).includes(t.id))
.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</optgroup>
)}
</select>
</div>
</div>
<div className="px-4 py-3">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Built-in Themes</span>
<div className="mt-2 space-y-2">
{BUILTIN_THEME_OPTIONS.map(theme => {
const disabled = (policy.themePolicy?.disabledBuiltinThemes || []).includes(theme.id);
return (
<div key={theme.id} className="flex items-center justify-between gap-4">
<span className="text-sm text-foreground">{theme.name}</span>
<button onClick={() => toggleBuiltinTheme(theme.id)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
);
})}
</div>
</div>
{themes.length > 0 && (
<div className="px-4 py-3">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Admin-deployed Themes</span>
<div className="mt-2 space-y-2">
{themes.map(theme => {
const disabled = (policy.themePolicy?.disabledThemes || []).includes(theme.id);
return (
<div key={theme.id} className="flex items-center justify-between gap-4">
<span className="text-sm text-foreground">{theme.name}</span>
<button onClick={() => toggleAdminTheme(theme.id)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<Palette className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Deployed Themes</h2>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded themes available to all users</p>
</div>
{themes.length === 0 ? (
<div className="p-12 text-center">
<Palette className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No themes installed</p>
<p className="text-xs text-muted-foreground mt-1">Upload a theme ZIP file to get started</p>
</div>
) : (
<div className="divide-y divide-border">
{themes.map(theme => (
<div key={theme.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">{theme.name}</span>
<span className="text-xs text-muted-foreground">v{theme.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${theme.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
{theme.enabled ? 'Enabled' : 'Disabled'}
</span>
{theme.forceEnabled && (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
<Lock className="w-3 h-3" /> Forced
</span>
)}
</div>
{theme.description && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{theme.description}</p>
)}
<div className="text-xs text-muted-foreground mt-1">
by {theme.author} &middot; {theme.variants.join(', ')} &middot; installed {new Date(theme.installedAt).toLocaleDateString()}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => toggleForceEnabled(theme.id, !theme.forceEnabled)}
title={theme.forceEnabled ? 'Remove force-enable (users can deactivate)' : 'Force enable (users cannot deactivate)'}
className={`p-2 rounded-md transition-colors ${theme.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
>
{theme.forceEnabled ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
</button>
<button
onClick={() => toggleTheme(theme.id, !theme.enabled)}
title={theme.enabled ? 'Disable' : 'Enable'}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<Power className="w-4 h-4" />
</button>
<button
onClick={() => deleteTheme(theme.id, theme.name)}
title="Remove"
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
+237
View File
@@ -0,0 +1,237 @@
'use client';
import { useEffect, useState } from 'react';
import {
Loader2,
RefreshCw,
CheckCircle2,
AlertTriangle,
ShieldAlert,
ExternalLink,
} from 'lucide-react';
import { SettingsSection, SettingItem } from '@/components/settings/settings-section';
import { apiFetch } from '@/lib/browser-navigation';
import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
interface VersionAdminStatus {
current: string;
build: string;
endpoint: string;
defaultEndpoint: string;
disabledByEnv: boolean;
lastCheckedAt: string | null;
lastSuccessAt: string | null;
nextScheduledAt: string | null;
status: UpdateStatus | null;
}
function timeAgo(iso: string | null): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
if (d < 0) return new Date(iso).toLocaleString();
const m = Math.floor(d / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m} min ago`;
const h = Math.floor(m / 60);
if (h < 48) return `${h} hours ago`;
return `${Math.floor(h / 24)} days ago`;
}
function severityChip(severity: UpdateSeverity) {
switch (severity) {
case 'security':
return {
label: 'Security update',
className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
Icon: ShieldAlert,
};
case 'deprecated':
return {
label: 'Deprecated',
className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
Icon: ShieldAlert,
};
case 'normal':
return {
label: 'Update available',
className: 'bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/30',
Icon: AlertTriangle,
};
case 'unknown':
return {
label: 'Unknown',
className: 'bg-muted text-muted-foreground border-border',
Icon: AlertTriangle,
};
case 'none':
default:
return {
label: 'Up to date',
className: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/30',
Icon: CheckCircle2,
};
}
}
export function VersionTab() {
const [data, setData] = useState<VersionAdminStatus | null>(null);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [checkResult, setCheckResult] = useState<{ ok: boolean; msg: string } | null>(null);
async function refresh(): Promise<void> {
setLoading(true);
try {
const r = await apiFetch('/api/admin/version');
if (!r.ok) throw new Error('failed to load');
setData((await r.json()) as VersionAdminStatus);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
useEffect(() => { void refresh(); }, []);
async function checkNow(): Promise<void> {
setChecking(true);
setCheckResult(null);
try {
const r = await apiFetch('/api/admin/version', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'check-now' }),
});
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; error?: string };
setCheckResult({
ok: !!j.ok,
msg: j.ok ? 'Update check completed.' : `Failed: ${j.error ?? 'unknown'}`,
});
await refresh();
} finally {
setChecking(false);
}
}
if (loading || !data) {
return (
<div className="p-8 flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> loading
</div>
);
}
const status = data.status;
const chip = severityChip(status?.severity ?? 'none');
const ChipIcon = chip.Icon;
const releaseUrl = status?.url ?? null;
const newer = status?.latest && status.latest !== data.current ? status.latest : null;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Version</h1>
<p className="text-sm text-muted-foreground mt-1">
Hourly check against the Bulwark version server. Severity is decided server-side and
disable with <code>BULWARK_UPDATE_CHECK=off</code>.
</p>
</div>
<button
type="button"
disabled={checking}
onClick={() => void checkNow()}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{checking ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Check now
</button>
</div>
{checkResult && (
<div
className={`text-sm rounded-md px-3 py-2 ${
checkResult.ok
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
: 'bg-destructive/10 text-destructive'
}`}
>
{checkResult.msg}
</div>
)}
<SettingsSection title="Status">
<SettingItem label="Severity">
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${chip.className}`}
>
<ChipIcon className="h-3 w-3" />
{chip.label}
</span>
</SettingItem>
<SettingItem label="Running" description={data.build !== 'unknown' ? `Build ${data.build}` : undefined}>
<span className="text-sm font-mono text-foreground">{data.current}</span>
</SettingItem>
{newer && (
<SettingItem label="Latest release">
{releaseUrl ? (
<a
href={releaseUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm font-mono text-foreground hover:underline"
>
{newer} <ExternalLink className="w-3 h-3" />
</a>
) : (
<span className="text-sm font-mono text-foreground">{newer}</span>
)}
</SettingItem>
)}
{status?.advisory && (
<SettingItem label="Advisory">
<span className="text-sm font-mono text-red-600 dark:text-red-400">{status.advisory}</span>
</SettingItem>
)}
</SettingsSection>
<SettingsSection title="Schedule" description="Hourly polling with ±5 minute jitter.">
<SettingItem label="Last checked">
<span className="text-sm text-foreground">{timeAgo(data.lastCheckedAt)}</span>
</SettingItem>
<SettingItem label="Last success">
<span className="text-sm text-foreground">{timeAgo(data.lastSuccessAt)}</span>
</SettingItem>
<SettingItem label="Next scheduled">
<span className="text-sm text-foreground">{timeAgo(data.nextScheduledAt)}</span>
</SettingItem>
{status?.checkedAt && (
<SettingItem label="Server timestamp" description="When the server last refreshed its release list.">
<span className="text-sm text-foreground">{new Date(status.checkedAt).toLocaleString()}</span>
</SettingItem>
)}
</SettingsSection>
<SettingsSection title="Source">
<SettingItem
label="Endpoint"
description={data.endpoint === data.defaultEndpoint ? 'Default endpoint.' : `Default: ${data.defaultEndpoint}`}
>
<a
href={data.endpoint}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm text-foreground hover:underline break-all"
>
{data.endpoint} <ExternalLink className="w-3 h-3 shrink-0" />
</a>
</SettingItem>
<SettingItem label="Disabled by env" description="Set BULWARK_UPDATE_CHECK=off to disable.">
<span className={`text-sm font-medium ${data.disabledByEnv ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'}`}>
{data.disabledByEnv ? 'Yes' : 'No'}
</span>
</SettingItem>
</SettingsSection>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=auth');
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=branding');
}
+117
View File
@@ -0,0 +1,117 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Lock } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
export default function ChangePasswordPage() {
const router = useRouter();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setSuccess(false);
if (newPassword.length < 8) {
setError('New password must be at least 8 characters.');
return;
}
if (newPassword !== confirmPassword) {
setError('New passwords do not match.');
return;
}
setLoading(true);
const res = await apiFetch('/api/admin/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword }),
});
if (res.ok) {
setSuccess(true);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setTimeout(() => router.push('/admin'), 2000);
} else {
const data = await res.json().catch(() => ({}));
setError(data.error || 'Failed to change password.');
}
setLoading(false);
}
return (
<div className="max-w-md mx-auto space-y-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">Change Password</h1>
<p className="text-sm text-muted-foreground mt-1">Update your admin password.</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Current Password</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
required
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>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">New Password</label>
<input
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
required
minLength={8}
className="w-full h-9 px-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="new-password"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Confirm New Password</label>
<input
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
required
minLength={8}
className="w-full h-9 px-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="new-password"
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
{success && (
<p className="text-sm text-green-600">Password changed. Redirecting...</p>
)}
<button
type="submit"
disabled={loading}
className="w-full h-9 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{loading ? 'Changing...' : 'Change Password'}
</button>
</form>
</div>
);
}
+479
View File
@@ -0,0 +1,479 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import Link from 'next/link';
import { useAdminTabStore, type AdminTabId } from '@/stores/admin-tab-store';
import {
LayoutDashboard,
Settings,
Palette,
Shield,
Scale,
ScrollText,
LogOut,
KeyRound,
Puzzle,
SwatchBook,
Activity,
Package,
Mail,
Calendar,
BookUser,
HardDrive,
Store,
Menu,
X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useConfig } from '@/hooks/use-config';
import { usePolicyStore } from '@/stores/policy-store';
import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
// Single-page tab navigation: clicks update a Zustand store. The URL stays
// at /admin so React doesn't fire a route transition on every tab switch -
// matches the regular settings page pattern, fixes the dev-mode "Rendering…"
// hang we saw with both /admin/<segment> routes and ?tab= search params.
const NAV_GROUPS: ReadonlyArray<{
label: string;
items: ReadonlyArray<{ tab: AdminTabId; label: string; icon: typeof LayoutDashboard }>;
}> = [
{
label: 'Overview',
items: [
{ tab: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
],
},
{
label: 'Configuration',
items: [
{ tab: 'settings', label: 'Settings', icon: Settings },
{ tab: 'branding', label: 'Branding', icon: Palette },
{ tab: 'auth', label: 'Authentication', icon: Shield },
{ tab: 'policy', label: 'Policy', icon: Scale },
],
},
{
label: 'Extensions',
items: [
{ tab: 'plugins', label: 'Plugins', icon: Puzzle },
{ tab: 'themes', label: 'Themes', icon: SwatchBook },
{ tab: 'marketplace', label: 'Marketplace', icon: Store },
],
},
{
label: 'System',
items: [
{ tab: 'version', label: 'Version', icon: Package },
{ tab: 'telemetry', label: 'Telemetry', icon: Activity },
{ tab: 'logs', label: 'Audit Log', icon: ScrollText },
],
},
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const storeActiveTab = useAdminTabStore((s) => s.activeTab);
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
// Highlight the active tab only on /admin itself - on dynamic routes
// (e.g. /admin/plugins/[id]) no tab is "current".
const activeTab = pathname === '/admin' ? storeActiveTab : null;
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const logoUrl = withBasePath(resolvedTheme === 'dark'
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl));
// Match the navigation rail: red for security/deprecated, amber for normal.
const hasUpdate = useUpdateStore(selectHasUpdate);
const updateSeverity = useUpdateStore((s) => s.status?.severity);
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
useEffect(() => {
setMobileNavOpen(false);
}, [pathname]);
useEffect(() => {
if (!mobileNavOpen) return;
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previous;
};
}, [mobileNavOpen]);
useEffect(() => {
if (pathname === '/admin/login') return;
let cancelled = false;
async function checkAuth() {
try {
const jmapHeaders = getActiveAccountSlotHeaders();
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
const data = await res.json();
if (cancelled) return;
const stalwartAdmin = data.stalwartAdmin === true;
setIsStalwartAdmin(stalwartAdmin);
// If neither password-based admin nor Stalwart admin, redirect away
if (!data.enabled && !stalwartAdmin) {
router.replace('/');
return;
}
if (data.authenticated) {
setAuthenticated(true);
return;
}
// If Stalwart admin but not yet authenticated, auto-login
if (stalwartAdmin) {
const loginRes = await apiFetch('/api/admin/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
body: JSON.stringify({ stalwartAuth: true }),
});
if (cancelled) return;
if (loginRes.ok) {
setAuthenticated(true);
return;
}
const body = await loginRes.json().catch(() => ({}));
setAuthError(body?.error || `Admin auto-login failed (HTTP ${loginRes.status})`);
setAuthenticated(false);
return;
}
router.replace('/admin/login');
} catch (err) {
if (cancelled) return;
setAuthError(err instanceof Error ? err.message : 'Network error during admin check');
setAuthenticated(false);
}
}
checkAuth();
return () => { cancelled = true; };
}, [pathname, router]);
async function handleLogout() {
await apiFetch('/api/admin/auth', { method: 'DELETE' });
router.replace('/admin/login');
}
// Don't gate the login page
if (pathname === '/admin/login') {
return <>{children}</>;
}
// /admin lives outside the [locale] tree, so links back to the webmail
// apps are bare <a> tags (hard navigation). Next.js only auto-applies
// basePath to <Link>/router APIs - for these we prepend it manually so
// NEXT_PUBLIC_BASE_PATH=/webmail deployments don't redirect to "/".
const prefix = getPathPrefix();
const navContent = (
<>
<div className="flex-1 overflow-y-auto py-2">
<div className="px-2 space-y-0.5">
{NAV_GROUPS.map((group, groupIndex) => (
<div key={group.label}>
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
<div className="px-3 pt-2.5 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map(({ tab, label, icon: Icon }) => {
const active = activeTab === tab;
const showDot = tab === 'version' && hasUpdate;
const handleClick = () => {
setActiveTab(tab);
// From a dynamic route (/admin/plugins/[id], /admin/marketplace/[slug])
// we still need a real navigation back to /admin so the page renders.
if (pathname !== '/admin') router.push('/admin');
};
return (
<button
key={tab}
type="button"
onClick={handleClick}
className={cn(
'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'
)}
>
<span className="relative shrink-0">
<Icon className={cn(
'w-4 h-4',
active ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{showDot && (
<span
className={cn(
'absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2',
active ? 'ring-accent' : 'ring-background',
updateImportant ? 'bg-red-500' : 'bg-amber-500',
)}
aria-label={updateImportant ? 'Important update available' : 'Update available'}
/>
)}
</span>
{label}
</button>
);
})}
</div>
))}
</div>
</div>
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
{!isStalwartAdmin && (
<Link
href="/admin/change-password"
className={cn(
'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'
)}
>
<KeyRound className={cn(
'w-4 h-4 shrink-0',
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
Change Password
</Link>
)}
<button
onClick={handleLogout}
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
</button>
</div>
</>
);
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-e border-border sticky top-0 h-screen shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
) : (
<div className="w-7 h-7 mb-2" />
)}
<a
href={`${prefix}/`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Mail"
>
<Mail className="w-[18px] h-[18px]" />
</a>
<a
href={`${prefix}/calendar`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Calendar"
>
<Calendar className="w-[18px] h-[18px]" />
</a>
<a
href={`${prefix}/contacts`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Contacts"
>
<BookUser className="w-[18px] h-[18px]" />
</a>
{filesEnabled && (
<a
href={`${prefix}/files`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Files"
>
<HardDrive className="w-[18px] h-[18px]" />
</a>
)}
<div className="mt-auto flex flex-col items-center gap-2">
<div className="flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary" title="Admin">
<Shield className="w-[18px] h-[18px]" />
</div>
<a
href={`${prefix}/settings`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Settings"
>
<Settings className="w-[18px] h-[18px]" />
</a>
</div>
</nav>
{/* Admin Sidebar (desktop only) */}
<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 me-2" />
) : (
<Shield className="w-5 h-5 text-primary me-2" />
)}
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
</div>
{navContent}
</aside>
{/* Mobile drawer overlay */}
{mobileNavOpen && (
<div
className="md:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
onClick={() => setMobileNavOpen(false)}
aria-hidden="true"
/>
)}
{/* Mobile drawer */}
<aside
className={cn(
'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"
aria-hidden={!mobileNavOpen}
>
<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 me-2" />
) : (
<Shield className="w-5 h-5 text-primary me-2" />
)}
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
</div>
<button
type="button"
onClick={() => setMobileNavOpen(false)}
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label="Close navigation"
>
<X className="w-5 h-5" />
</button>
</div>
{navContent}
</aside>
{/* Main content */}
<main className="flex-1 min-w-0 overflow-x-hidden">
{/* Mobile header */}
<div className="md:hidden sticky top-0 z-30 h-14 flex items-center gap-2 px-3 border-b border-border bg-background">
<button
type="button"
onClick={() => setMobileNavOpen(true)}
className="flex items-center justify-center w-9 h-9 rounded-md text-foreground hover:bg-muted transition-colors"
aria-label="Open navigation"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center min-w-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
) : (
<Shield className="w-5 h-5 text-primary me-2" />
)}
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
</div>
</div>
<div className="max-w-4xl mx-auto p-4 md:p-6 pb-[calc(4rem+env(safe-area-inset-bottom))] md:pb-6">
{authError ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
<p className="font-medium">Admin authentication failed</p>
<p className="mt-1 text-destructive/80">{authError}</p>
</div>
) : authenticated === null ? (
<div className="py-12 text-center text-sm text-muted-foreground animate-pulse">
Loading admin panel
</div>
) : authenticated ? (
children
) : null}
</div>
</main>
{/* Mobile bottom nav (main webmail nav) */}
<nav
className="md:hidden fixed inset-x-0 bottom-0 z-30 flex items-center bg-background border-t border-border pb-[env(safe-area-inset-bottom)]"
aria-label="Main navigation"
>
<a
href={`${prefix}/`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Mail"
>
<Mail className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Mail</span>
</a>
<a
href={`${prefix}/calendar`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Calendar"
>
<Calendar className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Calendar</span>
</a>
<a
href={`${prefix}/contacts`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Contacts"
>
<BookUser className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
</a>
{filesEnabled && (
<a
href={`${prefix}/files`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Files"
>
<HardDrive className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
</a>
)}
<div
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary"
title="Admin"
aria-current="page"
>
<div className="relative">
<Shield className="w-5 h-5" />
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
</div>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Admin</span>
</div>
<a
href={`${prefix}/settings`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Settings"
>
<Settings className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Settings</span>
</a>
</nav>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { Shield } from 'lucide-react';
import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store';
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
export default function AdminLoginPage() {
const router = useRouter();
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const logoUrl = withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await apiFetch('/api/admin/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Login failed');
return;
}
router.push('/admin');
} catch {
setError('Network error. Please try again.');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8">
{logoUrl ? (
<img src={logoUrl} alt="" className="h-12 object-contain mb-4" />
) : (
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
<Shield className="w-6 h-6 text-primary" />
</div>
)}
<h1 className="text-xl font-semibold text-foreground">Admin Dashboard</h1>
<p className="text-sm text-muted-foreground mt-1">Enter your admin password to continue</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1.5">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring"
placeholder="Enter admin password"
required
autoFocus
autoComplete="current-password"
/>
</div>
{error && (
<div className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
{error}
</div>
)}
<button
type="submit"
disabled={loading || !password}
className="w-full h-10 rounded-md bg-primary text-primary-foreground font-medium text-sm hover:bg-primary/90 disabled:opacity-50 disabled:pointer-events-none transition-all duration-200 shadow-sm"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=logs');
}
@@ -0,0 +1,620 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import {
ArrowLeft,
ArrowUpCircle,
Download,
Loader2,
Puzzle,
SwatchBook,
Star,
Trash2,
Check,
Settings as SettingsIcon,
ExternalLink,
Shield,
AlertTriangle,
FileCode,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
interface PreviewData {
extension: {
slug: string;
name: string;
type: 'plugin' | 'theme';
pluginType: string | null;
description: string;
longDescription: string | null;
tags: string[];
permissions: string[];
totalDownloads: number;
featured: boolean;
githubRepo: string | null;
license: string | null;
minAppVersion: string | null;
iconUrl: string | null;
bannerUrl: string | null;
author: {
displayName: string;
githubLogin: string;
avatarUrl: string | null;
verified?: boolean;
} | null;
latestVersion: string | null;
versions: Array<{
version: string;
changelog: string | null;
bundleSize: number;
minAppVersion: string | null;
publishedAt: string | null;
permissions: string[];
}>;
screenshots: Array<{ url: string; altText: string | null }>;
themePreviews: Array<{
variant: 'light' | 'dark';
previewPath: string;
colors: Record<string, string> | null;
}>;
createdAt: string | null;
updatedAt: string | null;
};
bundle: {
manifest: Record<string, unknown> | null;
source: { name: string; content: string; truncated: boolean } | null;
size: number;
error: string | null;
};
installed: boolean;
installedVersion: string | null;
}
const RISKY_PERMISSIONS = new Set([
'mail:write',
'mail:delete',
'storage:write',
'network',
'admin',
]);
export default function MarketplacePreviewPage() {
const params = useParams();
const slug = params.slug as string;
const [data, setData] = useState<PreviewData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [installing, setInstalling] = useState(false);
const [uninstalling, setUninstalling] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [showSource, setShowSource] = useState(false);
const [showManifest, setShowManifest] = useState(false);
const fetchPreview = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await apiFetch(`/api/admin/marketplace/${encodeURIComponent(slug)}`);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
setError(body.error || 'Failed to load preview');
return;
}
setData(await res.json());
} catch {
setError('Failed to connect to extension directory');
} finally {
setLoading(false);
}
}, [slug]);
useEffect(() => { fetchPreview(); }, [fetchPreview]);
async function handleInstall() {
if (!data) return;
const isUpdate = data.installed;
const targetVersion = data.extension.latestVersion || '1.0.0';
setInstalling(true);
setMessage(null);
try {
const res = await apiFetch('/api/admin/marketplace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug: data.extension.slug,
version: targetVersion,
type: data.extension.type,
}),
});
const body = await res.json();
if (res.ok) {
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
setMessage({
type: 'success',
text: isUpdate
? `"${data.extension.name}" updated to v${targetVersion}${warnings}`
: `"${data.extension.name}" installed${warnings}`,
});
setData(prev => prev ? { ...prev, installed: true, installedVersion: targetVersion } : prev);
} else {
setMessage({ type: 'error', text: body.error || (isUpdate ? 'Update failed' : 'Installation failed') });
}
} catch {
setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
} finally {
setInstalling(false);
}
}
async function handleUninstall() {
if (!data) return;
if (!confirm(`Remove "${data.extension.name}"? This cannot be undone.`)) return;
setUninstalling(true);
setMessage(null);
try {
const endpoint = data.extension.type === 'theme'
? '/api/admin/themes'
: '/api/admin/plugins';
const res = await apiFetch(endpoint, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: data.extension.slug }),
});
const body = await res.json().catch(() => ({}));
if (res.ok) {
setMessage({ type: 'success', text: `"${data.extension.name}" removed` });
setData(prev => prev ? { ...prev, installed: false } : prev);
} else {
setMessage({ type: 'error', text: body.error || 'Uninstall failed' });
}
} catch {
setMessage({ type: 'error', text: 'Uninstall failed - network error' });
} finally {
setUninstalling(false);
}
}
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 me-2" />
Loading...
</div>
);
}
if (error || !data) {
return (
<div className="space-y-4">
<Link
href="/admin/marketplace"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
</Link>
<p className="text-sm text-destructive">{error || 'Extension not found'}</p>
</div>
);
}
const ext = data.extension;
const bundle = data.bundle;
const isPlugin = ext.type === 'plugin';
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || [];
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
const versionMismatch = !!ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion);
const updateAvailable = data.installed
&& !!data.installedVersion
&& !!ext.latestVersion
&& compareVersions(ext.latestVersion, data.installedVersion) > 0
&& !versionMismatch;
return (
<div className="space-y-6 max-w-4xl">
{/* Back link */}
<Link
href="/admin/marketplace"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
</Link>
{/* Banner / hero */}
{ext.bannerUrl && (
<div className="mb-6 overflow-hidden rounded-lg border border-border bg-muted">
<img
src={ext.bannerUrl}
alt=""
className="block w-full max-h-64 object-cover"
loading="lazy"
/>
</div>
)}
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="flex items-start gap-4 flex-1 min-w-0">
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0 overflow-hidden">
{ext.iconUrl ? (
<img
src={ext.iconUrl}
alt=""
className="w-14 h-14 object-cover"
loading="lazy"
/>
) : isPlugin ? (
<Puzzle className="w-7 h-7 text-muted-foreground" />
) : (
<SwatchBook className="w-7 h-7 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<h1 className="text-2xl font-semibold text-foreground break-words min-w-0">{ext.name}</h1>
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
{data.installed && !updateAvailable && (
<span
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium"
title={data.installedVersion ? `Installed: v${data.installedVersion}` : undefined}
>
<Check className="w-3 h-3" /> Installed
</span>
)}
{data.installed && updateAvailable && (
<span
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400 font-medium"
title={`Installed v${data.installedVersion} → v${ext.latestVersion} available`}
>
<ArrowUpCircle className="w-3 h-3" /> Update available
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
isPlugin
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
}`}>
{isPlugin ? (ext.pluginType || 'plugin') : 'theme'}
</span>
{ext.author && (
<span>by {ext.author.displayName}</span>
)}
{ext.latestVersion && <span>v{ext.latestVersion}</span>}
{ext.license && <span>{ext.license}</span>}
<span className="inline-flex items-center gap-1">
<Download className="w-3 h-3" />
{ext.totalDownloads.toLocaleString()}
</span>
</div>
</div>
</div>
{/* Action buttons */}
<div className="flex flex-wrap items-center gap-2 shrink-0">
{data.installed ? (
<>
{updateAvailable && (
<button
onClick={handleInstall}
disabled={installing || !!bundle.error}
title={`Update from v${data.installedVersion} to v${ext.latestVersion}`}
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowUpCircle className="w-4 h-4" />}
Update to v{ext.latestVersion}
</button>
)}
<Link
href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'}
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
<SettingsIcon className="w-4 h-4" />
Manage
</Link>
<button
onClick={handleUninstall}
disabled={uninstalling}
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50 transition-colors"
>
{uninstalling ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Uninstall
</button>
</>
) : (
<button
onClick={handleInstall}
disabled={installing || !!bundle.error || versionMismatch}
title={versionMismatch
? `Requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}. Update Bulwark to install.`
: undefined}
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
Install
</button>
)}
</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}
</div>
)}
{versionMismatch && (
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div>
<p className="font-medium">Update Bulwark to install this extension</p>
<p className="text-xs mt-0.5 opacity-90">
Requires app v{ext.minAppVersion}+. You are running v{CURRENT_APP_VERSION}.
</p>
</div>
</div>
)}
{bundle.error && (
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div>
<p className="font-medium">Could not preview bundle</p>
<p className="text-xs mt-0.5 opacity-90">{bundle.error}</p>
</div>
</div>
)}
{/* Description */}
<section className="border border-border rounded-lg p-4">
<h2 className="text-sm font-medium text-foreground">About</h2>
<p className="text-sm text-muted-foreground mt-2">{ext.description}</p>
{ext.longDescription && ext.longDescription !== ext.description && (
<p className="text-sm text-muted-foreground mt-3 whitespace-pre-wrap">{ext.longDescription}</p>
)}
{ext.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{ext.tags.map(tag => (
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{tag}
</span>
))}
</div>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-4 pt-3 border-t border-border flex-wrap">
{ext.minAppVersion && <span>Requires app v{ext.minAppVersion}+</span>}
{bundle.size > 0 && <span>Bundle: {(bundle.size / 1024).toFixed(1)} KB</span>}
{ext.githubRepo && (
<a
href={`https://github.com/${ext.githubRepo}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 hover:text-foreground"
>
<ExternalLink className="w-3 h-3" />
{ext.githubRepo}
</a>
)}
</div>
</section>
{/* Screenshots */}
{ext.screenshots.length > 0 && (
<section className="border border-border rounded-lg p-4">
<h2 className="text-sm font-medium text-foreground">Screenshots</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
{ext.screenshots.map((s, i) => (
<img
key={i}
src={s.url}
alt={s.altText || `Screenshot ${i + 1}`}
className="w-full rounded-md border border-border bg-muted"
loading="lazy"
/>
))}
</div>
</section>
)}
{/* Theme color preview */}
{!isPlugin && ext.themePreviews.length > 0 && (
<section className="border border-border rounded-lg p-4">
<h2 className="text-sm font-medium text-foreground">Theme preview</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
{ext.themePreviews.map(preview => (
<ThemeColorSwatch key={preview.variant} preview={preview} />
))}
</div>
</section>
)}
{/* Permissions */}
{isPlugin && (
<section className="border border-border rounded-lg p-4">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Permissions</h2>
</div>
{manifestPerms.length === 0 ? (
<p className="text-sm text-muted-foreground mt-2">This plugin requests no permissions.</p>
) : (
<ul className="mt-3 space-y-1.5">
{manifestPerms.map(perm => {
const risky = RISKY_PERMISSIONS.has(perm);
return (
<li
key={perm}
className={`flex items-center gap-2 text-sm rounded-md px-2 py-1 ${
risky
? 'bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300'
: 'bg-muted/50 text-foreground'
}`}
>
{risky && <AlertTriangle className="w-3.5 h-3.5 shrink-0" />}
<code className="font-mono text-xs">{perm}</code>
</li>
);
})}
</ul>
)}
{frameOrigins.length > 0 && (
<div className="mt-4 pt-3 border-t border-border">
<h3 className="text-xs font-medium text-foreground">Iframe origins</h3>
<p className="text-xs text-muted-foreground mt-0.5">
The plugin will be allowed to embed content from these origins.
</p>
<ul className="mt-2 space-y-1">
{frameOrigins.map(origin => (
<li key={origin} className="text-xs font-mono text-foreground bg-muted/50 px-2 py-1 rounded">
{origin}
</li>
))}
</ul>
</div>
)}
</section>
)}
{/* Settings schema preview */}
{isPlugin && settingsSchema && Object.keys(settingsSchema).length > 0 && (
<section className="border border-border rounded-lg p-4">
<h2 className="text-sm font-medium text-foreground">User settings</h2>
<p className="text-xs text-muted-foreground mt-0.5">Settings users will be able to configure after install.</p>
<ul className="mt-3 divide-y divide-border">
{Object.entries(settingsSchema).map(([key, field]) => (
<li key={key} className="py-2">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-foreground">{key}</code>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{field.type}</span>
</div>
<div className="text-sm text-foreground mt-0.5">{field.label}</div>
{field.description && (
<div className="text-xs text-muted-foreground mt-0.5">{field.description}</div>
)}
</li>
))}
</ul>
</section>
)}
{/* Source / manifest disclosure */}
{bundle.manifest && (
<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-start hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-2">
<FileCode className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">manifest.json</h2>
</div>
{showManifest ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</button>
{showManifest && (
<pre className="px-4 pb-4 text-xs font-mono overflow-x-auto text-foreground whitespace-pre">
{JSON.stringify(bundle.manifest, null, 2)}
</pre>
)}
</section>
)}
{bundle.source && (
<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-start hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-2">
<FileCode className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">{bundle.source.name}</h2>
{bundle.source.truncated && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400">truncated</span>
)}
</div>
{showSource ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</button>
{showSource && (
<pre className="px-4 pb-4 text-xs font-mono overflow-x-auto text-foreground whitespace-pre max-h-[600px] overflow-y-auto">
{bundle.source.content}
</pre>
)}
</section>
)}
{/* Version history */}
{ext.versions.length > 0 && (
<section className="border border-border rounded-lg p-4">
<h2 className="text-sm font-medium text-foreground">Version history</h2>
<ul className="mt-3 divide-y divide-border">
{ext.versions.slice(0, 5).map(v => (
<li key={v.version} className="py-2 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-foreground">v{v.version}</code>
{v.publishedAt && (
<span className="text-xs text-muted-foreground">
{new Date(v.publishedAt).toLocaleDateString()}
</span>
)}
</div>
{v.changelog && (
<p className="text-xs text-muted-foreground mt-0.5 whitespace-pre-wrap">{v.changelog}</p>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0">
{(v.bundleSize / 1024).toFixed(1)} KB
</span>
</li>
))}
</ul>
</section>
)}
</div>
);
}
function ThemeColorSwatch({ preview }: { preview: { variant: 'light' | 'dark'; colors: Record<string, string> | null } }) {
const colors = preview.colors || {};
const bg = colors.background || (preview.variant === 'dark' ? '#0f0f10' : '#ffffff');
const fg = colors.foreground || (preview.variant === 'dark' ? '#fafafa' : '#0a0a0a');
const accent = colors.primary || colors.accent || '#7c5cff';
const muted = colors.muted || (preview.variant === 'dark' ? '#1a1a1c' : '#f5f5f5');
const border = colors.border || (preview.variant === 'dark' ? '#27272a' : '#e5e5e5');
return (
<div className="rounded-md border border-border overflow-hidden">
<div className="px-3 py-2 text-xs font-medium text-muted-foreground bg-muted/30 border-b border-border capitalize">
{preview.variant}
</div>
<div className="p-3 space-y-2" style={{ background: bg, color: fg }}>
<div className="flex items-center gap-2">
<span className="inline-block w-6 h-6 rounded" style={{ background: accent }} />
<span className="text-sm font-medium" style={{ color: fg }}>Sample text</span>
</div>
<div className="rounded p-2 text-xs" style={{ background: muted, border: `1px solid ${border}` }}>
<span style={{ color: fg }}>Card surface</span>
</div>
<div className="flex flex-wrap gap-1">
{Object.entries(colors).slice(0, 6).map(([key, value]) => (
<span
key={key}
title={`${key}: ${value}`}
className="inline-block w-4 h-4 rounded border"
style={{ background: value, borderColor: border }}
/>
))}
</div>
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=marketplace');
}
+49
View File
@@ -0,0 +1,49 @@
'use client';
import { useEffect } from 'react';
import { useAdminTabStore, isAdminTab } from '@/stores/admin-tab-store';
import { DashboardTab } from './_tabs/dashboard';
import { SettingsTab } from './_tabs/settings';
import { BrandingTab } from './_tabs/branding';
import { AuthTab } from './_tabs/auth';
import { PolicyTab } from './_tabs/policy';
import { PluginsTab } from './_tabs/plugins';
import { ThemesTab } from './_tabs/themes';
import { MarketplaceTab } from './_tabs/marketplace';
import { VersionTab } from './_tabs/version';
import { TelemetryTab } from './_tabs/telemetry';
import { LogsTab } from './_tabs/logs';
export default function AdminPage() {
const activeTab = useAdminTabStore((s) => s.activeTab);
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
// Honour deep links from the old route structure: /admin?tab=settings
// (emitted by the redirect pages in /admin/<x>/page.tsx) sets the store
// once on mount, then strips the param so the URL stays at /admin and
// subsequent tab clicks don't accumulate query strings.
useEffect(() => {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
const fromUrl = url.searchParams.get('tab');
if (isAdminTab(fromUrl)) {
setActiveTab(fromUrl);
url.searchParams.delete('tab');
window.history.replaceState(null, '', url.pathname + url.search + url.hash);
}
}, [setActiveTab]);
switch (activeTab) {
case 'dashboard': return <DashboardTab />;
case 'settings': return <SettingsTab />;
case 'branding': return <BrandingTab />;
case 'auth': return <AuthTab />;
case 'policy': return <PolicyTab />;
case 'plugins': return <PluginsTab />;
case 'themes': return <ThemesTab />;
case 'marketplace': return <MarketplaceTab />;
case 'version': return <VersionTab />;
case 'telemetry': return <TelemetryTab />;
case 'logs': return <LogsTab />;
}
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from 'next/navigation';
// Inline panel handles plugin config now - see _tabs/plugin-config-panel.tsx.
// Old deep links land on the plugins tab; the user clicks the gear again.
export default function Page() {
redirect('/admin?tab=plugins');
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=plugins');
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=policy');
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=settings');
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=telemetry');
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=themes');
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=version');
}
@@ -44,7 +44,7 @@ export default function GlobalError({
onClick={reset} onClick={reset}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" 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 Try again
</button> </button>
</div> </div>
+139
View File
@@ -0,0 +1,139 @@
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, 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"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
export async function generateMetadata(): Promise<Metadata> {
await configManager.ensureLoaded();
// 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: 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",
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
},
formatDetection: {
telephone: false,
},
icons: { icon: withBasePath(faviconUrl) },
};
}
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const locale = await resolveRequestLocale();
const nonce = (await headers()).get("x-nonce") ?? "";
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
return (
<html lang={locale} dir={getLocaleDirection(locale)} suppressHydrationWarning>
<head>
<meta name="theme-color" content="#ffffff" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-title"
content={process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail"}
/>
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
{parentOrigin && (
<meta name="parent-origin" content={parentOrigin} />
)}
<script
nonce={nonce}
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
const stored = localStorage.getItem('theme-storage');
const theme = stored ? JSON.parse(stored).state.theme : 'system';
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
const resolved = theme === 'system' ? systemTheme : theme;
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(resolved);
} catch (e) {
document.documentElement.classList.add('light');
}
})();
`,
}}
/>
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ServiceWorkerRegistration />
{children}
</body>
</html>
);
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { useEffect } from "react";
import { useAuthStore } from "@/stores/auth-store";
import { getPathPrefix } from "@/lib/browser-navigation";
export default function NotFound() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
useEffect(() => {
if (!isAuthenticated) {
const prefix = getPathPrefix();
// Don't redirect admin routes to the webmail login page. Admin paths
// are mounted relative to the deployment prefix, so account for it.
const adminBase = `${prefix}/admin`;
const isAdminRoute = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
if (!isAdminRoute) {
window.location.href = `${prefix}/login`;
}
}
}, [isAuthenticated]);
if (!isAuthenticated) {
let isAdmin = false;
if (typeof window !== 'undefined') {
const prefix = getPathPrefix();
const adminBase = `${prefix}/admin`;
isAdmin = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
}
if (!isAdmin) return null;
}
const prefix = typeof window !== 'undefined' ? getPathPrefix() : '';
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center max-w-md px-4">
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
<p className="text-muted-foreground mb-6">This page could not be found.</p>
<a
href={`${prefix}/`}
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
>
Go home
</a>
</div>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { getTranslations } from "next-intl/server";
import { MailtoProtocolClient } from "@/components/protocol/mailto-protocol-client";
export default async function MailtoProtocolPage() {
const t = await getTranslations("protocol_handlers");
return <MailtoProtocolClient openingText={t("opening_mailto")} />;
}
+8
View File
@@ -0,0 +1,8 @@
import { getTranslations } from "next-intl/server";
import { WebcalProtocolClient } from "@/components/protocol/webcal-protocol-client";
export default async function WebcalProtocolPage() {
const t = await getTranslations("protocol_handlers");
return <WebcalProtocolClient openingText={t("opening_webcal")} />;
}
+5
View File
@@ -0,0 +1,5 @@
import type { ReactNode } from 'react';
export default function SetupLayout({ children }: { children: ReactNode }) {
return <div className="min-h-screen bg-background text-foreground">{children}</div>;
}
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
// 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',
robots: { index: false, follow: false },
};
export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<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 />;
}
+10
View File
@@ -0,0 +1,10 @@
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in
// Next's injected hydration/chunk scripts. With force-static, those scripts
// render without a nonce and the strict sandbox CSP blocks them.
export const dynamic = 'force-dynamic';
export default function PluginSandboxPage() {
return <SandboxRuntime />;
}
-123
View File
@@ -1,123 +0,0 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useAuthStore } from "@/stores/auth-store";
import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useParams } from "next/navigation";
function OAuthCallbackInner() {
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
const t = useTranslations("login");
const { loginWithOAuth } = useAuthStore();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const code = searchParams.get("code");
const state = searchParams.get("state");
const errorParam = searchParams.get("error");
if (errorParam) {
setError(errorParam === "access_denied" ? "access_denied" : "token_exchange_failed");
return;
}
if (!code) {
setError("missing_params");
return;
}
const savedState = sessionStorage.getItem("oauth_state");
if (!state || state !== savedState) {
setError("invalid_state");
return;
}
const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
const serverUrl = sessionStorage.getItem("oauth_server_url");
if (!codeVerifier || !serverUrl) {
setError("missing_params");
return;
}
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri)
.then((success) => {
if (success) {
sessionStorage.removeItem("oauth_state");
sessionStorage.removeItem("oauth_code_verifier");
sessionStorage.removeItem("oauth_server_url");
sessionStorage.removeItem("oauth_add_account_mode");
let redirectTo = `/${params.locale}`;
try {
const saved = sessionStorage.getItem('redirect_after_login');
if (saved) {
sessionStorage.removeItem('redirect_after_login');
redirectTo = saved;
}
} catch { /* sessionStorage may be unavailable */ }
router.push(redirectTo);
} else {
setError("token_exchange_failed");
}
})
.catch(() => {
setError("token_exchange_failed");
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="w-full max-w-sm mx-auto px-4 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-red-500/10 mb-6">
<AlertCircle className="w-10 h-10 text-red-500" />
</div>
<h1 className="text-xl font-medium text-foreground mb-2">
{t("oauth_error.title")}
</h1>
<p className="text-muted-foreground text-sm mb-6">
{t(`oauth_error.${error}`)}
</p>
<Button
variant="outline"
onClick={() => router.push(`/${params.locale}/login`)}
>
{t("oauth_error.back_to_login")}
</Button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="w-full max-w-sm mx-auto px-4 text-center" role="status">
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto mb-4" />
<p className="text-muted-foreground text-sm">{t("oauth_completing")}</p>
</div>
</div>
);
}
export default function OAuthCallbackPage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="w-full max-w-sm mx-auto px-4 text-center" role="status">
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto mb-4" />
</div>
</div>
}
>
<OAuthCallbackInner />
</Suspense>
);
}
-899
View File
@@ -1,899 +0,0 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo, type TouchEvent as ReactTouchEvent } from "react";
import { useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { Plus } from "lucide-react";
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
addMonths, subMonths, addWeeks, subWeeks, addDays, subDays,
format, parseISO,
} from "date-fns";
import { useCalendarStore } from "@/stores/calendar-store";
import { isCalendarViewMode } from "@/stores/calendar-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useIdentityStore } from "@/stores/identity-store";
import { toast } from "@/stores/toast-store";
import { useIsMobile } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
import { CalendarDayView } from "@/components/calendar/calendar-day-view";
import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
import { MiniCalendar } from "@/components/calendar/mini-calendar";
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
import { EventModal } from "@/components/calendar/event-modal";
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { cn } from "@/lib/utils";
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
import { getUserParticipantId } from "@/lib/calendar-participants";
import { debug } from "@/lib/debug";
type PendingScopeAction =
| { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean }
| { type: "delete"; event: CalendarEvent; sendScheduling?: boolean };
function isRecurringEvent(event: CalendarEvent): boolean {
return (event.recurrenceRules?.length ?? 0) > 0 || event.recurrenceId != null;
}
export default function CalendarPage() {
const router = useRouter();
const t = useTranslations("calendar");
const isMobile = useIsMobile();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const {
calendars, events, selectedDate, viewMode, selectedCalendarIds,
isLoading, isLoadingEvents, supportsCalendar, error,
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
refreshAllSubscriptions,
} = useCalendarStore();
const { firstDayOfWeek, timeFormat } = useSettingsStore();
const { identities } = useIdentityStore();
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
const currentUserEmails = useMemo(() =>
identities.map(id => id.email).filter(Boolean),
[identities]
);
const [showEventModal, setShowEventModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
const [miniMonth, setMiniMonth] = useState(new Date());
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
const hasFetched = useRef(false);
// Sidebar resize state
const [calSidebarWidth, setCalSidebarWidth] = useState(() => {
try { const v = localStorage.getItem("calendar-sidebar-width"); return v ? Number(v) : 256; } catch { return 256; }
});
const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(256);
// Swipe navigation ref (handlers defined after navigatePrev/navigateNext)
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
// Check auth on mount
useEffect(() => {
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
router.push("/login");
} else if (client && !supportsCalendar) {
router.push("/");
}
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, router]);
useEffect(() => {
if (error) {
toast.error(error);
}
}, [error]);
useEffect(() => {
if (client && !hasFetched.current) {
hasFetched.current = true;
fetchCalendars(client);
}
}, [client, fetchCalendars]);
// Auto-refresh iCal subscriptions
useEffect(() => {
if (!client) return;
// Refresh on mount (respects per-subscription interval)
refreshAllSubscriptions(client);
// Check again every 5 minutes
const interval = setInterval(() => refreshAllSubscriptions(client), 5 * 60 * 1000);
return () => clearInterval(interval);
}, [client, refreshAllSubscriptions]);
const dateRange = useMemo(() => {
const d = selectedDate;
switch (normalizedViewMode) {
case "month": {
const ms = startOfMonth(d);
const me = endOfMonth(d);
return {
start: format(startOfWeek(ms, { weekStartsOn: firstDayOfWeek }), "yyyy-MM-dd'T'00:00:00"),
end: format(endOfWeek(me, { weekStartsOn: firstDayOfWeek }), "yyyy-MM-dd'T'23:59:59"),
};
}
case "week": {
const ws = startOfWeek(d, { weekStartsOn: firstDayOfWeek });
return {
start: format(ws, "yyyy-MM-dd'T'00:00:00"),
end: format(addDays(ws, 6), "yyyy-MM-dd'T'23:59:59"),
};
}
case "day":
return {
start: format(d, "yyyy-MM-dd'T'00:00:00"),
end: format(d, "yyyy-MM-dd'T'23:59:59"),
};
case "agenda":
return {
start: format(d, "yyyy-MM-dd'T'00:00:00"),
end: format(addDays(d, 30), "yyyy-MM-dd'T'23:59:59"),
};
}
}, [selectedDate, normalizedViewMode, firstDayOfWeek]);
useEffect(() => {
if (client && calendars.length > 0 && dateRange) {
fetchEvents(client, dateRange.start, dateRange.end);
}
}, [client, calendars.length, dateRange, fetchEvents]);
const navigatePrev = useCallback(() => {
let next: Date;
switch (normalizedViewMode) {
case "month": next = subMonths(selectedDate, 1); break;
case "week": next = subWeeks(selectedDate, 1); break;
case "day": next = subDays(selectedDate, 1); break;
case "agenda": next = subMonths(selectedDate, 1); break;
}
setSelectedDate(next);
setMiniMonth(next);
}, [normalizedViewMode, selectedDate, setSelectedDate]);
const navigateNext = useCallback(() => {
let next: Date;
switch (normalizedViewMode) {
case "month": next = addMonths(selectedDate, 1); break;
case "week": next = addWeeks(selectedDate, 1); break;
case "day": next = addDays(selectedDate, 1); break;
case "agenda": next = addMonths(selectedDate, 1); break;
}
setSelectedDate(next);
setMiniMonth(next);
}, [normalizedViewMode, selectedDate, setSelectedDate]);
const goToToday = useCallback(() => {
setSelectedDate(new Date());
setMiniMonth(new Date());
}, [setSelectedDate]);
// Swipe navigation handlers for mobile
const handleTouchStart = useCallback((e: ReactTouchEvent) => {
const touch = e.touches[0];
touchStartRef.current = { x: touch.clientX, y: touch.clientY, time: Date.now() };
}, []);
const handleTouchEnd = useCallback((e: ReactTouchEvent) => {
if (!touchStartRef.current || !isMobile) return;
const touch = e.changedTouches[0];
const dx = touch.clientX - touchStartRef.current.x;
const dy = touch.clientY - touchStartRef.current.y;
const elapsed = Date.now() - touchStartRef.current.time;
touchStartRef.current = null;
// Only trigger swipe if horizontal movement is dominant and fast enough
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5 && elapsed < 400) {
if (dx > 0) navigatePrev();
else navigateNext();
}
}, [isMobile, navigatePrev, navigateNext]);
const handleSelectDate = useCallback((date: Date) => {
setSelectedDate(date);
setMiniMonth(date);
// On mobile month view, tapping a date switches to day view
if (isMobile && normalizedViewMode === "month") {
setViewMode("day");
}
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
const handleMiniMonthChange = useCallback((date: Date) => {
setMiniMonth(date);
setSelectedDate(date);
}, [setSelectedDate]);
const openCreateModal = useCallback((date?: Date, endDate?: Date) => {
setEditEvent(null);
const d = date || selectedDate;
setDefaultModalDate(d);
setDefaultModalEndDate(endDate);
setSelectedDate(d);
setShowEventModal(true);
}, [selectedDate, setSelectedDate]);
const openEditModal = useCallback((event: CalendarEvent) => {
setEditEvent(event);
setDefaultModalDate(undefined);
setShowEventModal(true);
}, []);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeDetail = useCallback(() => {
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
setDetailEvent(null);
setDetailAnchorRect(null);
}, []);
const handleSelectEvent = useCallback((event: CalendarEvent, _anchorRect: DOMRect) => {
// Click opens the sidebar for viewing/editing
closeDetail();
openEditModal(event);
}, [closeDetail, openEditModal]);
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
// Don't show hover popover if the sidebar is already open for this event
if (showEventModal && editEvent?.id === event.id) return;
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}, [showEventModal, editEvent]);
const handleHoverLeave = useCallback(() => {
hoverTimerRef.current = setTimeout(() => {
setDetailEvent(null);
setDetailAnchorRect(null);
}, 200);
}, []);
const handleEditFromDetail = useCallback(() => {
if (detailEvent) {
const ev = detailEvent;
closeDetail();
openEditModal(ev);
}
}, [detailEvent, closeDetail, openEditModal]);
const findMasterEvent = useCallback(async (occurrence: CalendarEvent): Promise<CalendarEvent | null> => {
if ((occurrence.recurrenceRules?.length ?? 0) > 0 && !occurrence.recurrenceId) {
return occurrence;
}
const master = events.find(e =>
e.uid === occurrence.uid && !e.recurrenceId && (e.recurrenceRules?.length ?? 0) > 0
);
if (master) return master;
if (!client) return null;
try {
const results = await client.queryCalendarEvents({ uid: occurrence.uid });
return results.find(e => !e.recurrenceId && (e.recurrenceRules?.length ?? 0) > 0) || null;
} catch (error) {
debug.error("Failed to query master event for UID:", occurrence.uid, error);
throw error;
}
}, [events, client]);
const refetchCurrentRange = useCallback(async () => {
if (!client) return;
const { dateRange: currentRange } = useCalendarStore.getState();
if (currentRange) {
await fetchEvents(client, currentRange.start, currentRange.end);
}
}, [client, fetchEvents]);
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => {
if (!client) { toast.error(t("notifications.event_error")); return; }
try {
if (editEvent) {
if (isRecurringEvent(editEvent)) {
setPendingScopeAction({
type: "edit",
event: editEvent,
updates: data,
sendScheduling: sendSchedulingMessages,
});
setShowEventModal(false);
setEditEvent(null);
return;
}
await updateEvent(client, editEvent.id, data, sendSchedulingMessages);
toast.success(t("notifications.event_updated"));
} else {
const created = await createEvent(client, data, sendSchedulingMessages);
if (!created) {
toast.error(t("notifications.event_error"));
return;
}
if (sendSchedulingMessages) {
toast.success(t("notifications.invitation_sent"));
} else {
toast.success(t("notifications.event_created"));
}
}
setShowEventModal(false);
setEditEvent(null);
} catch {
toast.error(t("notifications.event_error"));
}
}, [client, editEvent, createEvent, updateEvent, t]);
const handleDuplicateEvent = useCallback(async (data: Partial<CalendarEvent>) => {
if (!client) { toast.error(t("notifications.event_error")); return; }
try {
const created = await createEvent(client, data);
if (!created) {
toast.error(t("notifications.event_error"));
return;
}
toast.success(t("notifications.event_duplicated"));
setEditEvent(created);
setDefaultModalDate(undefined);
} catch {
toast.error(t("notifications.event_error"));
setShowEventModal(false);
setEditEvent(null);
}
}, [client, createEvent, t]);
const handleDeleteEvent = useCallback(async (id: string, sendSchedulingMessages?: boolean) => {
if (!client) { toast.error(t("notifications.event_error")); return; }
const eventToDelete = events.find(e => e.id === id) || editEvent;
if (eventToDelete && isRecurringEvent(eventToDelete)) {
setPendingScopeAction({
type: "delete",
event: eventToDelete,
sendScheduling: sendSchedulingMessages || undefined,
});
setShowEventModal(false);
setEditEvent(null);
return;
}
try {
await deleteEvent(client, id, sendSchedulingMessages);
toast.success(t("notifications.event_deleted"));
} catch {
toast.error(t("notifications.event_error"));
}
}, [client, deleteEvent, events, editEvent, t]);
const truncateRecurrenceAtEvent = useCallback(async (event: CalendarEvent): Promise<{
master: CalendarEvent;
originalRules: CalendarEvent["recurrenceRules"];
} | null> => {
const master = await findMasterEvent(event);
if (!master) return null;
const originalRules = master.recurrenceRules
? JSON.parse(JSON.stringify(master.recurrenceRules))
: null;
const occurrenceDate = event.recurrenceId || event.start;
const untilDate = new Date(occurrenceDate);
untilDate.setSeconds(untilDate.getSeconds() - 1);
const until = format(untilDate, "yyyy-MM-dd'T'HH:mm:ss");
const truncatedRules = (master.recurrenceRules || []).map(rule => ({
...rule,
until,
count: null,
}));
await updateEvent(client!, master.id, { recurrenceRules: truncatedRules });
return { master, originalRules };
}, [client, findMasterEvent, updateEvent]);
const handleScopeSelect = useCallback(async (scope: RecurrenceEditScope) => {
if (!client || !pendingScopeAction) { toast.error(t("notifications.event_error")); return; }
const { type, event, sendScheduling } = pendingScopeAction;
const updates = type === "edit" ? pendingScopeAction.updates : undefined;
setPendingScopeAction(null);
try {
if (type === "edit" && updates) {
switch (scope) {
case "this":
await updateEvent(client, event.id, updates, sendScheduling);
break;
case "this_and_future": {
const result = await truncateRecurrenceAtEvent(event);
if (!result) {
toast.error(t("notifications.event_error"));
return;
}
const { master, originalRules } = result;
const occurrenceStart = event.recurrenceId || event.start;
const newEventData: Partial<CalendarEvent> = {
title: master.title,
description: master.description,
duration: master.duration,
timeZone: master.timeZone,
calendarIds: { ...master.calendarIds },
status: master.status,
freeBusyStatus: master.freeBusyStatus,
privacy: master.privacy,
showWithoutTime: master.showWithoutTime,
recurrenceRules: originalRules,
...updates,
start: updates.start || occurrenceStart,
};
delete (newEventData as Record<string, unknown>).id;
delete (newEventData as Record<string, unknown>).uid;
delete (newEventData as Record<string, unknown>).recurrenceId;
try {
await createEvent(client, newEventData, sendScheduling);
} catch (createError) {
debug.error("Failed to create new series, rolling back master truncation:", createError);
try {
await updateEvent(client, master.id, { recurrenceRules: originalRules });
} catch (rollbackError) {
debug.error("Rollback of master event also failed:", rollbackError);
}
throw createError;
}
break;
}
case "all": {
const master = await findMasterEvent(event);
if (!master) {
toast.error(t("notifications.event_error"));
return;
}
const allUpdates = { ...updates };
delete (allUpdates as Record<string, unknown>).recurrenceId;
await updateEvent(client, master.id, allUpdates, sendScheduling);
break;
}
default: {
const _exhaustive: never = scope;
throw new Error(`Unhandled scope: ${_exhaustive}`);
}
}
toast.success(t("notifications.event_updated"));
} else {
switch (scope) {
case "this":
await deleteEvent(client, event.id, sendScheduling);
break;
case "this_and_future": {
const result = await truncateRecurrenceAtEvent(event);
if (!result) {
toast.error(t("notifications.event_error"));
return;
}
break;
}
case "all": {
const master = await findMasterEvent(event);
if (!master) {
toast.error(t("notifications.event_error"));
return;
}
await deleteEvent(client, master.id, sendScheduling);
break;
}
default: {
const _exhaustive: never = scope;
throw new Error(`Unhandled scope: ${_exhaustive}`);
}
}
toast.success(t("notifications.event_deleted"));
}
try {
await refetchCurrentRange();
} catch {
debug.error("Failed to refresh calendar after scope operation");
}
} catch {
toast.error(t("notifications.event_error"));
}
}, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, truncateRecurrenceAtEvent, refetchCurrentRange, t]);
const handleRsvp = useCallback(async (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => {
if (!client) return;
try {
await rsvpEvent(client, eventId, participantId, status);
toast.success(t("notifications.rsvp_updated"));
} catch {
toast.error(t("notifications.rsvp_error"));
}
}, [client, rsvpEvent, t]);
const handleDeleteFromDetail = useCallback(() => {
if (!detailEvent) return;
const hasParticipants = detailEvent.participants && Object.keys(detailEvent.participants).length > 0;
closeDetail();
handleDeleteEvent(detailEvent.id, hasParticipants || undefined);
}, [detailEvent, closeDetail, handleDeleteEvent]);
const handleDuplicateFromDetail = useCallback(async () => {
if (!detailEvent || !client) return;
const start = parseISO(detailEvent.start);
const newStart = addDays(start, 1);
const data: Partial<CalendarEvent> = {
title: detailEvent.title,
description: detailEvent.description,
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
duration: detailEvent.duration,
timeZone: detailEvent.timeZone,
showWithoutTime: detailEvent.showWithoutTime,
calendarIds: { ...detailEvent.calendarIds },
status: "confirmed",
freeBusyStatus: detailEvent.freeBusyStatus,
privacy: detailEvent.privacy,
};
if (detailEvent.locations) data.locations = structuredClone(detailEvent.locations);
if (detailEvent.recurrenceRules) data.recurrenceRules = structuredClone(detailEvent.recurrenceRules);
if (detailEvent.alerts) data.alerts = structuredClone(detailEvent.alerts);
if (detailEvent.participants) data.participants = structuredClone(detailEvent.participants);
closeDetail();
try {
const created = await createEvent(client, data);
if (created) {
toast.success(t("notifications.event_duplicated"));
openEditModal(created);
}
} catch {
toast.error(t("notifications.event_error"));
}
}, [detailEvent, client, createEvent, closeDetail, openEditModal, t]);
const handleSaveNoteFromDetail = useCallback(async (note: string) => {
if (!detailEvent || !client) return;
const timestamp = format(new Date(), "yyyy-MM-dd HH:mm");
const separator = `\n\n--- ${timestamp} ---\n`;
const newDescription = detailEvent.description
? `${detailEvent.description}${separator}${note}`
: `--- ${timestamp} ---\n${note}`;
try {
await updateEvent(client, detailEvent.id, { description: newDescription });
setDetailEvent({ ...detailEvent, description: newDescription });
toast.success(t("detail.note_saved"));
} catch {
toast.error(t("notifications.event_error"));
}
}, [detailEvent, client, updateEvent, t]);
const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus']) => {
if (!detailEvent || !client) return;
const participantId = getUserParticipantId(detailEvent, currentUserEmails);
if (!participantId) return;
closeDetail();
await handleRsvp(detailEvent.id, participantId, status);
}, [detailEvent, client, currentUserEmails, closeDetail, handleRsvp]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") return;
if (showEventModal || detailEvent) return;
switch (e.key) {
case "ArrowLeft": e.preventDefault(); navigatePrev(); break;
case "ArrowRight": e.preventDefault(); navigateNext(); break;
case "t": goToToday(); break;
case "m": setViewMode("month"); break;
case "w": setViewMode("week"); break;
case "d": setViewMode("day"); break;
case "a": setViewMode("agenda"); break;
case "n": openCreateModal(); break;
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent]);
const visibleEvents = useMemo(() =>
events.filter((e) => {
if (!e.calendarIds) return false;
const calIds = Object.keys(e.calendarIds);
return calIds.some((id) => selectedCalendarIds.includes(id));
}),
[events, selectedCalendarIds]
);
if (!isAuthenticated || !supportsCalendar) return null;
const renderView = () => {
if (isLoading && calendars.length === 0) {
return (
<div className="flex items-center justify-center flex-1 text-muted-foreground">
<p className="text-sm">{t("status.loading_calendars")}</p>
</div>
);
}
const viewContent = (() => {
switch (normalizedViewMode) {
case "month":
return (
<CalendarMonthView
selectedDate={selectedDate}
events={visibleEvents}
calendars={calendars}
onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek}
isMobile={isMobile}
/>
);
case "week":
return (
<CalendarWeekView
selectedDate={selectedDate}
events={visibleEvents}
calendars={calendars}
onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek}
timeFormat={timeFormat}
isMobile={isMobile}
/>
);
case "day":
return (
<CalendarDayView
selectedDate={selectedDate}
events={visibleEvents}
calendars={calendars}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onCreateAtTime={openCreateModal}
timeFormat={timeFormat}
isMobile={isMobile}
/>
);
case "agenda":
return (
<CalendarAgendaView
selectedDate={selectedDate}
events={visibleEvents}
calendars={calendars}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
timeFormat={timeFormat}
/>
);
}
})();
return (
<div className="relative flex-1 flex flex-col overflow-hidden">
{viewContent}
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
<div className="h-5 w-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
);
};
return (
<div className="flex h-dvh bg-background overflow-hidden">
{/* Left Navigation Rail */}
{!isMobile && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
quota={quota}
isPushConnected={isPushConnected}
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
</div>
)}
{inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
)}
{/* Sidebar - full height */}
{!isMobile && !inlineApp && (
<>
<div
className={cn(
"border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
!isResizing && "transition-[width] duration-300"
)}
style={{ width: `${calSidebarWidth}px` }}
>
<MiniCalendar
selectedDate={selectedDate}
displayMonth={miniMonth}
onSelectDate={handleSelectDate}
onChangeMonth={handleMiniMonthChange}
events={events}
firstDayOfWeek={firstDayOfWeek}
/>
<CalendarSidebarPanel
calendars={calendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
onColorChange={client ? (calendarId, color) => {
updateCalendar(client, calendarId, { color });
} : undefined}
onSubscribe={() => setShowSubscriptionModal(true)}
client={client}
/>
</div>
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }}
onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsResizing(false);
localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth));
}}
onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }}
/>
</>
)}
{!inlineApp && (
<div className="flex flex-col flex-1 min-w-0">
<CalendarToolbar
selectedDate={selectedDate}
viewMode={normalizedViewMode}
onPrev={navigatePrev}
onNext={navigateNext}
onToday={goToToday}
onViewModeChange={setViewMode}
onCreateEvent={() => openCreateModal()}
onImport={() => setShowImportModal(true)}
onSubscribe={() => setShowSubscriptionModal(true)}
isMobile={isMobile}
calendars={calendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
/>
<div
className="flex flex-1 overflow-hidden relative"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{renderView()}
{/* Desktop event panel */}
{!isMobile && showEventModal && (
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
<EventModal
key={editEvent?.id ?? 'new'}
event={editEvent}
calendars={calendars}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
onSave={handleSaveEvent}
onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp}
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
currentUserEmails={currentUserEmails}
isMobile={false}
/>
</div>
)}
{/* Floating Create Event Button (mobile) */}
{isMobile && (
<Button
onClick={() => openCreateModal()}
className="absolute bottom-4 right-4 z-40 h-14 w-14 rounded-full shadow-lg"
aria-label={t("events.create")}
>
<Plus className="h-6 w-6" />
</Button>
)}
</div>
</div>
)}
{/* Mobile Bottom Navigation */}
{isMobile && (
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
)}
{detailEvent && detailAnchorRect && (
<EventDetailPopover
event={detailEvent}
calendar={calendars.find(c => detailEvent.calendarIds[c.id])}
anchorRect={detailAnchorRect}
onEdit={handleEditFromDetail}
onDelete={handleDeleteFromDetail}
onDuplicate={handleDuplicateFromDetail}
onClose={closeDetail}
onSaveNote={handleSaveNoteFromDetail}
onRsvp={handleRsvpFromDetail}
onMouseEnter={() => { if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } }}
onMouseLeave={handleHoverLeave}
currentUserEmails={currentUserEmails}
timeFormat={timeFormat}
isMobile={isMobile}
/>
)}
{showEventModal && isMobile && (
<EventModal
key={editEvent?.id ?? 'new'}
event={editEvent}
calendars={calendars}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
onSave={handleSaveEvent}
onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp}
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
currentUserEmails={currentUserEmails}
isMobile={true}
/>
)}
{showImportModal && client && (
<ICalImportModal
calendars={calendars}
client={client}
onClose={() => setShowImportModal(false)}
/>
)}
{showSubscriptionModal && client && (
<ICalSubscriptionModal
client={client}
onClose={() => setShowSubscriptionModal(false)}
/>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<RecurrenceScopeDialog
isOpen={!!pendingScopeAction}
actionType={pendingScopeAction?.type || "edit"}
onSelect={handleScopeSelect}
onClose={() => setPendingScopeAction(null)}
/>
</div>
);
}
-34
View File
@@ -1,34 +0,0 @@
import { notFound } from "next/navigation";
import { IntlProvider } from "@/components/providers/intl-provider";
import { ThemeProvider } from "@/components/providers/theme-provider";
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
import { locales } from "@/i18n/routing";
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!(locales as readonly string[]).includes(locale)) notFound();
let messages;
try {
messages = (await import(`@/locales/${locale}/common.json`)).default;
} catch {
notFound();
}
return (
<IntlProvider locale={locale} messages={messages}>
<ThemeProvider>
<CalendarAlertProvider>
{children}
</CalendarAlertProvider>
</ThemeProvider>
</IntlProvider>
);
}
-801
View File
@@ -1,801 +0,0 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "@/i18n/navigation";
import { useParams, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuthStore } from "@/stores/auth-store";
import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config";
import { cn } from "@/lib/utils";
import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield } from "lucide-react";
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
const APP_VERSION = "1.4.3";
const THEME_OPTIONS = [
{ value: "light" as const, icon: Sun, label: "Light" },
{ value: "dark" as const, icon: Moon, label: "Dark" },
{ value: "system" as const, icon: Monitor, label: "System" },
];
export default function LoginPage() {
const router = useRouter();
const t = useTranslations("login");
const params = useParams();
const searchParams = useSearchParams();
const isAddAccountMode = searchParams.get("mode") === "add-account";
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const [formData, setFormData] = useState({
username: "",
password: "",
});
const [totpCode, setTotpCode] = useState("");
const [showTotpField, setShowTotpField] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const [sessionExpired, setSessionExpired] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [shakeError, setShakeError] = useState(false);
const [showThemeMenu, setShowThemeMenu] = useState(false);
const [savedUsernames, setSavedUsernames] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
const [oauthMetadata, setOauthMetadata] = useState<OAuthMetadata | null>(null);
const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false);
const [oauthLoading, setOauthLoading] = useState(false);
const suggestionsRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const justSelectedSuggestion = useRef(false);
const totpInputRef = useRef<HTMLInputElement>(null);
const prevError = useRef<string | null>(null);
const themeMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
initializeTheme();
}, [initializeTheme]);
useEffect(() => {
if (serverUrl) {
document.title = appName;
}
}, [appName, serverUrl]);
useEffect(() => {
try {
if (sessionStorage.getItem('session_expired') === 'true') {
setSessionExpired(true);
sessionStorage.removeItem('session_expired');
}
} catch { /* sessionStorage unavailable */ }
}, []);
useEffect(() => {
if (error && error !== prevError.current) {
setShakeError(true);
const timer = setTimeout(() => setShakeError(false), 400);
return () => clearTimeout(timer);
}
prevError.current = error;
}, [error]);
useEffect(() => {
if (!serverUrl) return;
const saved = localStorage.getItem("webmail_usernames");
if (saved) {
try {
const usernames = JSON.parse(saved);
setSavedUsernames(usernames);
} catch {
console.error("Failed to parse saved usernames");
}
}
}, [serverUrl]);
useEffect(() => {
if (isAuthenticated && !isAddAccountMode) {
let redirectTo = '/';
try {
const saved = sessionStorage.getItem('redirect_after_login');
if (saved) {
sessionStorage.removeItem('redirect_after_login');
redirectTo = saved;
}
} catch { /* ignore */ }
router.push(redirectTo);
}
}, [isAuthenticated, router, isAddAccountMode]);
useEffect(() => {
clearError();
}, [formData, clearError]);
useEffect(() => {
if (!serverUrl) return;
if (justSelectedSuggestion.current) {
justSelectedSuggestion.current = false;
return;
}
if (formData.username && savedUsernames.length > 0) {
const filtered = savedUsernames.filter(username =>
username.toLowerCase().includes(formData.username.toLowerCase())
);
setFilteredSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
} else if (formData.username === "" && savedUsernames.length > 0) {
setFilteredSuggestions(savedUsernames);
setShowSuggestions(false);
} else {
setShowSuggestions(false);
}
setSelectedSuggestionIndex(-1);
}, [formData.username, savedUsernames, serverUrl]);
useEffect(() => {
if (!serverUrl) return;
const handleClickOutside = (event: MouseEvent) => {
if (suggestionsRef.current && !suggestionsRef.current.contains(event.target as Node) &&
inputRef.current && !inputRef.current.contains(event.target as Node)) {
setShowSuggestions(false);
}
if (themeMenuRef.current && !themeMenuRef.current.contains(event.target as Node)) {
setShowThemeMenu(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [serverUrl]);
useEffect(() => {
if (!oauthEnabled || !serverUrl) return;
discoverOAuth(oauthIssuerUrl || serverUrl)
.then((metadata) => {
setOauthMetadata(metadata);
setOauthDiscoveryDone(true);
})
.catch(() => {
setOauthMetadata(null);
setOauthDiscoveryDone(true);
});
}, [oauthEnabled, serverUrl, oauthIssuerUrl]);
const handleThemeSelect = useCallback((newTheme: "light" | "dark" | "system") => {
setTheme(newTheme);
setShowThemeMenu(false);
}, [setTheme]);
if (configLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<div className="w-full max-w-sm mx-auto px-4 text-center" role="status">
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto" />
<span className="sr-only">{t("loading")}</span>
</div>
</div>
);
}
if (configError) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<div className="w-full max-w-md mx-auto px-4 text-center">
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl p-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-500/10 mb-5">
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
<h1 className="text-xl font-semibold text-foreground mb-2">{t("config_error.title")}</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("config_error.fetch_failed")}
</p>
</div>
</div>
</div>
);
}
if (!serverUrl) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<div className="w-full max-w-md mx-auto px-4 text-center">
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl p-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-500/10 mb-5">
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
<h1 className="text-xl font-semibold text-foreground mb-2">{t("config_error.title")}</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("config_error.server_not_configured")}
</p>
</div>
</div>
</div>
);
}
const saveUsername = (username: string) => {
const saved = localStorage.getItem("webmail_usernames");
let usernames: string[] = [];
if (saved) {
try {
usernames = JSON.parse(saved);
} catch {
console.error("Failed to parse saved usernames");
}
}
if (!usernames.includes(username)) {
usernames = [username, ...usernames].slice(0, 5);
localStorage.setItem("webmail_usernames", JSON.stringify(usernames));
setSavedUsernames(usernames);
}
};
const removeUsername = (username: string, e: React.MouseEvent) => {
e.stopPropagation();
const updated = savedUsernames.filter(u => u !== username);
localStorage.setItem("webmail_usernames", JSON.stringify(updated));
setSavedUsernames(updated);
setFilteredSuggestions(updated.filter(u =>
u.toLowerCase().includes(formData.username.toLowerCase())
));
};
const handleUsernameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, username: e.target.value });
};
const handleUsernameFocus = () => {
if (savedUsernames.length > 0 && formData.username === "") {
setFilteredSuggestions(savedUsernames);
setShowSuggestions(true);
} else if (filteredSuggestions.length > 0) {
setShowSuggestions(true);
}
};
const selectSuggestion = (username: string) => {
justSelectedSuggestion.current = true;
setFormData({ ...formData, username });
setShowSuggestions(false);
document.getElementById("password")?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!showSuggestions || filteredSuggestions.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedSuggestionIndex(prev =>
prev < filteredSuggestions.length - 1 ? prev + 1 : prev
);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedSuggestionIndex(prev => prev > 0 ? prev - 1 : -1);
} else if (e.key === "Enter" && selectedSuggestionIndex >= 0) {
e.preventDefault();
selectSuggestion(filteredSuggestions[selectedSuggestionIndex]);
} else if (e.key === "Escape") {
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
}
};
const handleOAuthLogin = async () => {
if (!oauthMetadata || !oauthClientId) return;
setOauthLoading(true);
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = generateState();
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
sessionStorage.setItem("oauth_code_verifier", verifier);
sessionStorage.setItem("oauth_state", state);
sessionStorage.setItem("oauth_server_url", serverUrl!);
if (isAddAccountMode) {
sessionStorage.setItem("oauth_add_account_mode", "true");
}
const authUrl = new URL(oauthMetadata.authorization_endpoint);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", oauthClientId);
authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("scope", OAUTH_SCOPES);
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");
window.location.href = authUrl.toString();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const success = await login(
serverUrl,
formData.username,
formData.password,
totpCode || undefined,
rememberMe
);
if (success) {
saveUsername(formData.username);
router.push('/');
}
};
const handleDevLogin = async () => {
const success = await login(serverUrl, "dev@localhost", "dev");
if (success) {
let redirectTo = '/';
try {
const saved = sessionStorage.getItem('redirect_after_login');
if (saved) {
sessionStorage.removeItem('redirect_after_login');
redirectTo = saved;
}
} catch { /* ignore */ }
router.push(redirectTo);
}
};
const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2];
const CurrentThemeIcon = currentThemeOption.icon;
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
{/* Theme toggle - top right, dropdown style */}
<div className="absolute top-5 right-5" ref={themeMenuRef} suppressHydrationWarning>
<button
type="button"
onClick={() => setShowThemeMenu(!showThemeMenu)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-xl border text-sm transition-all duration-200",
showThemeMenu
? "bg-secondary border-border text-foreground shadow-md"
: "bg-background/60 backdrop-blur-sm border-border/50 text-muted-foreground hover:text-foreground hover:bg-secondary/80 hover:border-border"
)}
aria-label={`Theme: ${currentThemeOption.label}`}
aria-expanded={showThemeMenu}
aria-haspopup="listbox"
>
<CurrentThemeIcon className="w-4 h-4" />
<span className="hidden sm:inline" suppressHydrationWarning>{currentThemeOption.label}</span>
</button>
{showThemeMenu && (
<div
className="absolute right-0 top-full mt-2 w-40 rounded-xl border border-border bg-background shadow-lg overflow-hidden animate-fade-in z-50"
role="listbox"
aria-label="Theme selection"
>
{THEME_OPTIONS.map((option) => {
const Icon = option.icon;
const isActive = theme === option.value;
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={isActive}
onClick={() => handleThemeSelect(option.value)}
className={cn(
"w-full flex items-center gap-3 px-3.5 py-2.5 text-sm transition-colors",
isActive
? "bg-primary/10 text-foreground font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<Icon className="w-4 h-4" />
<span className="flex-1 text-left">{option.label}</span>
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
</button>
);
})}
</div>
)}
</div>
<div className="w-full max-w-[400px] mx-auto">
{/* Card container */}
<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">
<img
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
alt={appName}
className="max-w-16 max-h-16 object-contain"
/>
</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>
</div>
{/* Form section */}
<div className="px-8 pb-8">
{/* Session Expired Banner */}
{sessionExpired && (
<div
className="mb-5 p-3.5 bg-blue-500/10 border border-blue-500/20 rounded-xl flex items-start gap-3"
role="status"
aria-live="polite"
>
<Info className="w-4.5 h-4.5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-blue-700 dark:text-blue-300 flex-1 leading-relaxed">
{t("session_expired")}
</p>
<button
type="button"
onClick={() => setSessionExpired(false)}
className="p-0.5 rounded-md hover:bg-blue-500/10 transition-colors flex-shrink-0"
aria-label={t("dismiss")}
>
<X className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</button>
</div>
)}
{/* Error Message */}
{error && (
<div className={cn(
"mb-5 p-3.5 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3",
shakeError && "animate-shake"
)}>
<AlertCircle className="w-4.5 h-4.5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400 leading-relaxed">
{error === 'invalid_credentials' && showTotpField && totpCode
? t('error.totp_invalid')
: t(`error.${error}`) || t("error.generic")}
</p>
</div>
)}
{/* Dev Mode: One-click login */}
{devMode ? (
<div className="space-y-4">
<Button
type="button"
className="w-full h-12 font-medium text-base bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-lg shadow-primary/20"
onClick={handleDevLogin}
disabled={isLoading}
>
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{t("signing_in")}
</div>
) : (
<div className="flex items-center gap-2">
<LogIn className="w-4 h-4" />
{t("sign_in")}
</div>
)}
</Button>
<p className="text-center text-xs text-muted-foreground">
Dev mode logging in as dev@localhost
</p>
</div>
) : oauthOnly ? (
/* OAuth-only mode: show SSO button only */
<div className="space-y-4">
{oauthMetadata ? (
<Button
type="button"
className="w-full h-11 font-medium text-[15px] bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-md shadow-primary/15 hover:shadow-lg hover:shadow-primary/20"
onClick={handleOAuthLogin}
disabled={oauthLoading}
>
{oauthLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{t("signing_in")}
</div>
) : (
<div className="flex items-center gap-2">
<LogIn className="w-4 h-4" />
{t("sign_in_sso")}
</div>
)}
</Button>
) : oauthDiscoveryDone ? (
<div className="p-3.5 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-amber-700 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-700 dark:text-amber-400">
{t("error.oauth_discovery_failed")}
</p>
</div>
) : (
<div className="flex justify-center py-4">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
)}
</div>
) : (
/* Login Form */
<form onSubmit={handleSubmit} className="space-y-5">
<fieldset disabled={isLoading} className="space-y-4">
{/* Username field */}
<div className="space-y-1.5">
<label htmlFor="username" className="block text-sm font-medium text-foreground">
{t("username_label")}
</label>
<div className="relative">
<Input
ref={inputRef}
id="username"
type="text"
value={formData.username}
onChange={handleUsernameChange}
onFocus={handleUsernameFocus}
onKeyDown={handleKeyDown}
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
placeholder={t("username_placeholder")}
required
autoComplete="off"
data-form-type="other"
data-lpignore="true"
autoFocus
/>
{/* Custom autocomplete dropdown */}
{showSuggestions && filteredSuggestions.length > 0 && (
<div
ref={suggestionsRef}
className="absolute top-full mt-1.5 w-full bg-background border border-border rounded-xl shadow-lg z-50 overflow-hidden"
>
{filteredSuggestions.map((username, index) => (
<div
key={username}
className={cn(
"px-3.5 py-2.5 flex items-center justify-between hover:bg-muted cursor-pointer transition-colors",
index === selectedSuggestionIndex && "bg-muted"
)}
onClick={() => selectSuggestion(username)}
>
<span className="text-sm text-foreground">{username}</span>
<button
type="button"
onClick={(e) => removeUsername(username, e)}
className="p-1 hover:bg-secondary rounded-md transition-colors"
title={t("remove_from_history")}
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
</div>
))}
</div>
)}
</div>
</div>
{/* Password field */}
<div className="space-y-1.5">
<label htmlFor="password" className="block text-sm font-medium text-foreground">
{t("password_label")}
</label>
<div className="relative">
<Input
id="password"
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"
placeholder={t("password_placeholder")}
required
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground hover:text-foreground transition-colors"
aria-label={showPassword ? t("hide_password") : t("show_password")}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
</div>
{/* 2FA toggle / field */}
{!showTotpField ? (
<button
type="button"
onClick={() => {
setShowTotpField(true);
setTimeout(() => totpInputRef.current?.focus(), 50);
}}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Shield className="w-3.5 h-3.5" />
{t("totp_toggle")}
</button>
) : (
<div className="space-y-1.5">
<label htmlFor="totp" className="block text-sm font-medium text-foreground">
{t("totp_label")}
</label>
<Input
ref={totpInputRef}
id="totp"
type="text"
inputMode="numeric"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest"
placeholder={t("totp_placeholder")}
autoComplete="one-time-code"
aria-label={t("totp_label")}
/>
</div>
)}
{/* Remember me */}
{rememberMeEnabled && (
<label className="flex items-center gap-2.5 cursor-pointer group select-none pt-1">
<span className="relative flex items-center justify-center">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="peer sr-only"
/>
<span className="flex items-center justify-center w-[18px] h-[18px] rounded-[5px] border border-border/80 bg-muted/40 peer-checked:bg-primary peer-checked:border-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background transition-all duration-200">
{rememberMe && (
<svg className="w-3 h-3 text-primary-foreground" viewBox="0 0 12 12" fill="none">
<path d="M2 6L5 9L10 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
</span>
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
{t("remember_me")}
</span>
</label>
)}
</fieldset>
<Button
type="submit"
className="w-full h-11 font-medium text-[15px] bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-md shadow-primary/15 hover:shadow-lg hover:shadow-primary/20"
disabled={isLoading}
>
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{t("signing_in")}
</div>
) : (
<div className="flex items-center gap-2">
<LogIn className="w-4 h-4" />
{t("sign_in")}
</div>
)}
</Button>
{oauthMetadata && (
<>
<div className="relative my-2">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border/60" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background/80 px-3 text-muted-foreground">{t("or")}</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full h-11 font-medium text-[15px] rounded-xl border-border/60 hover:bg-muted/50"
onClick={handleOAuthLogin}
disabled={oauthLoading || isLoading}
>
{oauthLoading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<LogIn className="w-4 h-4 mr-2" />
)}
{t("sign_in_sso")}
</Button>
</>
)}
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (
<div className="mt-2 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-amber-700 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-700 dark:text-amber-400">
{t("error.oauth_discovery_failed")}
</p>
</div>
)}
</form>
)}
{isAddAccountMode && (
<div className="mt-4">
<Button
type="button"
variant="ghost"
className="w-full h-10 text-sm text-muted-foreground hover:text-foreground"
onClick={() => router.push('/')}
>
{t("cancel")}
</Button>
</div>
)}
</div>
</div>
{/* Company name & links - below card */}
<div className="mt-6 flex flex-col items-center gap-2">
{loginCompanyName && (
<p className="text-center text-xs text-muted-foreground/60 font-medium">
{loginCompanyName}
</p>
)}
{(loginImprintUrl || loginPrivacyPolicyUrl || loginWebsiteUrl) && (
<div className="flex items-center gap-3 flex-wrap justify-center">
{loginWebsiteUrl && (
<a
href={loginWebsiteUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors"
>
{t("website")}
</a>
)}
{loginImprintUrl && (
<a
href={loginImprintUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors"
>
{t("imprint")}
</a>
)}
{loginPrivacyPolicyUrl && (
<a
href={loginPrivacyPolicyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors"
>
{t("privacy_policy")}
</a>
)}
</div>
)}
<p className="text-center text-xs text-muted-foreground/40">
v{APP_VERSION}
</p>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
-425
View File
@@ -1,425 +0,0 @@
"use client";
import { useState, useEffect, useRef } from 'react';
import { useRouter } from '@/i18n/navigation';
import { useTranslations } from 'next-intl';
import {
ArrowLeft,
ChevronRight,
LogOut,
Settings as SettingsIcon,
Palette,
Mail,
User,
Shield,
UserPen,
PalmtreeIcon,
Calendar,
Filter,
FileText,
FolderOpen,
Tags,
HardDrive,
Wrench,
BookUser,
KeyRound,
PanelLeftClose,
type LucideIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { EmailSettings } from '@/components/settings/email-settings';
import { AccountSettings } from '@/components/settings/account-settings';
import { IdentitySettings } from '@/components/settings/identity-settings';
import { VacationSettings } from '@/components/settings/vacation-settings';
import { CalendarSettings } from '@/components/settings/calendar-settings';
import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings';
import { FilterSettings } from '@/components/settings/filter-settings';
import { TemplateSettings } from '@/components/settings/template-settings';
import { AdvancedSettings } from '@/components/settings/advanced-settings';
import { FolderSettings } from '@/components/settings/folder-settings';
import { KeywordSettings } from '@/components/settings/keyword-settings';
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings';
import { SmimeSettings } from '@/components/settings/smime-settings';
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useIsDesktop } from '@/hooks/use-media-query';
import { NavigationRail } from '@/components/layout/navigation-rail';
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
import { InlineAppView } from '@/components/layout/inline-app-view';
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
import { ResizeHandle } from '@/components/layout/resize-handle';
import { useConfig } from '@/hooks/use-config';
import { cn } from '@/lib/utils';
type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'advanced';
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
interface TabDef {
id: Tab;
label: string;
icon: LucideIcon;
group: TabGroup;
}
const tabIcons: Record<Tab, LucideIcon> = {
appearance: Palette,
email: Mail,
account: User,
security: Shield,
identities: UserPen,
encryption: KeyRound,
vacation: PalmtreeIcon,
calendar: Calendar,
contacts: BookUser,
filters: Filter,
templates: FileText,
folders: FolderOpen,
keywords: Tags,
files: HardDrive,
sidebar_apps: PanelLeftClose,
advanced: Wrench,
};
const tabGroupOrder: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system'];
export default function SettingsPage() {
const router = useRouter();
const t = useTranslations('settings');
const tSidebar = useTranslations('sidebar');
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig();
const [activeTab, setActiveTab] = useState<Tab>(() => {
try {
const saved = localStorage.getItem('settings-active-tab');
if (saved) return saved as Tab;
} catch { /* ignore */ }
return 'appearance';
});
const [mobileShowContent, setMobileShowContent] = useState(false);
const isDesktop = useIsDesktop();
// Sidebar resize state
const [settingsSidebarWidth, setSettingsSidebarWidth] = useState(() => {
try { const v = localStorage.getItem('settings-sidebar-width'); return v ? Number(v) : 256; } catch { return 256; }
});
const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(256);
// Check auth on mount
useEffect(() => {
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
router.push('/login');
}
}, [initialCheckDone, isAuthenticated, authLoading, router]);
if (!isAuthenticated) {
return null;
}
const supportsVacation = client?.supportsVacationResponse() ?? false;
const supportsCalendar = client?.supportsCalendars() ?? false;
const supportsSieve = client?.supportsSieve() ?? false;
const supportsFiles = client?.supportsFiles() ?? false;
const tabs: TabDef[] = [
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' },
{ id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' },
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' },
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []),
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' },
{ id: 'encryption', label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' },
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []),
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []),
{ id: 'templates', label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' },
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' },
{ id: 'keywords', label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' },
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
{ id: 'sidebar_apps', label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' },
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
];
// Group tabs by category
const groupedTabs = tabGroupOrder
.map((group) => ({
group,
label: t(`tab_groups.${group}`),
items: tabs.filter((tab) => tab.group === group),
}))
.filter((g) => g.items.length > 0);
const handleTabSelect = (tabId: Tab) => {
setActiveTab(tabId);
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
if (!isDesktop) {
setMobileShowContent(true);
}
};
const activeTabLabel = tabs.find((tab) => tab.id === activeTab)?.label ?? '';
const renderTabContent = () => (
<>
{activeTab === 'appearance' && <AppearanceSettings />}
{activeTab === 'email' && <EmailSettings />}
{activeTab === 'account' && <AccountSettings />}
{activeTab === 'security' && <AccountSecuritySettings />}
{activeTab === 'identities' && <IdentitySettings />}
{activeTab === 'encryption' && <SmimeSettings />}
{activeTab === 'vacation' && <VacationSettings />}
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{activeTab === 'contacts' && <ContactsSettings />}
{activeTab === 'filters' && <FilterSettings />}
{activeTab === 'templates' && <TemplateSettings />}
{activeTab === 'folders' && <FolderSettings />}
{activeTab === 'keywords' && <KeywordSettings />}
{activeTab === 'files' && <FilesSettingsComponent />}
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />}
{activeTab === 'advanced' && <AdvancedSettings />}
</>
);
// Mobile layout
if (!isDesktop) {
// Mobile: show content view
if (mobileShowContent) {
return (
<div className="flex flex-col h-dvh bg-background">
{/* Mobile content header */}
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => setMobileShowContent(false)}
className="h-10 w-10"
>
<ArrowLeft className="w-5 h-5" />
</Button>
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<div className="bg-card border border-border rounded-lg p-4">
{renderTabContent()}
</div>
</div>
{/* Bottom Navigation */}
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div>
);
}
// Mobile: show tab list
return (
<div className="flex flex-col h-dvh bg-background">
{/* Mobile header */}
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => router.push('/')}
className="h-10 w-10"
>
<ArrowLeft className="w-5 h-5" />
</Button>
<div className="flex items-center gap-2">
<SettingsIcon className="w-5 h-5 text-muted-foreground" />
<h1 className="font-semibold text-lg">{t('title')}</h1>
</div>
</div>
{/* Tab list */}
<div className="flex-1 overflow-y-auto">
<div className="py-2">
{groupedTabs.map((group, groupIndex) => (
<div key={group.group}>
{groupIndex > 0 && <div className="mx-5 my-2 border-t border-border" />}
<div className="px-5 pt-3 pb-1.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => handleTabSelect(tab.id)}
className="w-full flex items-center justify-between px-5 py-3.5 text-sm text-foreground hover:bg-muted transition-colors duration-150"
>
<span className="flex items-center gap-3">
<Icon className="w-4 h-4 text-muted-foreground" />
{tab.label}
</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
);
})}
</div>
))}
</div>
{/* Logout */}
<div className="border-t border-border px-5 py-3">
<button
onClick={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive hover:bg-muted rounded-md px-2 transition-colors duration-150"
>
<LogOut className="w-4 h-4" />
<span>{tSidebar('sign_out')}</span>
</button>
</div>
</div>
{/* Bottom Navigation */}
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div>
);
}
// Desktop layout
return (
<div className="flex h-dvh bg-background">
{/* Navigation Rail */}
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
quota={quota}
isPushConnected={isPushConnected}
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
</div>
{inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
)}
{!inlineApp && (
<>
{/* Settings Sidebar */}
<div
className={cn(
"border-r border-border bg-secondary flex flex-col",
!isResizing && "transition-[width] duration-300"
)}
style={{ width: `${settingsSidebarWidth}px` }}
>
{/* Header */}
<div className="p-4 border-b border-border">
<Button
variant="ghost"
size="sm"
onClick={() => router.push('/')}
className="w-full justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t('back_to_mail')}
</Button>
</div>
{/* Tabs */}
<div className="flex-1 overflow-y-auto py-2">
<div className="px-2 space-y-0.5">
{groupedTabs.map((group, groupIndex) => (
<div key={group.group}>
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
<div className="px-3 pt-2.5 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(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',
activeTab === tab.id
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<Icon className={cn(
'w-4 h-4 shrink-0',
activeTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{tab.label}
</button>
);
})}
</div>
))}
</div>
</div>
</div>
{/* Sidebar resize handle */}
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsResizing(false);
localStorage.setItem('settings-sidebar-width', String(settingsSidebarWidth));
}}
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
/>
{/* Settings Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto p-8">
{/* Page Header */}
<div className="mb-6">
<div className="flex items-center gap-2.5 mb-2">
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
</div>
</div>
{/* Active Tab Content */}
<div className="bg-card border border-border rounded-lg p-6">
{renderTabContent()}
</div>
</div>
</div>
</>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div>
);
}
-72
View File
@@ -1,72 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* GET /api/account/stalwart/auth
* Proxy to Stalwart GET /api/account/auth
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart auth info failed', { status: response.status });
return NextResponse.json(
{ error: 'Failed to fetch auth info', details: text },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart auth proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/account/stalwart/auth
* Proxy to Stalwart POST /api/account/auth
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart auth update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart auth update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
-72
View File
@@ -1,72 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* GET /api/account/stalwart/crypto
* Proxy to Stalwart GET /api/account/crypto
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.apiUrl}/api/account/crypto`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart crypto info failed', { status: response.status });
return NextResponse.json(
{ error: 'Failed to fetch crypto info', details: text },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart crypto proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/account/stalwart/crypto
* Proxy to Stalwart POST /api/account/crypto
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const response = await fetch(`${creds.apiUrl}/api/account/crypto`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart crypto update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart crypto update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* POST /api/account/stalwart/jmap
*
* Passthrough to Stalwart's JMAP endpoint using the stored basic-auth
* context so the browser does not need access to the user's credentials.
*
* Body: standard JMAP request `{ using: string[], methodCalls: [...] }`
*
* In Stalwart 0.16 all management operations (password change, app
* passwords, API keys, account settings, etc.) are exposed as JMAP
* methods under the `x:` namespace on the same endpoint.
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.text();
const response = await fetch(`${creds.serverUrl}/jmap/`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body,
});
const responseText = await response.text();
return new NextResponse(responseText, {
status: response.status,
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
});
} catch (error) {
logger.error('Stalwart JMAP passthrough error', {
error: error instanceof Error ? error.message : 'Unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -1,83 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: SESSION_COOKIE_MAX_AGE,
};
/**
* POST /api/account/stalwart/password
* Change user password via Stalwart PATCH /api/principal/{name}
*
* Body: { currentPassword: string, newPassword: string }
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { currentPassword, newPassword } = await request.json();
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
if (newPassword.length < 8) {
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 });
}
// Verify current password by attempting to authenticate
const verifyAuth = `Basic ${Buffer.from(`${creds.username}:${currentPassword}`).toString('base64')}`;
const verifyResponse = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
method: 'GET',
headers: { 'Authorization': verifyAuth },
});
if (!verifyResponse.ok) {
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 403 });
}
// Change password via Stalwart principal API
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'PATCH',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify([
{ action: 'set', field: 'secrets', value: newPassword },
]),
});
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart password change failed', { status: response.status });
return NextResponse.json(
{ error: 'Failed to change password', details: text },
{ status: response.status }
);
}
// If session cookie exists, update it with the new password
if (creds.hasSessionCookie) {
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, newToken, COOKIE_OPTIONS);
}
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Stalwart password change proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -1,81 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* GET /api/account/stalwart/principal
* Proxy to Stalwart GET /api/principal/{username}
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart principal fetch failed', { status: response.status });
return NextResponse.json(
{ error: 'Failed to fetch principal', details: text },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart principal proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PATCH /api/account/stalwart/principal
* Proxy to Stalwart PATCH /api/principal/{username}
* Body: PrincipalUpdateAction[] (array of {action, field, value})
*/
export async function PATCH(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
// Prevent secrets field from being changed through this endpoint (use /password instead)
if (Array.isArray(body)) {
const hasSecrets = body.some((action: { field?: string }) => action.field === 'secrets');
if (hasSecrets) {
return NextResponse.json({ error: 'Use /api/account/stalwart/password to change passwords' }, { status: 400 });
}
}
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'PATCH',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart principal update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart principal update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
-44
View File
@@ -1,44 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* GET /api/account/stalwart/probe
* Detect whether the JMAP server is Stalwart by probing /api/account/auth
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ isStalwart: false });
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
return NextResponse.json({ isStalwart: false });
}
const data = await response.json();
const isStalwart = data.data !== undefined && typeof data.data.otpEnabled === 'boolean';
return NextResponse.json({ isStalwart });
} catch {
clearTimeout(timeout);
return NextResponse.json({ isStalwart: false });
}
} catch (error) {
logger.error('Stalwart probe error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ isStalwart: false });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/admin/session';
import { readAuditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
/**
* GET /api/admin/audit - Get paginated audit log entries (admin-protected)
*/
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10));
const limit = Math.min(200, Math.max(1, parseInt(request.nextUrl.searchParams.get('limit') || '50', 10)));
const action = request.nextUrl.searchParams.get('action') || undefined;
const { entries, total } = await readAuditLog(page, limit, action);
return NextResponse.json({ entries, total, page, limit }, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Audit log read error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
Binary file not shown.
@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { getConfigDir } from '@/lib/admin/paths';
function getBrandingDir(): string {
return path.join(getConfigDir(), 'branding');
}
const MIME_TYPES: Record<string, string> = {
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
};
/**
* GET /api/admin/branding/[filename] - Serve uploaded branding images
*
* This endpoint is public (no admin auth) so browsers can load images.
* Only files in the branding directory are served; directory traversal is prevented.
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ filename: string }> },
) {
try {
const { filename } = await params;
// Sanitize: only allow basename, no path separators
const safe = path.basename(filename);
if (safe !== filename || filename.includes('..')) {
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
}
const ext = path.extname(safe).toLowerCase();
const contentType = MIME_TYPES[ext];
if (!contentType) {
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 });
}
const filePath = path.join(getBrandingDir(), safe);
// Ensure resolved path is still within getBrandingDir()
const resolved = path.resolve(filePath);
if (!resolved.startsWith(path.resolve(getBrandingDir()))) {
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
}
const fileStat = await stat(resolved).catch(() => null);
if (!fileStat || !fileStat.isFile()) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const buffer = await readFile(resolved);
// SVG can carry inline <script> and event handlers that execute when the
// file is fetched as a top-level document. Defense in depth on top of
// admin-only upload: nosniff blocks MIME confusion, the CSP forces a
// sandboxed unique origin so any script in an SVG is inert and cannot
// touch app cookies or storage.
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600, must-revalidate',
'Content-Length': String(buffer.length),
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy':
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
},
});
} catch {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
}
+277
View File
@@ -0,0 +1,277 @@
import { NextRequest, NextResponse } from 'next/server';
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, readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
function getBrandingDir(): string {
return path.join(getConfigDir(), 'branding');
}
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
const ALLOWED_MIME_TYPES = new Set([
'image/svg+xml',
'image/png',
'image/jpeg',
'image/webp',
'image/x-icon',
'image/vnd.microsoft.icon',
]);
type UploadSlot = BrandingOverrideKey;
/** Slots that correspond to branding config keys */
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 {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
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 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 });
}
if (!ALLOWED_MIME_TYPES.has(file.type)) {
return NextResponse.json(
{ error: `Unsupported file type: ${file.type}. Allowed: SVG, PNG, JPEG, WebP, ICO` },
{ status: 400 },
);
}
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);
if (!existsSync(getBrandingDir())) {
await mkdir(getBrandingDir(), { recursive: true });
}
// 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);
const servedUrl = `/api/admin/branding/${safeName}`;
await configManager.ensureLoaded();
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) {
logger.error('Branding upload error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* DELETE /api/admin/branding - Remove an uploaded branding file
*
* 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 {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
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 as UploadSlot)) {
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
}
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;
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;
}
}
}
await configManager.ensureLoaded();
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, host: host || undefined, fileRemoved: removed }, ip);
return NextResponse.json({ success: true });
} catch (error) {
logger.error('Branding delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { changeAdminPassword } from '@/lib/admin/password';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
/**
* POST /api/admin/change-password - Change admin password
*/
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
const { currentPassword, newPassword } = await request.json();
if (!currentPassword || !newPassword || typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
return NextResponse.json({ error: 'Both current and new password are required' }, { status: 400 });
}
if (newPassword.length < 8) {
return NextResponse.json({ error: 'New password must be at least 8 characters' }, { status: 400 });
}
const success = await changeAdminPassword(currentPassword, newPassword);
if (!success) {
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 });
}
await auditLog('admin.change-password', {}, ip);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Admin change password error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+155
View File
@@ -0,0 +1,155 @@
import { NextRequest, NextResponse } from 'next/server';
import { configManager } from '@/lib/admin/config-manager';
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
// can warn about a placeholder session secret without us ever returning the
// raw value to the client.
const SENSITIVE_PLACEHOLDERS = new Set(['your-secret-key-here']);
/**
* GET /api/admin/config - Get full config with sources (admin-protected)
*
* Sensitive keys (sessionSecret, oauthClientSecret) are returned with
* `value` omitted and a `hasValue` boolean instead. An admin session is
* enough to read every other config knob; the secrets themselves stay on
* the server so that an XSS or session-theft can't lift them in one
* request and forge admin/user session cookies offline.
*/
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
await configManager.ensureLoaded();
const config = configManager.getAllWithSources();
const safe: Record<string, { value?: unknown; source: 'admin' | 'env' | 'default'; hasValue?: boolean }> = {};
for (const [key, entry] of Object.entries(config)) {
if (SENSITIVE_CONFIG_KEYS.has(key)) {
const v = entry.value;
const hasValue =
typeof v === 'string' && v.length > 0 && !SENSITIVE_PLACEHOLDERS.has(v);
safe[key] = { source: entry.source, hasValue };
} else {
safe[key] = entry;
}
}
return NextResponse.json(safe, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Admin config read error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PATCH /api/admin/config - Update config overrides (admin-protected)
*/
export async function PATCH(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
const updates = await request.json();
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
return NextResponse.json({ error: 'Request body must be an object' }, { status: 400 });
}
// Validate keys
const validKeys = Object.keys(CONFIG_ENV_MAP);
const invalidKeys = Object.keys(updates).filter(k => !validKeys.includes(k));
if (invalidKeys.length > 0) {
return NextResponse.json({ error: `Unknown config keys: ${invalidKeys.join(', ')}` }, { status: 400 });
}
// Normalize jmapServers: pass through the parser so invalid entries are
// rejected (bad ids, duplicate ids, non-HTTP URLs) before they're persisted.
if ('jmapServers' in updates) {
const incoming = updates.jmapServers;
if (incoming != null && !Array.isArray(incoming)) {
return NextResponse.json({ error: 'jmapServers must be an array' }, { status: 400 });
}
const sanitized = parseJmapServers(incoming);
const incomingCount = Array.isArray(incoming) ? incoming.length : 0;
if (sanitized.length !== incomingCount) {
return NextResponse.json({
error: 'One or more jmapServers entries are invalid (each needs a unique id, label, and HTTP(S) url).',
}, { status: 400 });
}
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)) {
oldValues[key] = configManager.get(key);
}
await configManager.setAdminConfig(updates);
await auditLog('config.update', { changes: Object.keys(updates).map(k => ({ key: k, old: oldValues[k], new: updates[k] })) }, ip);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Admin config update error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* DELETE /api/admin/config - Remove admin override for a key (revert to env/default)
*/
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
const { key } = await request.json();
if (!key || typeof key !== 'string') {
return NextResponse.json({ error: 'Key is required' }, { status: 400 });
}
if (!CONFIG_ENV_MAP[key]) {
return NextResponse.json({ error: `Unknown config key: ${key}` }, { status: 400 });
}
const oldValue = configManager.get(key);
await configManager.removeAdminOverride(key);
await auditLog('config.revert', { key, oldValue }, ip);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Admin config revert error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+229
View File
@@ -0,0 +1,229 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/admin/session';
import { logger } from '@/lib/logger';
import {
getPluginRegistry,
getThemeRegistry,
} from '@/lib/admin/plugin-registry';
import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
import { configManager } from '@/lib/admin/config-manager';
async function getDirectoryUrl(): Promise<string> {
await configManager.ensureLoaded();
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
}
const MAX_PREVIEW_SOURCE_LEN = 100_000;
/**
* GET /api/admin/marketplace/[slug]
* Returns full preview info for an extension: directory metadata,
* the bundle's manifest, a (truncated) source preview, and install status.
* Lets admins audit what they're about to install before pressing the button.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> },
) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const { slug } = await params;
const directoryUrl = await getDirectoryUrl();
// 1. Extension metadata + screenshots + theme previews from the directory
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, directoryUrl);
const detailRes = await fetch(detailUrl.toString(), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!detailRes.ok) {
const status = detailRes.status === 404 ? 404 : 502;
return NextResponse.json(
{ error: status === 404 ? 'Extension not found' : 'Directory request failed' },
{ status },
);
}
const detailJson = await detailRes.json();
const extension = detailJson.data as Record<string, unknown> | undefined;
if (!extension) {
return NextResponse.json({ error: 'Extension not found' }, { status: 404 });
}
const type = extension.type as 'plugin' | 'theme';
const latestVersion = (extension.latestVersion as { version?: string } | null)?.version
?? null;
// 2. Pull the bundle so we can show what's actually inside.
let manifest: Record<string, unknown> | null = null;
let sourcePreview: { name: string; content: string; truncated: boolean } | null = null;
let bundleError: string | null = null;
let bundleSize = 0;
if (latestVersion) {
try {
const bundleUrl = new URL(
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
directoryUrl,
);
const bundleRes = await fetch(bundleUrl.toString(), {
signal: AbortSignal.timeout(30000),
});
if (!bundleRes.ok) {
bundleError = `Bundle download failed (${bundleRes.status})`;
} else {
const buffer = await bundleRes.arrayBuffer();
bundleSize = buffer.byteLength;
const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE;
if (buffer.byteLength > maxSize) {
bundleError = `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit`;
} else {
const zip = await JSZip.loadAsync(buffer);
// Detect optional root directory inside the ZIP.
const entries = Object.keys(zip.files);
const topDirs = new Set(entries.map((e) => e.split('/')[0]));
let root = '';
if (topDirs.size === 1) {
const dir = [...topDirs][0];
if (zip.files[dir + '/'] || entries.some((e) => e.startsWith(dir + '/'))) {
root = dir + '/';
}
}
const manifestFile = zip.file(root + 'manifest.json');
if (!manifestFile) {
bundleError = 'Bundle missing manifest.json';
} else {
try {
manifest = JSON.parse(await manifestFile.async('string'));
} catch {
bundleError = 'Invalid manifest.json in bundle';
}
}
if (manifest) {
if (type === 'theme') {
const cssFile = zip.file(root + 'theme.css');
if (cssFile) {
const css = await cssFile.async('string');
sourcePreview = {
name: 'theme.css',
content: css.length > MAX_PREVIEW_SOURCE_LEN
? css.slice(0, MAX_PREVIEW_SOURCE_LEN)
: css,
truncated: css.length > MAX_PREVIEW_SOURCE_LEN,
};
}
} else {
const entrypoint = (manifest.entrypoint as string) || 'index.js';
const jsFile = zip.file(root + entrypoint);
if (jsFile) {
const code = await jsFile.async('string');
sourcePreview = {
name: entrypoint,
content: code.length > MAX_PREVIEW_SOURCE_LEN
? code.slice(0, MAX_PREVIEW_SOURCE_LEN)
: code,
truncated: code.length > MAX_PREVIEW_SOURCE_LEN,
};
}
}
}
}
}
} catch (err) {
bundleError = err instanceof Error ? err.message : 'Failed to read bundle';
}
} else {
bundleError = 'Extension has no published version';
}
// 3. Install status (slug is used as the registry id at install time)
const [pluginRegistry, themeRegistry] = await Promise.all([
getPluginRegistry(),
getThemeRegistry(),
]);
const installedEntry = type === 'theme'
? themeRegistry.themes.find((t) => t.id === slug)
: pluginRegistry.plugins.find((p) => p.id === slug);
const installed = installedEntry !== undefined;
const installedVersion = installedEntry?.version ?? null;
// 4. Build screenshot URLs (proxy through the directory's public files endpoint).
const screenshots = Array.isArray(extension.screenshots)
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
url: new URL(`/api/v1/files/${s.path}`, directoryUrl).toString(),
altText: s.altText ?? null,
}))
: [];
// Strip the heavy `manifest` blob from versions when echoing the directory data.
const versions = Array.isArray(extension.versions)
? (extension.versions as Array<Record<string, unknown>>).map((v) => ({
version: v.version,
changelog: v.changelog,
bundleSize: v.bundleSize,
minAppVersion: v.minAppVersion,
publishedAt: v.publishedAt,
permissions: v.permissions,
}))
: [];
const fileUrl = (path: unknown): string | null =>
typeof path === 'string' && path
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
: null;
return NextResponse.json(
{
extension: {
slug: extension.slug,
name: extension.name,
type: extension.type,
pluginType: extension.pluginType ?? null,
description: extension.description,
longDescription: extension.longDescription ?? null,
tags: extension.tags ?? [],
permissions: extension.permissions ?? [],
totalDownloads: extension.totalDownloads ?? 0,
featured: extension.featured ?? false,
githubRepo: extension.githubRepo ?? null,
license: extension.license ?? null,
minAppVersion: extension.minAppVersion ?? null,
iconUrl: fileUrl(extension.iconPath),
bannerUrl: fileUrl(extension.bannerPath),
author: extension.author ?? null,
latestVersion,
versions,
screenshots,
themePreviews: extension.themePreviews ?? [],
createdAt: extension.createdAt ?? null,
updatedAt: extension.updatedAt ?? null,
},
bundle: {
manifest,
source: sourcePreview,
size: bundleSize,
error: bundleError,
},
installed,
installedVersion,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (error) {
logger.error('Marketplace preview error', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json(
{ error: 'Failed to load preview' },
{ status: 502 },
);
}
}
+393
View File
@@ -0,0 +1,393 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import {
savePlugin,
saveTheme,
getPlugin,
getTheme,
getPluginRegistry,
getThemeRegistry,
type ServerPlugin,
type ServerTheme,
} from '@/lib/admin/plugin-registry';
import {
sanitizeFrameOrigins,
sanitizeHttpOrigins,
sanitizeApiPostPaths,
invalidateFrameOriginsCache,
} from '@/lib/admin/csp-frame-origins';
import JSZip from 'jszip';
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';
async function getDirectoryUrl(): Promise<string> {
await configManager.ensureLoaded();
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
}
/**
* GET /api/admin/marketplace - Search/browse the extension directory
* Proxies to the extension directory API
*/
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const directoryUrl = await getDirectoryUrl();
const { searchParams } = request.nextUrl;
const url = new URL('/api/v1/extensions', directoryUrl);
// Forward all search params
for (const [key, value] of searchParams.entries()) {
url.searchParams.set(key, value);
}
const res = await fetch(url.toString(), {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
return NextResponse.json(
{ error: 'Failed to fetch from extension directory' },
{ status: 502 }
);
}
const data = await res.json();
// Enrich with install status
const [pluginRegistry, themeRegistry] = await Promise.all([
getPluginRegistry(),
getThemeRegistry(),
]);
const installedPluginVersions = new Map(
pluginRegistry.plugins.map(p => [p.id, p.version] as const),
);
const installedThemeVersions = new Map(
themeRegistry.themes.map(t => [t.id, t.version] as const),
);
const fileUrl = (path: unknown): string | null =>
typeof path === 'string' && path
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
: null;
if (data.data) {
data.data = data.data.map((ext: Record<string, unknown>) => {
const slug = ext.slug as string;
const installedVersion = ext.type === 'theme'
? installedThemeVersions.get(slug) ?? null
: installedPluginVersions.get(slug) ?? null;
return {
...ext,
iconUrl: fileUrl(ext.iconPath),
bannerUrl: fileUrl(ext.bannerPath),
installed: installedVersion !== null,
installedVersion,
};
});
}
return NextResponse.json(data, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Marketplace search error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Failed to connect to extension directory' }, { status: 502 });
}
}
/**
* POST /api/admin/marketplace - Install an extension from the directory
* Body: { slug: string, version: string, type: 'plugin' | 'theme' }
*/
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
const { slug, version, type } = await request.json();
if (!slug || !version || !type) {
return NextResponse.json({ error: 'Missing slug, version, or type' }, { status: 400 });
}
if (type !== 'plugin' && type !== 'theme') {
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
}
// Download the bundle from the directory
const directoryUrl = await getDirectoryUrl();
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, directoryUrl);
const bundleRes = await fetch(bundleUrl.toString(), {
signal: AbortSignal.timeout(30000),
});
if (!bundleRes.ok) {
return NextResponse.json(
{ error: `Failed to download bundle: ${bundleRes.status}` },
{ status: 502 }
);
}
const buffer = await bundleRes.arrayBuffer();
const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE;
if (buffer.byteLength > maxSize) {
return NextResponse.json(
{ error: `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit` },
{ status: 400 }
);
}
// Parse the ZIP
let zip: JSZip;
try {
zip = await JSZip.loadAsync(buffer);
} catch {
return NextResponse.json({ error: 'Invalid ZIP file from directory' }, { status: 400 });
}
// Find root directory
const entries = Object.keys(zip.files);
const topDirs = new Set(entries.map(e => e.split('/')[0]));
let root = '';
if (topDirs.size === 1) {
const dir = [...topDirs][0];
if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
root = dir + '/';
}
}
// Read manifest
const manifestFile = zip.file(root + 'manifest.json');
if (!manifestFile) {
return NextResponse.json({ error: 'Bundle missing manifest.json' }, { status: 400 });
}
let manifest: Record<string, unknown>;
try {
manifest = JSON.parse(await manifestFile.async('string'));
} catch {
return NextResponse.json({ error: 'Invalid manifest.json in bundle' }, { status: 400 });
}
const now = new Date().toISOString();
// Resolve and strictly validate the id used as a filename. Marketplace
// bundles are authored by a third-party publisher; without this an id
// like "../../foo" causes savePlugin/saveTheme to write outside the
// plugins/themes dir via path.join.
const resolvedId = typeof manifest.id === 'string' && manifest.id ? manifest.id : slug;
if (typeof resolvedId !== 'string' || !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(resolvedId)) {
return NextResponse.json(
{ error: 'Invalid id: must be lowercase alphanumeric with hyphens, min 2 chars' },
{ status: 400 },
);
}
if (type === 'theme') {
// Read theme.css
const cssFile = zip.file(root + 'theme.css');
if (!cssFile) {
return NextResponse.json({ error: 'Theme bundle missing theme.css' }, { status: 400 });
}
let css = await cssFile.async('string');
// Validate and sanitize CSS
const warnings: string[] = [];
const safety = validateThemeCSSSafety(css);
if (!safety.valid) {
const sanitized = sanitizeThemeCSS(css);
css = sanitized.css;
warnings.push(...sanitized.warnings);
}
const existingTheme = await getTheme(resolvedId);
const isUpdate = existingTheme !== null;
const theme: ServerTheme = {
id: resolvedId,
name: (manifest.name as string) || slug,
// Prefer the directory-published version (what we requested) over
// manifest.version. Publishers sometimes forget to bump the version
// inside the bundle's manifest.json; trusting it would make the
// update never appear to "stick" — the registry would keep showing
// the older version even after a successful update.
version: version || (manifest.version as string),
author: (manifest.author as string) || 'Unknown',
description: (manifest.description as string) || '',
variants: (manifest.variants as string[]) || ['light', 'dark'],
enabled: existingTheme?.enabled ?? true,
...(existingTheme?.forceEnabled !== undefined
? { forceEnabled: existingTheme.forceEnabled }
: {}),
installedAt: existingTheme?.installedAt ?? now,
updatedAt: now,
};
await saveTheme(theme, css);
await auditLog(
isUpdate ? 'marketplace.update_theme' : 'marketplace.install_theme',
{
id: theme.id,
name: theme.name,
version: theme.version,
slug,
...(isUpdate ? { previousVersion: existingTheme.version } : {}),
},
ip,
);
return NextResponse.json({ success: true, theme, warnings, updated: isUpdate });
} else {
// Plugin installation
// Read entrypoint JS
const entrypoint = (manifest.entrypoint as string) || 'index.js';
const jsFile = zip.file(root + entrypoint);
if (!jsFile) {
return NextResponse.json({ error: `Bundle missing entrypoint: ${entrypoint}` }, { status: 400 });
}
const code = await jsFile.async('string');
// Block plugins with dangerous JS patterns
const DANGEROUS_JS_PATTERNS = [
{ pattern: /\beval\s*\(/g, label: 'eval()' },
{ pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
{ pattern: /document\.cookie/g, label: 'document.cookie' },
{ pattern: /document\.write/g, label: 'document.write' },
{ pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
];
const dangerousFindings: string[] = [];
for (const { pattern, label } of DANGEROUS_JS_PATTERNS) {
if (pattern.test(code)) dangerousFindings.push(label);
pattern.lastIndex = 0;
}
if (dangerousFindings.length > 0) {
return NextResponse.json(
{ error: `Plugin rejected: contains ${dangerousFindings.join(', ')}. These patterns are not allowed for security reasons.` },
{ status: 400 },
);
}
// Validate permissions
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
const unknownPerms = permissions.filter(p => !validPerms.has(p));
const warnings: string[] = [];
if (unknownPerms.length > 0) {
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
}
// Plugins may declare iframe origins they need for embedded content.
// Anything that doesn't pass strict origin validation is silently
// dropped - the plugin still installs, but those origins are not
// added to the host CSP.
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
? (manifest.frameOrigins as unknown[]).filter(
(v) => typeof v !== 'string' || !declaredFrameOrigins.includes(v),
)
: [];
if (droppedFrameOrigins.length > 0) {
warnings.push(
`Ignored invalid frameOrigins: ${droppedFrameOrigins.join(', ')}`,
);
}
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
const droppedHttpOrigins = Array.isArray(manifest.httpOrigins)
? (manifest.httpOrigins as unknown[]).filter(
(v) => typeof v !== 'string' || !declaredHttpOrigins.includes(v),
)
: [];
if (droppedHttpOrigins.length > 0) {
warnings.push(
`Ignored invalid httpOrigins: ${droppedHttpOrigins.join(', ')}`,
);
}
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
const droppedApiPostPaths = Array.isArray(manifest.apiPostPaths)
? (manifest.apiPostPaths as unknown[]).filter(
(v) => typeof v !== 'string' || !declaredApiPostPaths.includes(v),
)
: [];
if (droppedApiPostPaths.length > 0) {
warnings.push(
`Ignored invalid apiPostPaths: ${droppedApiPostPaths.join(', ')}`,
);
}
const existingPlugin = await getPlugin(resolvedId);
const isUpdate = existingPlugin !== null;
const plugin: ServerPlugin = {
id: resolvedId,
name: (manifest.name as string) || slug,
// See theme branch: trust the directory-published version, not
// manifest.version, so updates actually stick in the registry.
version: version || (manifest.version as string),
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,
...(existingPlugin?.forceEnabled !== undefined
? { forceEnabled: existingPlugin.forceEnabled }
: {}),
installedAt: existingPlugin?.installedAt ?? now,
updatedAt: now,
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
: {}),
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
: {}),
...(declaredFrameOrigins.length > 0
? { frameOrigins: declaredFrameOrigins }
: {}),
...(declaredHttpOrigins.length > 0
? { httpOrigins: declaredHttpOrigins }
: {}),
...(declaredApiPostPaths.length > 0
? { apiPostPaths: declaredApiPostPaths }
: {}),
};
await savePlugin(plugin, code);
invalidateFrameOriginsCache();
await auditLog(
isUpdate ? 'marketplace.update_plugin' : 'marketplace.install_plugin',
{
id: plugin.id,
name: plugin.name,
version: plugin.version,
slug,
frameOrigins: declaredFrameOrigins,
httpOrigins: declaredHttpOrigins,
apiPostPaths: declaredApiPostPaths,
...(isUpdate ? { previousVersion: existingPlugin.version } : {}),
},
ip,
);
return NextResponse.json({ success: true, plugin, warnings, updated: isUpdate });
}
} catch (error) {
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Installation failed' }, { status: 500 });
}
}
+253
View File
@@ -0,0 +1,253 @@
import { NextRequest, NextResponse } from 'next/server';
import { randomBytes } from 'node:crypto';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { configManager } from '@/lib/admin/config-manager';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import { locales as ALL_LOCALES } from '@/i18n/routing';
const CLIENT_ID = 'bulwark-webmail';
const CLIENT_DESCRIPTION = 'Bulwark Webmail (auto-configured)';
const JMAP_TIMEOUT_MS = 10_000;
interface JmapMethodCall {
using: string[];
methodCalls: Array<[string, Record<string, unknown>, string]>;
}
interface JmapMethodResponse {
methodResponses?: Array<[string, Record<string, unknown>, string]>;
}
async function fetchWithTimeout(url: string, init: Parameters<typeof fetch>[1]): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), JMAP_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
async function jmapCall(
serverUrl: string,
authHeader: string,
body: JmapMethodCall,
): Promise<JmapMethodResponse> {
const res = await fetchWithTimeout(`${serverUrl}/jmap/`, {
method: 'POST',
headers: { 'Authorization': authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`JMAP HTTP ${res.status} ${text.slice(0, 200)}`);
}
return res.json() as Promise<JmapMethodResponse>;
}
async function getStalwartAccountId(
serverUrl: string,
authHeader: string,
): Promise<string | null> {
const res = await fetchWithTimeout(`${serverUrl}/.well-known/jmap`, {
method: 'GET',
headers: { 'Authorization': authHeader },
});
if (!res.ok) return null;
const session = await res.json() as { primaryAccounts?: Record<string, string> };
return session.primaryAccounts?.['urn:stalwart:jmap']
?? session.primaryAccounts?.['urn:ietf:params:jmap:mail']
?? Object.values(session.primaryAccounts ?? {})[0]
?? null;
}
function buildRedirectUris(origin: string, localeList: readonly string[]): Record<string, true> {
const out: Record<string, true> = {};
for (const loc of localeList) {
out[`${origin}/${loc}/auth/callback`] = true;
}
return out;
}
interface SetupRequestBody {
origin?: string;
issuerUrl?: string;
locales?: string[];
oauthOnly?: boolean;
}
function isValidOriginUrl(value: string): boolean {
return /^https?:\/\/[^/]+$/.test(value);
}
export async function POST(request: NextRequest) {
try {
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
const ip = getClientIP(request);
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json(
{ error: 'No Stalwart session available. Sign in to your mail account in another tab and retry.' },
{ status: 400 },
);
}
const body = await request.json() as SetupRequestBody;
const origin = (body.origin ?? '').trim().replace(/\/+$/, '');
if (!isValidOriginUrl(origin)) {
return NextResponse.json(
{ error: 'Webmail origin must be a URL like "https://webmail.example.com" with no path.' },
{ status: 400 },
);
}
const issuerUrl = (body.issuerUrl ?? origin).trim().replace(/\/+$/, '');
if (!isValidOriginUrl(issuerUrl)) {
return NextResponse.json(
{ error: 'Stalwart issuer URL must be a URL like "https://mail.example.com" with no path.' },
{ status: 400 },
);
}
const localeList = Array.isArray(body.locales) && body.locales.length > 0
? body.locales.filter(l => typeof l === 'string' && /^[a-z]{2,5}(-[A-Za-z0-9]+)*$/.test(l))
: Array.from(ALL_LOCALES);
if (localeList.length === 0) {
return NextResponse.json({ error: 'No valid locales supplied.' }, { status: 400 });
}
const oauthOnly = body.oauthOnly === true;
const accountId = await getStalwartAccountId(creds.serverUrl, creds.authHeader);
if (!accountId) {
return NextResponse.json(
{ error: 'Could not resolve Stalwart account from JMAP session.' },
{ status: 502 },
);
}
const queryRes = await jmapCall(creds.serverUrl, creds.authHeader, {
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
methodCalls: [[
'x:OAuthClient/query',
{ accountId, filter: { clientId: CLIENT_ID } },
'0',
]],
});
const queryEntry = queryRes.methodResponses?.[0];
if (!queryEntry || queryEntry[0] === 'error') {
return NextResponse.json({
error: 'Stalwart denied OAuthClient/query - your Stalwart account likely lacks admin permissions.',
detail: queryEntry?.[1],
}, { status: 403 });
}
const existingIds = (queryEntry[1].ids as string[] | undefined) ?? [];
const secret = randomBytes(32).toString('base64url');
const redirectUris = buildRedirectUris(origin, localeList);
let setArgs: Record<string, unknown>;
let action: 'created' | 'updated';
if (existingIds.length > 0) {
const targetId = existingIds[0];
action = 'updated';
setArgs = {
accountId,
update: {
[targetId]: {
secret,
redirectUris,
description: CLIENT_DESCRIPTION,
},
},
};
} else {
action = 'created';
setArgs = {
accountId,
create: {
new: {
clientId: CLIENT_ID,
description: CLIENT_DESCRIPTION,
secret,
redirectUris,
contacts: { [creds.username]: true },
},
},
};
}
const setRes = await jmapCall(creds.serverUrl, creds.authHeader, {
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
methodCalls: [['x:OAuthClient/set', setArgs, '0']],
});
const setEntry = setRes.methodResponses?.[0];
if (!setEntry || setEntry[0] === 'error') {
return NextResponse.json({
error: 'Stalwart denied OAuthClient/set - admin permissions required.',
detail: setEntry?.[1],
}, { status: 403 });
}
const setBody = setEntry[1] as {
notCreated?: Record<string, unknown>;
notUpdated?: Record<string, unknown>;
};
if (setBody.notCreated && Object.keys(setBody.notCreated).length > 0) {
return NextResponse.json(
{ error: 'Stalwart refused to create the OAuth client.', detail: setBody.notCreated },
{ status: 502 },
);
}
if (setBody.notUpdated && Object.keys(setBody.notUpdated).length > 0) {
return NextResponse.json(
{ error: 'Stalwart refused to update the OAuth client.', detail: setBody.notUpdated },
{ status: 502 },
);
}
await configManager.ensureLoaded();
const updates: Record<string, unknown> = {
oauthEnabled: true,
oauthClientId: CLIENT_ID,
oauthClientSecret: secret,
oauthIssuerUrl: issuerUrl,
};
if (oauthOnly) updates.oauthOnly = true;
await configManager.setAdminConfig(updates);
await auditLog('admin.oauth_setup', {
action,
clientId: CLIENT_ID,
origin,
issuer: issuerUrl,
redirectUriCount: localeList.length,
oauthOnly,
}, ip);
logger.info('Admin OAuth setup', {
action,
clientId: CLIENT_ID,
origin,
issuer: issuerUrl,
locales: localeList.length,
});
return NextResponse.json({
ok: true,
action,
clientId: CLIENT_ID,
origin,
issuerUrl,
redirectUriCount: localeList.length,
});
} catch (error) {
logger.error('Admin OAuth setup error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
{ status: 500 },
);
}
}
+85
View File
@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import { listApprovals, decideApproval, revokeApproval } from '@/lib/admin/plugin-approvals';
/**
* Admin-protected CRUD for the per-(pluginId, bundleHash) approval table.
*
* GET /api/admin/plugin-approvals list all entries
* POST /api/admin/plugin-approvals { pluginId, bundleHash, decision: 'approved'|'denied' }
* DELETE /api/admin/plugin-approvals?pluginId=&bundleHash= revoke
*/
function isValidId(s: unknown): s is string {
return typeof s === 'string' && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(s) && s.length <= 64;
}
function isValidHash(s: unknown): s is string {
return typeof s === 'string' && /^[a-f0-9]{16,128}$/i.test(s);
}
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const entries = await listApprovals();
return NextResponse.json({ entries }, { headers: { 'Cache-Control': 'no-store' } });
} catch (err) {
logger.error('plugin-approvals GET', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
// AdminSessionPayload carries only role/iat/exp; we use a stable label
// for the audit trail rather than a per-user identity.
const adminUser = 'admin';
void result;
const ip = getClientIP(request);
let body: unknown;
try { body = await request.json(); } catch { body = null; }
const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; decision?: unknown };
if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) {
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
}
if (b.decision !== 'approved' && b.decision !== 'denied') {
return NextResponse.json({ error: 'decision must be "approved" or "denied"' }, { status: 400 });
}
const entry = await decideApproval(b.pluginId, b.bundleHash, b.decision, adminUser);
await auditLog('plugin.approval', { pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision: entry.status }, ip);
return NextResponse.json({ entry });
} catch (err) {
logger.error('plugin-approvals POST', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
// AdminSessionPayload carries only role/iat/exp; we use a stable label
// for the audit trail rather than a per-user identity.
const adminUser = 'admin';
void result;
const ip = getClientIP(request);
const pluginId = request.nextUrl.searchParams.get('pluginId');
const bundleHash = request.nextUrl.searchParams.get('bundleHash');
if (!isValidId(pluginId) || !isValidHash(bundleHash)) {
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
}
await revokeApproval(pluginId, bundleHash);
await auditLog('plugin.approval.revoke', { pluginId, bundleHash, by: adminUser }, ip);
return NextResponse.json({ ok: true });
} catch (err) {
logger.error('plugin-approvals DELETE', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev';
import { signBytes } from '@/lib/admin/plugin-signing';
async function safeSign(code: string): Promise<string | null> {
try { return await signBytes(code); } catch { return null; }
}
/**
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
*
* Public endpoint so the client-side plugin loader can fetch bundles.
* Only serves plugins that exist in the registry and are enabled.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
// Validate ID format to prevent path traversal
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
}
// Dev plugins are read (and optionally bundled) straight from disk and
// served with no caching so every refresh picks up the latest source.
const devEntry = await getDevPlugin(id);
if (devEntry) {
const code = await readDevBundle(devEntry);
const signature = await safeSign(code);
const headers: Record<string, string> = {
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-store',
'ETag': `"${devEntry.plugin.bundleHash}"`,
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
};
if (signature) headers['X-Bundle-Signature'] = signature;
return new NextResponse(code, { headers });
}
const plugin = await getPlugin(id);
if (!plugin) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
if (!plugin.enabled) {
return NextResponse.json({ error: 'Plugin is disabled' }, { status: 403 });
}
const code = await getPluginBundle(id);
if (!code) {
return NextResponse.json({ error: 'Bundle not found' }, { status: 404 });
}
// Use the registry's bundleHash as the ETag so the browser can revalidate
// cheaply. Cache-Control: no-cache forces revalidation on every request,
// but a matching If-None-Match returns 304 with no body.
const etag = plugin.bundleHash ? `"${plugin.bundleHash}"` : undefined;
const headers: Record<string, string> = {
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'private, no-cache, must-revalidate',
};
if (etag) headers['ETag'] = etag;
const signature = await safeSign(code);
if (signature) headers['X-Bundle-Signature'] = signature;
if (etag && request.headers.get('if-none-match') === etag) {
return new NextResponse(null, { status: 304, headers });
}
headers['Content-Length'] = String(Buffer.byteLength(code, 'utf-8'));
return new NextResponse(code, { headers });
} catch {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
}
+167
View File
@@ -0,0 +1,167 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPlugin } from '@/lib/admin/plugin-registry';
import { getDevPlugin } from '@/lib/admin/plugin-dev';
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
import { requireAdminAuth } from '@/lib/admin/session';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/** Resolve a plugin from the persisted registry first, then PLUGIN_DEV_DIR. */
async function resolvePlugin(id: string) {
const registered = await getPlugin(id);
if (registered) return registered;
const dev = await getDevPlugin(id);
return dev?.plugin ?? null;
}
/**
* GET /api/admin/plugins/[id]/config - Read plugin config
*
* - Admin sessions receive every field, including those declared
* `type: 'secret'` in the plugin's configSchema.
* - Authenticated mailbox users (the plugin running in their browser)
* receive only non-secret fields.
* - Anonymous callers are rejected so unauthenticated visitors cannot
* enumerate plugin secrets.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
}
const adminAuth = await requireAdminAuth(request);
const isAdmin = !('error' in adminAuth);
if (!isAdmin) {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
}
const plugin = await resolvePlugin(id);
if (!plugin) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
const config = await getPluginConfig(id);
let response: Record<string, unknown>;
if (isAdmin) {
response = config;
} else {
response = {};
const schema = plugin.configSchema;
if (schema) {
for (const [key, value] of Object.entries(config)) {
const field = schema[key];
if (!field || field.type === 'secret') continue;
response[key] = value;
}
}
}
return NextResponse.json(response, {
headers: { 'Cache-Control': 'no-store' },
});
} catch {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PUT /api/admin/plugins/[id]/config - Set a config key
*
* Body: { key: string, value: unknown }
* Requires admin authentication (checked via admin session).
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const { id } = await params;
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
}
const plugin = await resolvePlugin(id);
if (!plugin) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
let body: { key?: string; value?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
if (!body.key || typeof body.key !== 'string') {
return NextResponse.json({ error: 'key is required and must be a string' }, { status: 400 });
}
// Validate key format (alphanumeric, hyphens, underscores, dots)
if (!/^[a-zA-Z0-9._-]+$/.test(body.key)) {
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
}
if (plugin.configSchema && !plugin.configSchema[body.key]) {
return NextResponse.json(
{ error: 'Key is not declared in the plugin configSchema' },
{ status: 400 },
);
}
await setPluginConfig(id, body.key, body.value);
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* DELETE /api/admin/plugins/[id]/config - Delete a config key
*
* Body: { key: string }
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const { id } = await params;
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
}
let body: { key?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
if (!body.key || typeof body.key !== 'string') {
return NextResponse.json({ error: 'key is required' }, { status: 400 });
}
await deletePluginConfigKey(id, body.key);
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+302
View File
@@ -0,0 +1,302 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import {
getPluginRegistry,
savePlugin,
deletePlugin as removePlugin,
type ServerPlugin,
} from '@/lib/admin/plugin-registry';
import { listDevPlugins } from '@/lib/admin/plugin-dev';
import {
sanitizeFrameOrigins,
sanitizeHttpOrigins,
sanitizeApiPostPaths,
invalidateFrameOriginsCache,
} from '@/lib/admin/csp-frame-origins';
// Server-side extraction using the same validation logic
// ZIP parsing needs to happen on the server for admin-uploaded plugins
import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
const SUSPICIOUS_JS_PATTERNS = [
{ pattern: /\beval\s*\(/g, label: 'eval()' },
{ pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
{ pattern: /document\.cookie/g, label: 'document.cookie' },
{ pattern: /document\.write/g, label: 'document.write' },
{ pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
];
/**
* GET /api/admin/plugins - List all admin-managed plugins
*/
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const [registry, devEntries] = await Promise.all([
getPluginRegistry(),
listDevPlugins(),
]);
// Dev plugins win on id collision so admins see what users actually load.
const devIds = new Set(devEntries.map(e => e.plugin.id));
const merged = [
...devEntries.map(e => ({ ...e.plugin, dev: true as const })),
...registry.plugins
.filter(p => !devIds.has(p.id))
.map(p => ({ ...p, dev: false as const })),
];
return NextResponse.json(merged, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Plugin list error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/admin/plugins - Upload and install a plugin ZIP
*/
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'Missing file' }, { status: 400 });
}
if (file.size > MAX_PLUGIN_SIZE) {
return NextResponse.json({ error: 'Plugin ZIP exceeds 5 MB size limit' }, { status: 400 });
}
// Extract and validate ZIP
let zip: JSZip;
try {
const buffer = await file.arrayBuffer();
zip = await JSZip.loadAsync(buffer);
} catch {
return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 });
}
// Find root
const entries = Object.keys(zip.files);
const topDirs = new Set(entries.map(e => e.split('/')[0]));
let root = '';
if (topDirs.size === 1) {
const dir = [...topDirs][0];
if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
root = dir + '/';
}
}
// Read manifest
const manifestFile = zip.file(root + 'manifest.json');
if (!manifestFile) {
return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 });
}
let manifest: Record<string, unknown>;
try {
manifest = JSON.parse(await manifestFile.async('string'));
} catch {
return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 });
}
// Validate manifest
const errors: string[] = [];
if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"');
if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"');
if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"');
if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"');
if (!manifest.entrypoint || typeof manifest.entrypoint !== 'string') errors.push('Missing or invalid "entrypoint"');
const validTypes = ['ui-extension', 'sidebar-app', 'hook'];
if (!validTypes.includes(manifest.type as string)) {
errors.push(`Invalid type. Must be one of: ${validTypes.join(', ')}`);
}
if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) {
errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars');
}
if (manifest.permissions && Array.isArray(manifest.permissions)) {
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
const unknown = (manifest.permissions as string[]).filter(p => !validPerms.has(p));
if (unknown.length > 0) errors.push(`Unknown permissions: ${unknown.join(', ')}`);
}
if (errors.length > 0) {
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
}
// Check file extensions
for (const [filePath, entry] of Object.entries(zip.files)) {
if (entry.dir) continue;
const ext = filePath.lastIndexOf('.') >= 0 ? filePath.slice(filePath.lastIndexOf('.')).toLowerCase() : '';
if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) {
errors.push(`Disallowed file type: ${filePath}`);
}
}
if (errors.length > 0) {
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
}
// Read entrypoint code
const entryFile = zip.file(root + (manifest.entrypoint as string));
if (!entryFile) {
return NextResponse.json({ error: `Missing entrypoint: ${manifest.entrypoint}` }, { status: 400 });
}
const code = await entryFile.async('string');
// Security: block plugins containing dangerous JS patterns
const warnings: string[] = [];
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
if (pattern.test(code)) warnings.push(`Contains ${label}`);
pattern.lastIndex = 0;
}
if (warnings.length > 0) {
return NextResponse.json(
{ error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` },
{ status: 400 },
);
}
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
const now = new Date().toISOString();
const plugin: ServerPlugin = {
id: manifest.id as string,
name: manifest.name as string,
version: manifest.version as string,
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,
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
: {}),
...(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 }
: {}),
...(declaredHttpOrigins.length > 0
? { httpOrigins: declaredHttpOrigins }
: {}),
...(declaredApiPostPaths.length > 0
? { apiPostPaths: declaredApiPostPaths }
: {}),
installedAt: now,
updatedAt: now,
};
await savePlugin(plugin, code);
invalidateFrameOriginsCache();
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip);
return NextResponse.json({ plugin });
} catch (error) {
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PATCH /api/admin/plugins - Update plugin metadata (enable/disable)
* Body: { id: string, enabled: boolean }
*/
export async function PATCH(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
const { id, enabled, forceEnabled } = await request.json();
if (!id || typeof id !== 'string') {
return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 });
}
if (typeof enabled !== 'boolean' && typeof forceEnabled !== 'boolean') {
return NextResponse.json({ error: 'enabled or forceEnabled must be a boolean' }, { status: 400 });
}
const updates: { enabled?: boolean; forceEnabled?: boolean } = {};
if (typeof enabled === 'boolean') updates.enabled = enabled;
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
let updated = await updatePluginMeta(id, updates);
if (!updated) {
// Dev plugins (PLUGIN_DEV_DIR) aren't in the persisted registry, but
// forceEnabled is canonical-stored in policy.forceEnabledPlugins on the
// client. Skip the registry write and return the live dev plugin so the
// policy save path can proceed.
const devEntries = await listDevPlugins();
const devEntry = devEntries.find(e => e.plugin.id === id);
if (!devEntry) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
updated = { ...devEntry.plugin, ...updates };
}
// Enable/disable changes the set of plugins contributing frame origins.
if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') {
invalidateFrameOriginsCache();
}
await auditLog('plugin.update', { id, ...updates }, ip);
return NextResponse.json({ plugin: updated });
} catch (error) {
logger.error('Plugin update error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* DELETE /api/admin/plugins - Remove a plugin
* Body: { id: string }
*/
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
const { id } = await request.json();
if (!id || typeof id !== 'string') {
return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 });
}
const removed = await removePlugin(id);
if (!removed) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
invalidateFrameOriginsCache();
await auditLog('plugin.delete', { id }, ip);
return NextResponse.json({ success: true });
} catch (error) {
logger.error('Plugin delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+58
View File
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server';
import { configManager } from '@/lib/admin/config-manager';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import type { SettingsPolicy } from '@/lib/admin/types';
/**
* GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this)
*/
export async function GET() {
try {
await configManager.ensureLoaded();
const policy = configManager.getPolicy();
return NextResponse.json(policy, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Policy read error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PUT /api/admin/policy - Update settings policy (admin-protected)
*/
export async function PUT(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
const policy = await request.json() as SettingsPolicy;
if (!policy || typeof policy !== 'object') {
return NextResponse.json({ error: 'Invalid policy object' }, { status: 400 });
}
// Basic validation
if (policy.restrictions && typeof policy.restrictions !== 'object') {
return NextResponse.json({ error: 'restrictions must be an object' }, { status: 400 });
}
if (policy.features && typeof policy.features !== 'object') {
return NextResponse.json({ error: 'features must be an object' }, { status: 400 });
}
if (policy.themePolicy && typeof policy.themePolicy !== 'object') {
return NextResponse.json({ error: 'themePolicy must be an object' }, { status: 400 });
}
await configManager.setPolicy(policy);
await auditLog('policy.update', { restrictionCount: Object.keys(policy.restrictions || {}).length }, ip);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Policy update error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+135
View File
@@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import {
effectiveConsent,
loadState,
saveState,
buildPayload,
sendOnce,
reschedule,
DEFAULT_ENDPOINT,
getLoginCounts,
resolveEndpointAllowed,
} from '@/lib/telemetry';
/**
* GET /api/admin/telemetry
* Returns current consent + endpoint + next/last send + a live preview
* of exactly what the next heartbeat would contain.
*/
export async function GET(request: NextRequest) {
try {
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
const { consent, source, state } = await effectiveConsent();
const [payload, accountCounts] = await Promise.all([
buildPayload(),
getLoginCounts(),
]);
return NextResponse.json(
{
consent,
consentSource: source,
endpoint: state.endpoint || DEFAULT_ENDPOINT,
consentedAt: state.consentedAt,
lastSentAt: state.lastSentAt,
nextScheduledAt: state.nextScheduledAt,
defaultEndpoint: DEFAULT_ENDPOINT,
payloadPreview: payload,
accountCounts,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (err) {
logger.error('telemetry GET error', {
error: err instanceof Error ? err.message : 'unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/admin/telemetry
* Body: { action: 'set-consent' | 'set-endpoint' | 'send-now', ... }
* set-consent : { action, consent: 'on' | 'off' }
* set-endpoint : { action, endpoint: string }
* send-now : { action }
*/
export async function POST(request: NextRequest) {
try {
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
const ip = getClientIP(request);
const body = (await request.json().catch(() => null)) as
| { action?: string; consent?: string; endpoint?: string }
| null;
if (!body || typeof body.action !== 'string') {
return NextResponse.json({ error: 'action required' }, { status: 400 });
}
const { source } = await effectiveConsent();
if (body.action === 'set-consent') {
if (source === 'env') {
return NextResponse.json(
{ error: 'consent is overridden by BULWARK_TELEMETRY env var' },
{ status: 409 },
);
}
if (body.consent !== 'on' && body.consent !== 'off') {
return NextResponse.json({ error: 'consent must be "on" or "off"' }, { status: 400 });
}
const state = await loadState();
const before = state.consent;
state.consent = body.consent;
if (body.consent === 'on' && !state.consentedAt) {
state.consentedAt = new Date().toISOString();
}
await saveState(state);
await reschedule();
await auditLog('telemetry.set-consent', { from: before, to: body.consent }, ip);
return NextResponse.json({ ok: true });
}
if (body.action === 'set-endpoint') {
if (typeof body.endpoint !== 'string') {
return NextResponse.json({ error: 'endpoint required' }, { status: 400 });
}
const trimmed = body.endpoint.trim();
if (trimmed) {
const check = await resolveEndpointAllowed(trimmed);
if (!check.ok) {
return NextResponse.json({ error: check.reason }, { status: 400 });
}
}
const state = await loadState();
const before = state.endpoint;
state.endpoint = trimmed || DEFAULT_ENDPOINT;
await saveState(state);
await auditLog('telemetry.set-endpoint', { from: before, to: state.endpoint }, ip);
return NextResponse.json({ ok: true, endpoint: state.endpoint });
}
if (body.action === 'send-now') {
const result = await sendOnce({ reason: 'admin-manual' });
await auditLog(
'telemetry.send-now',
{ ok: result.ok, status: result.status ?? null, error: result.error ?? null },
ip,
);
return NextResponse.json(result, { status: result.ok ? 200 : 502 });
}
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
} catch (err) {
logger.error('telemetry POST error', {
error: err instanceof Error ? err.message : 'unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry';
import { logger } from '@/lib/logger';
/**
* GET /api/admin/themes/[id]/css - Serve theme CSS to clients
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Validate ID format
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
return NextResponse.json({ error: 'Invalid theme ID' }, { status: 400 });
}
// Verify theme exists and is enabled
const registry = await getThemeRegistry();
const theme = registry.themes.find(t => t.id === id);
if (!theme) {
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
}
if (!theme.enabled) {
return NextResponse.json({ error: 'Theme is disabled' }, { status: 403 });
}
const css = await getThemeCSS(id);
if (!css) {
return NextResponse.json({ error: 'Theme CSS not found' }, { status: 404 });
}
return new NextResponse(css, {
headers: {
'Content-Type': 'text/css; charset=utf-8',
'Cache-Control': 'public, max-age=3600',
'X-Content-Type-Options': 'nosniff',
},
});
} catch (error) {
logger.error('Theme CSS serve error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+217
View File
@@ -0,0 +1,217 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import {
getThemeRegistry,
saveTheme,
deleteTheme as removeTheme,
type ServerTheme,
} from '@/lib/admin/plugin-registry';
import JSZip from 'jszip';
import { MAX_THEME_SIZE } from '@/lib/plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
/**
* GET /api/admin/themes - List all admin-managed themes
*/
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const registry = await getThemeRegistry();
return NextResponse.json(registry.themes, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Theme list error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/admin/themes - Upload and install a theme ZIP
*/
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'Missing file' }, { status: 400 });
}
if (file.size > MAX_THEME_SIZE) {
return NextResponse.json({ error: 'Theme ZIP exceeds 1 MB size limit' }, { status: 400 });
}
// Extract and validate ZIP
let zip: JSZip;
try {
const buffer = await file.arrayBuffer();
zip = await JSZip.loadAsync(buffer);
} catch {
return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 });
}
// Find root
const entries = Object.keys(zip.files);
const topDirs = new Set(entries.map(e => e.split('/')[0]));
let root = '';
if (topDirs.size === 1) {
const dir = [...topDirs][0];
if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
root = dir + '/';
}
}
// Read manifest
const manifestFile = zip.file(root + 'manifest.json');
if (!manifestFile) {
return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 });
}
let manifest: Record<string, unknown>;
try {
manifest = JSON.parse(await manifestFile.async('string'));
} catch {
return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 });
}
// Validate manifest
const errors: string[] = [];
if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"');
if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"');
if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"');
if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"');
if (manifest.type !== 'theme') {
errors.push(`Expected type "theme", got "${manifest.type}"`);
}
if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) {
errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars');
}
if (!manifest.variants || !Array.isArray(manifest.variants) || manifest.variants.length === 0) {
errors.push('Missing or empty "variants" array');
} else {
const valid = manifest.variants.every((v: unknown) => v === 'light' || v === 'dark');
if (!valid) errors.push('Variants must be "light" or "dark"');
}
if (errors.length > 0) {
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
}
// Read theme.css
const cssFile = zip.file(root + 'theme.css');
if (!cssFile) {
return NextResponse.json({ error: 'Missing theme.css' }, { status: 400 });
}
let css = await cssFile.async('string');
// Validate and sanitize CSS
const warnings: string[] = [];
const safety = validateThemeCSSSafety(css);
if (!safety.valid) {
const sanitized = sanitizeThemeCSS(css);
css = sanitized.css;
warnings.push(...sanitized.warnings);
}
const now = new Date().toISOString();
const theme: ServerTheme = {
id: manifest.id as string,
name: manifest.name as string,
version: manifest.version as string,
author: manifest.author as string,
description: (manifest.description as string) || '',
variants: manifest.variants as string[],
enabled: true,
installedAt: now,
updatedAt: now,
};
await saveTheme(theme, css);
await auditLog('theme.install', { id: theme.id, name: theme.name, version: theme.version, warnings }, ip);
return NextResponse.json({ theme, warnings });
} catch (error) {
logger.error('Theme install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PATCH /api/admin/themes - Update theme metadata (enable/disable)
* Body: { id: string, enabled: boolean }
*/
export async function PATCH(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
const { id, enabled, forceEnabled } = await request.json();
if (!id || typeof id !== 'string') {
return NextResponse.json({ error: 'Missing theme id' }, { status: 400 });
}
if (typeof enabled !== 'boolean' && typeof forceEnabled !== 'boolean') {
return NextResponse.json({ error: 'enabled or forceEnabled must be a boolean' }, { status: 400 });
}
const updates: { enabled?: boolean; forceEnabled?: boolean } = {};
if (typeof enabled === 'boolean') updates.enabled = enabled;
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
const { updateThemeMeta } = await import('@/lib/admin/plugin-registry');
const updated = await updateThemeMeta(id, updates);
if (!updated) {
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
}
await auditLog('theme.update', { id, ...updates }, ip);
return NextResponse.json({ theme: updated });
} catch (error) {
logger.error('Theme update error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* DELETE /api/admin/themes - Remove a theme
* Body: { id: string }
*/
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
const { id } = await request.json();
if (!id || typeof id !== 'string') {
return NextResponse.json({ error: 'Missing theme id' }, { status: 400 });
}
const removed = await removeTheme(id);
if (!removed) {
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
}
await auditLog('theme.delete', { id }, ip);
return NextResponse.json({ success: true });
} catch (error) {
logger.error('Theme delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/admin/session';
import { logger } from '@/lib/logger';
import {
loadState,
checkOnce,
effectiveEndpoint,
disabledByEnv,
DEFAULT_VERSION_ENDPOINT,
} from '@/lib/version-check';
/**
* GET /api/admin/version
* Returns the cached update status, last check times, and effective config.
*/
export async function GET(request: NextRequest) {
try {
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
const state = await loadState();
return NextResponse.json(
{
current: process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0',
build: process.env.NEXT_PUBLIC_GIT_COMMIT || 'unknown',
endpoint: effectiveEndpoint(state),
defaultEndpoint: DEFAULT_VERSION_ENDPOINT,
disabledByEnv: disabledByEnv(),
lastCheckedAt: state.lastCheckedAt,
lastSuccessAt: state.lastSuccessAt,
nextScheduledAt: state.nextScheduledAt,
status: state.status,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (err) {
logger.error('version admin GET error', {
error: err instanceof Error ? err.message : 'unknown',
});
return NextResponse.json({ error: 'failed' }, { status: 500 });
}
}
/**
* POST /api/admin/version
* { action: 'check-now' } - force a fresh upstream fetch.
*/
export async function POST(req: NextRequest) {
try {
const auth = await requireAdminAuth(req);
if ('error' in auth) return auth.error;
const body = (await req.json().catch(() => null)) as { action?: string } | null;
if (!body || body.action !== 'check-now') {
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
}
const result = await checkOnce({ reason: 'admin-trigger' });
return NextResponse.json(result);
} catch (err) {
logger.error('version admin POST error', {
error: err instanceof Error ? err.message : 'unknown',
});
return NextResponse.json({ error: 'failed' }, { status: 500 });
}
}
+141
View File
@@ -0,0 +1,141 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptSession } from '@/lib/auth/crypto';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { normalizeJmapServerUrl } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
import { recordLogin } from '@/lib/telemetry/login-tracker';
import {
ImpersonationJwtError,
impersonationReplayCache,
verifyImpersonationJwt,
} from '@/lib/impersonation/jwt';
import {
readImpersonationConfig,
resolveImpersonationServerUrl,
} from '@/lib/impersonation/master-config';
export const runtime = 'nodejs';
const IMPERSONATION_SLOT = 0;
/**
* Impersonation cookies deliberately omit Max-Age so the browser treats
* them as session cookies - the impersonated session ends when the user
* closes the browser, not 30 days later. Impersonation is a temporary
* support handoff; a normal password login is the only thing that should
* survive a browser restart.
*/
function impersonationCookieOptions() {
const { maxAge: _maxAge, ...rest } = getCookieOptions();
return rest;
}
/**
* GET /api/auth/impersonate?token=<jwt>
*
* Master-user impersonation via signed JWT. The token carries the target
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
* master credentials from env, then mints the same session cookies the
* password-login path produces. The browser is redirected to "/" and the
* SPA hydrates as if the user had just logged in with master@target%master.
*
* Returns 404 when the feature is not configured so an unconfigured
* deployment does not advertise the endpoint.
*/
export async function GET(request: NextRequest) {
const config = readImpersonationConfig();
if (!config) {
// Not configured - behave exactly like an unknown route.
return new NextResponse('Not found', { status: 404 });
}
const token = request.nextUrl.searchParams.get('token');
if (!token) {
return NextResponse.json({ error: 'Missing token' }, { status: 400 });
}
let claims;
try {
claims = verifyImpersonationJwt(token, config.jwtSecret, {
expectedIssuer: config.expectedIssuer,
});
} catch (err) {
if (err instanceof ImpersonationJwtError) {
logger.warn('Impersonation JWT rejected', { code: err.code });
return NextResponse.json({ error: err.message }, { status: err.status });
}
logger.error('Impersonation JWT error', {
error: err instanceof Error ? err.message : 'Unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
if (!impersonationReplayCache.consume(claims.jti, claims.exp)) {
logger.warn('Impersonation JWT replay rejected', { jti: claims.jti });
return NextResponse.json({ error: 'Token already used' }, { status: 401 });
}
const serverUrl = await resolveImpersonationServerUrl();
if (!serverUrl) {
logger.error('Impersonation requested but jmapServerUrl is not configured');
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
}
let normalizedServerUrl: string;
try {
normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
} catch {
return NextResponse.json({ error: 'Invalid JMAP server URL' }, { status: 500 });
}
// Stalwart master-user impersonation: username = "<target>%<master>",
// password = <master_password>. Per Stalwart docs:
// https://stalw.art/docs/auth/authorization/administrator/
const impersonatedUsername = `${claims.mailbox}%${config.masterUser}`;
const authHeader = `Basic ${Buffer.from(
`${impersonatedUsername}:${config.masterPassword}`,
).toString('base64')}`;
const cookieStore = await cookies();
const sessionToken = encryptSession(
normalizedServerUrl,
impersonatedUsername,
config.masterPassword,
);
cookieStore.set(sessionCookieName(IMPERSONATION_SLOT), sessionToken, impersonationCookieOptions());
setStalwartAuthContextInStore(cookieStore, IMPERSONATION_SLOT, {
serverUrl: normalizedServerUrl,
username: impersonatedUsername,
authHeader,
});
// Structured audit log - operators rely on this for security review.
logger.info('Impersonation session granted', {
event: 'impersonation_granted',
jti: claims.jti,
mailbox: claims.mailbox,
tenant_id: claims.tenant_id,
actor_user_id: claims.actor_user_id,
iss: claims.iss,
ip:
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
null,
referer: request.headers.get('referer'),
user_agent: request.headers.get('user-agent'),
});
void recordLogin(impersonatedUsername, normalizedServerUrl);
// Use a relative Location header so the browser resolves it against the
// public request URL. NextResponse.redirect(new URL('/', request.url))
// would absolutise to the container's internal bind (http://0.0.0.0:3000)
// when running behind a reverse proxy that doesn't set X-Forwarded-Host.
return new NextResponse(null, {
status: 303,
headers: { Location: '/' },
});
}
+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 });
}
}

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