Compare commits

...
201 Commits
Author SHA1 Message Date
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
299 changed files with 40495 additions and 2596 deletions
+20 -1
View File
@@ -9,9 +9,16 @@
# App name displayed in the UI
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
# 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
# =============================================================================
@@ -41,6 +48,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# OAuth client secret (server-side only, never exposed to the browser)
# 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
# OAUTH_ISSUER_URL=https://your-idp.example.com
@@ -53,6 +62,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Required for both "Remember me" and settings sync features.
# Generate with: openssl rand -base64 32
# 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
@@ -132,6 +143,14 @@ LOGIN_COMPANY_NAME=Bulwark Webmail
# URL for the company website link on the login page
LOGIN_WEBSITE_URL=https://bulwarkmail.org
# =============================================================================
# Extension Directory / Marketplace
# =============================================================================
# URL of the BulwarkMail extension directory for the admin marketplace.
# Set this to enable browsing and installing plugins/themes from the directory.
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
# =============================================================================
# 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]
+23 -11
View File
@@ -21,11 +21,23 @@ on:
- ".github/workflows/docker-publish.yml"
workflow_dispatch:
env:
IMAGE_NAME: ghcr.io/${{ github.repository }}
jobs:
prepare:
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:
@@ -57,7 +69,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
images: ${{ needs.prepare.outputs.image_name }}
- name: Build and push by digest
id: build
@@ -66,7 +78,7 @@ jobs:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
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 }}
@@ -86,7 +98,7 @@ jobs:
merge:
runs-on: ubuntu-latest
needs: build
needs: [prepare, build]
permissions:
contents: read
packages: write
@@ -113,17 +125,17 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
images: ${{ needs.prepare.outputs.image_name }}
tags: |
type=raw,value={{branch}}
type=sha,prefix={{branch}}-
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 '${{ env.IMAGE_NAME }}@sha256:%s ' *)
$(printf '${{ needs.prepare.outputs.image_name }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
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 -1
View File
@@ -46,4 +46,7 @@ next-env.d.ts
/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
+187
View File
@@ -1,5 +1,192 @@
# Changelog
## 1.4.13 (2026-04-12)
Thank you for your donations:
**One-time**
- [@boris22100](https://github.com/boris22100)
- [@mkorthaus-private](https://github.com/mkorthaus-private)
**Monthly**
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
### Features
- **Contacts**: Store trusted senders in a dedicated JMAP address book (#176)
- **Email**: Warn on send when attachment keyword found but no file attached (#172)
- **Email**: Enable keyword reordering (#174) and multi-tag support per email (#173)
- **PWA**: Add "don't remind me again" option to install prompt
- **Auth**: Add `SESSION_SECRET_FILE` and `OAUTH_CLIENT_SECRET_FILE` environment variable support
- **Plugins**: Add `onAvatarResolve` plugin hook
- **Docker**: Publish main and dev branches as separate GHCR packages
### Fixes
- **Email**: Style links in plain text emails
- **Email**: Seed list history entry when app initializes on an email view
- **Email**: Remount composer on draft edit and preserve identity (#60)
- **Contacts**: Display contact names stored in `name.full` (#179)
- **Contacts**: Fix category dropdown blocking Save button in contact form (#177)
- **Contacts**: Resolve TS error from optional `name.components` in vCard parser
- **Search**: Search all folders when filtering emails by tag (#175)
- **Auth**: Include mount prefix in SSO redirect URI when app is served under a subpath
- **PWA**: Correct PWA icons with proper sizing, transparency, and dark/light mode support
## 1.4.12 (2026-04-09)
Thank you for your donations:
**One-time**
- [@mkorthaus-private](https://github.com/mkorthaus-private)
**Monthly**
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
### Features
- **PWA**: Add PWA support with service worker and install prompt
- **Calendar**: Add birthday calendar feature with settings and localization
- **Calendar**: Clamp February 29 birthdays in non-leap years
- **Identity**: Add automatic identity synchronization (#167)
- **Plugins**: Disable plugins by default and require admin approval
- **Plugins**: Replace auth header exposure with a secure HTTP proxy API for plugins
- **Auth**: Add configurable OAuth scopes and cookie security via environment variables
- **Email**: Sync mail view to browser history for back/forward navigation
- **Contacts**: Add ability to rename address books (#152)
- **UI**: Add version badge in settings
- **i18n**: Add Latvian (lv) locale support
- **i18n**: Add Polish language support
- **i18n**: Add Korean language support
- **i18n**: Add Simplified Chinese (zh_CN) locale support
### Fixes
- **Email**: Show recipient instead of sender in Sent and Drafts folder lists
- **Email**: Embed dropped images as data URLs and prevent duplicate attachments (#163)
- **Email**: Fix logic for marking email as read in EmailViewer
- **Email**: Fix archive action passing MouseEvent as argument
- **Mailbox**: Preserve search filters on push-triggered mailbox refresh (#164)
- **Mailbox**: Align shared account folders with primary folders (#151)
- **Mailbox**: Fetch mailboxes on mount in FolderSettings when store is empty
- **Mailbox**: Improve mailbox deletion error handling
- **Calendar**: Improve calendar event retrieval by batching requests to avoid server limits (#141)
- **Calendar**: Compute per-occurrence UTC start/end in recurrence expansion (#116)
- **Calendar**: Guard against undefined trigger in calendar event alert popover (#143)
- **Files**: Stream WebDAV PUT uploads to avoid buffering in memory (#162)
- **Files**: Prune recent files against server nodes on refresh (#146)
- **Files**: Fix file deletion logic to update recent files and handle errors (#146)
- **Files**: Extend file drop zone to fill remaining viewport height
- **Files**: Fallback to application/octet-stream for long MIME types
- **Security**: Replace unguarded crypto.randomUUID() with safe generateUUID() utility
- **Security**: Validate plugin HTTP post URL against origin with regression tests
- **Security**: Allow blob images in CSP for inline drag-and-drop (#163)
- **Auth**: Resolve settings sync identity mismatch for OAuth/SSO sessions (#127)
- **Contacts**: Fix address book ID namespacing for shared contacts in create and update operations (#133)
- **UI**: Fix focused mode expanding beyond screen bounds (#156)
- **API**: Handle 403 on principal fetch without console error
- **API**: Enhance error handling in Stalwart API responses
## 1.4.11 (2026-03-31)
### Features
- **Logging**: Add logging categories for better log management
### Fixes
- **Security**: Harden security with CSP enforcement, SSRF redirect validation, reenabled S/MIME chain verify, IP spoofing prevention, and PDF iframe sandbox
- **Security**: Harden proxy authentication and SSRF defenses
- **Security**: Block plugins with dangerous JS patterns and enforce strict session secret length validation
- **S/MIME**: Add self-signed certificate detection and update status messages for S/MIME signatures
- **Email**: Auto-focus input fields in email composer for improved user experience (#126)
- **Mailbox**: Prevent orphaning of nested mailboxes by restricting deduplication to root-level folders
- **JMAP**: Strip server-immutable fields from updates before sending to JMAP (#128)
- **Files**: Update file feature disabled messages and add stability warnings
- **i18n**: Add missing translation keys to all non-English locales
## 1.4.10 (2026-03-31)
### Features
- **Plugins**: Add plugin configuration UI with schema-driven admin config page, calendar event action slot, and Jitsi Meet plugin
- **Calendar**: Implement client-side recurrence expansion for calendar events
- **Calendar**: Add iCal subscription editing and batch event import
- **Calendar**: Add hover preview settings and functionality
- **Calendar**: Add virtual location input for calendar events (#121)
- **Email**: Add reply-to addresses support in email composer
- **Email**: Add mail layout settings and update email list components
- **Email**: Add auto-select reply identity feature with settings and localization
- **Email**: Enhance compose functionality with button integration and translations
- **Filters**: Preserve activation state when updating or creating Sieve scripts to avoid deactivating server-managed vacation scripts
- **Filters**: Skip server-managed vacation script in Sieve script handling
- **Settings**: Add support for custom JMAP server endpoints in login and settings
- **Settings**: Add folder expansion state management and settings navigation
- **UI**: Add options to hide account switcher and show account avatars on navigation rail
- **i18n**: Add JMAP server endpoint labels and hints in multiple languages
- **i18n**: Add missing translation keys to all non-English locales
### Fixes
- **Security**: Patch critical auth bypass and credential leak vulnerabilities
- **Security**: Support 3DES S/MIME decryption by importing legacy RSAES-PKCS1-v1_5 keys and add diagnostic logging (#35)
- **Security**: Account isolation, auto-import signer certs, and no-key error handling (#35)
- **Calendar**: Fix JSCalendar 2.0 recurrenceRule single-object compatibility (#116)
- **Calendar**: Enhance calendar event handling to distinguish between events and tasks
- **Calendar**: Link existing events to target calendar during iCal import instead of skipping (#113)
- **Calendar**: Deduplicate UIDs during iCal import to prevent mass failures (#113)
- **Calendar**: Fix events disappearing after iCal import/subscription refresh
- **Calendar**: Enhance calendar event handling with full-day detection and layout adjustments
- **Calendar**: Use UTC timestamps for timed event rendering
- **Calendar**: Work around Stalwart not returning Task objects via CalendarEvent/query
- **Email**: Enhance email loading and deduplication logic in email store (#119)
- **Email**: Ensure draft editing function is called correctly in EmailViewer component (#60)
- **Email**: Match hover action background to selected row state
- **Email**: Align tag counts with mailbox folder counts in sidebar
- **Auth**: Handle 2FA/TOTP session expiry with basic auth (#117)
- **Mailbox**: Improve mailbox tree logic and enhance mailbox handling with logging (#118)
- **UI**: Improve dark mode handling for media elements and background images
- **UI**: Adjust account list spacing and remove push connection indicator
- **UI**: Fix nested button in theme card
## 1.4.9 (2026-03-27)
### Features
- **Admin**: Add Stalwart admin authentication, sidebar access, and a reorganized dashboard with dedicated policy sections
- **Plugins**: Add plugin/theme admin dashboard, harness tooling, forced enable or disable controls, managed policy enforcement, and a resizable detail sidebar
- **Filters**: Add vacation responder management with Sieve generation and parsing, UI integration, and improved sync preservation
- **Email**: Add plain text only composer mode, optional conversation threading disable, configurable hover action placement, and OAuth app password support
- **UI**: Add drag-and-drop customization for sidebar apps
- **Files**: Use dynamic server-configured maximum upload sizes
- **i18n**: Add Russian locale support and complete missing translation strings for recent task features
### Fixes
- **Calendar**: Improve date parsing and event normalization, prevent calendar page re-render loops, ensure unique ICal subscription IDs, and create all-day events with correct JSCalendar midnight handling
- **Email**: Respect the configured mark-as-read delay in EmailViewer and fetch full email content when needed while editing drafts (#60, #95)
- **Auth**: Improve network error handling, add JMAP rate limiting handling, and enhance settings retrieval and persistence diagnostics (#100, #104)
- **UI**: Improve mobile layout behavior on contacts and calendar pages (#103)
- **Themes**: Repair theme ZIP bundle handling and enforce admin theme locks correctly
- **Code Quality**: Resolve outstanding ESLint warnings across the codebase
## 1.4.8 (2026-03-23)
### Features
- **Email**: Add support for marking emails as answered or forwarded and display status icons in email list and thread views
- **Email**: Enhance identity selection by supporting sub-addressing (plus addressing) in email composer
- **Settings**: Add notification settings with sound picker, preview playback, and configurable alert sounds
- **Settings**: Add default mail program settings with localization support across all locales
- **Auth**: Implement path prefix handling for OAuth callbacks and login redirects, enabling reverse proxy deployments
- **Validation**: Add all multi-part TLDs for domain validation in favicon API (#81)
### Fixes
- **Calendar**: Fix bugs in duration parsing, RFC compliance, and event handling across calendar components
- **Calendar**: Detect tasks created by external CalDAV clients such as Thunderbird
- **Settings**: Enhance account settings with username and authentication method display (#90)
## 1.4.7 (2026-03-21)
### Features
+15 -4
View File
@@ -1,9 +1,9 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20White%20and%20Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" />
<img src="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" alt="Bulwark Webmail" width="220" />
<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_Logo_with_Lettering_Dark_Color.svg" />
<img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
</picture>
</div>
@@ -12,6 +12,17 @@
Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors.
## Join our Community
**New to the project or looking for a place to start?** You don't need to be an expert to contribute! Whether you need help setting up your environment, want to report a bug, or are interested in helping with translations, our Discord is the best place to connect.
* **Get Support:** Get real-time help with development hurdles.
* **Contribute:** Share ideas, suggest features, or help us improve documentation.
* **Collaborate:** Meet the team and other contributors working to make Bulwark better.
[**Join the Bulwark Discord Server**](https://discord.gg/tYCujymGrT)
---
## Getting Started
### Development Setup
@@ -195,7 +206,7 @@ webmail/
- **Never commit sensitive data** (API keys, passwords, etc.)
- **Sanitize user input** and email content
- **Block external content** by default for privacy
- Report security vulnerabilities privately
- Report security vulnerabilities privately (e.g. bulwark@rbm.systems)
## Questions?
+1
View File
@@ -26,6 +26,7 @@ RUN apk upgrade --no-cache && \
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN mkdir -p /app/data/settings /app/data/admin && chown -R nextjs:nodejs /app/data
USER nextjs
EXPOSE 3000
ENV PORT=3000
+14 -10
View File
@@ -11,9 +11,10 @@
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/).<br/>
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.7-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.4.13-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>
@@ -101,6 +102,7 @@ Built with Next.js and the JMAP protocol.
- **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
- **Answered/forwarded status icons** in email list and thread views
- **Color tags/labels** and star/unstar
- **Virtual scrolling** for large mailboxes
- **Quick reply** from the viewer
@@ -119,7 +121,9 @@ Built with Next.js and the JMAP protocol.
- **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
- **Task management** — create, edit, and track tasks with due dates, priority, and completion status
- **Week numbers** in mini-calendar sidebar
- **Notifications** with configurable sound, alert persistence, and sound picker with preview playback
- **Real-time sync** via JMAP push
### Contacts
@@ -155,7 +159,7 @@ Built with Next.js and the JMAP protocol.
- **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
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode and non-interactive SSO for embedded/iframe deployments
- **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)
@@ -170,6 +174,7 @@ Built with Next.js and the JMAP protocol.
- **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
- **Interactive guided tour** — onboarding walkthrough for new users
- **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
@@ -194,6 +199,7 @@ Automatic browser detection with persistent preference.
### Operations
- **Automatic update check** — server logs when a newer release is available
- **Demo mode** — try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes — no mail server required
---
@@ -265,6 +271,7 @@ PORT=3000 # Default listen port
OAUTH_ENABLED=true
OAUTH_CLIENT_ID=webmail
OAUTH_CLIENT_SECRET= # optional, for confidential clients
OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
```
@@ -276,7 +283,8 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
<summary>Remember Me</summary>
```env
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
SESSION_SECRET_FILE=/session-secret # Path to a file containing the session secret
```
Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry).
@@ -319,10 +327,6 @@ Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day exp
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## Roadmap
See [ROADMAP.md](ROADMAP.md) for planned features and current status.
## License
[GNU AGPL v3](LICENSE)
+1 -1
View File
@@ -1 +1 @@
1.4.7
1.4.12
+7 -4
View File
@@ -4,6 +4,7 @@ import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useAuthStore } from "@/stores/auth-store";
import { getPathPrefix } from "@/lib/browser-navigation";
import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useParams } from "next/navigation";
@@ -48,7 +49,8 @@ function OAuthCallbackInner() {
return;
}
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
const prefix = getPathPrefix(params.locale as string);
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri)
.then((success) => {
@@ -57,7 +59,7 @@ function OAuthCallbackInner() {
sessionStorage.removeItem("oauth_code_verifier");
sessionStorage.removeItem("oauth_server_url");
sessionStorage.removeItem("oauth_add_account_mode");
let redirectTo = `/${params.locale}`;
let redirectTo = `${prefix}/${params.locale}`;
try {
const saved = sessionStorage.getItem('redirect_after_login');
if (saved) {
@@ -75,10 +77,11 @@ function OAuthCallbackInner() {
});
} else if (state) {
// Server-side SSO flow — state was stored in encrypted httpOnly cookie
const ssoPrefix = getPathPrefix(params.locale as string);
loginWithServerSso(code, state)
.then((success) => {
if (success) {
let redirectTo = `/${params.locale}`;
let redirectTo = `${ssoPrefix}/${params.locale}`;
try {
const saved = sessionStorage.getItem('redirect_after_login');
if (saved) {
@@ -114,7 +117,7 @@ function OAuthCallbackInner() {
</p>
<Button
variant="outline"
onClick={() => router.push(`/${params.locale}/login`)}
onClick={() => router.push(`${getPathPrefix(params.locale as string)}/${params.locale}/login`)}
>
{t("oauth_error.back_to_login")}
</Button>
+168 -29
View File
@@ -38,10 +38,14 @@ 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 { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
import { getEventStartDate } from "@/lib/calendar-utils";
import { useTaskStore } from "@/stores/task-store";
import { useContactStore } from "@/stores/contact-store";
import { cn } from "@/lib/utils";
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
import { getUserParticipantId } from "@/lib/calendar-participants";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { debug } from "@/lib/debug";
type PendingScopeAction =
@@ -65,11 +69,13 @@ export default function CalendarPage() {
isLoading, isLoadingEvents, supportsCalendar, error,
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
refreshAllSubscriptions,
refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore();
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar } = useSettingsStore();
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
const taskStore = useTaskStore();
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
const { identities } = useIdentityStore();
const contacts = useContactStore((s) => s.contacts);
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
const currentUserEmails = useMemo(() =>
@@ -80,6 +86,7 @@ export default function CalendarPage() {
const [showEventModal, setShowEventModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
@@ -90,6 +97,9 @@ export default function CalendarPage() {
const [pendingPreview, setPendingPreview] = useState<PendingEventPreview | null>(null);
const [showTaskModal, setShowTaskModal] = useState(false);
const [editTask, setEditTask] = useState<import("@/lib/jmap/types").CalendarTask | null>(null);
const [mobileReturnToMonth, setMobileReturnToMonth] = useState(false);
const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null);
const [swipeKey, setSwipeKey] = useState(0);
const hasFetched = useRef(false);
// Sidebar resize state
@@ -141,6 +151,13 @@ export default function CalendarPage() {
return () => clearInterval(interval);
}, [client, refreshAllSubscriptions]);
// Auto-add birthday calendar to selected IDs when enabled
useEffect(() => {
if (showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
toggleCalendarVisibility(BIRTHDAY_CALENDAR_ID);
}
}, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps
const dateRange = useMemo(() => {
const d = selectedDate;
switch (normalizedViewMode) {
@@ -181,9 +198,9 @@ export default function CalendarPage() {
// Fetch tasks when tasks view is active or when tasks are shown on calendar grid
useEffect(() => {
if (client && enableCalendarTasks && (normalizedViewMode === "tasks" || showTasksOnCalendar)) {
taskStore.fetchTasks(client);
fetchTasksFn(client);
}
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar]);
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]);
useEffect(() => {
if (client && calendars.length > 0 && dateRange) {
@@ -230,6 +247,8 @@ export default function CalendarPage() {
const handleTouchEnd = useCallback((e: ReactTouchEvent) => {
if (!touchStartRef.current || !isMobile) return;
// Week view has its own horizontal scroll, skip swipe navigation
if (normalizedViewMode === 'week') { touchStartRef.current = null; return; }
const touch = e.changedTouches[0];
const dx = touch.clientX - touchStartRef.current.x;
const dy = touch.clientY - touchStartRef.current.y;
@@ -238,20 +257,34 @@ export default function CalendarPage() {
// 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();
if (dx > 0) {
setSwipeDirection('right');
navigatePrev();
} else {
setSwipeDirection('left');
navigateNext();
}
setSwipeKey(k => k + 1);
// Clear direction after animation completes
setTimeout(() => setSwipeDirection(null), 250);
}
}, [isMobile, navigatePrev, navigateNext]);
}, [isMobile, normalizedViewMode, 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") {
setMobileReturnToMonth(true);
setViewMode("day");
}
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
const navigateBackToMonth = useCallback(() => {
setMobileReturnToMonth(false);
setViewMode("month");
}, [setViewMode]);
const handleMiniMonthChange = useCallback((date: Date) => {
setMiniMonth(date);
setSelectedDate(date);
@@ -316,18 +349,28 @@ export default function CalendarPage() {
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
if (isMobile) return;
if (calendarHoverPreview === 'off') return;
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);
}, [isMobile, showEventModal, editEvent]);
if (calendarHoverPreview === 'delay-500ms' || calendarHoverPreview === 'delay-1s' || calendarHoverPreview === 'delay-2s') {
const ms = calendarHoverPreview === 'delay-500ms' ? 500 : calendarHoverPreview === 'delay-1s' ? 1000 : 2000;
hoverTimerRef.current = setTimeout(() => {
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}, ms);
} else {
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}
}, [isMobile, calendarHoverPreview, showEventModal, editEvent]);
const handleHoverLeave = useCallback(() => {
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
hoverTimerRef.current = setTimeout(() => {
setDetailEvent(null);
setDetailAnchorRect(null);
}, 200);
}, 300);
}, []);
const handleEditFromDetail = useCallback(() => {
@@ -364,6 +407,24 @@ export default function CalendarPage() {
}
}, [client, fetchEvents]);
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
if (!event.start) {
return;
}
const eventDate = getEventStartDate({
start: event.start,
utcStart: event.utcStart ?? null,
showWithoutTime: event.showWithoutTime ?? false,
});
if (Number.isNaN(eventDate.getTime())) {
return;
}
setSelectedDate(eventDate);
setMiniMonth(eventDate);
}, [setSelectedDate]);
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => {
if (!client) { toast.error(t("notifications.event_error")); return; }
try {
@@ -380,6 +441,9 @@ export default function CalendarPage() {
return;
}
await updateEvent(client, editEvent.id, data, sendSchedulingMessages);
if (data.start) {
focusCalendarOnEvent({ start: data.start });
}
toast.success(t("notifications.event_updated"));
} else {
const created = await createEvent(client, data, sendSchedulingMessages);
@@ -387,6 +451,7 @@ export default function CalendarPage() {
toast.error(t("notifications.event_error"));
return;
}
focusCalendarOnEvent(created);
if (sendSchedulingMessages) {
toast.success(t("notifications.invitation_sent"));
} else {
@@ -398,7 +463,7 @@ export default function CalendarPage() {
} catch {
toast.error(t("notifications.event_error"));
}
}, [client, editEvent, createEvent, updateEvent, t]);
}, [client, editEvent, createEvent, updateEvent, focusCalendarOnEvent, t]);
const handleDuplicateEvent = useCallback(async (data: Partial<CalendarEvent>) => {
if (!client) { toast.error(t("notifications.event_error")); return; }
@@ -408,6 +473,7 @@ export default function CalendarPage() {
toast.error(t("notifications.event_error"));
return;
}
focusCalendarOnEvent(created);
toast.success(t("notifications.event_duplicated"));
setEditEvent(created);
setDefaultModalDate(undefined);
@@ -416,7 +482,7 @@ export default function CalendarPage() {
setShowEventModal(false);
setEditEvent(null);
}
}, [client, createEvent, t]);
}, [client, createEvent, focusCalendarOnEvent, t]);
const handleDeleteEvent = useCallback(async (id: string, sendSchedulingMessages?: boolean) => {
if (!client) { toast.error(t("notifications.event_error")); return; }
@@ -612,7 +678,7 @@ export default function CalendarPage() {
if (!detailEvent || !client) return;
const start = parseISO(detailEvent.start);
const newStart = addDays(start, 1);
const data: Partial<CalendarEvent> = {
const data = sanitizeOutgoingCalendarEventData<Partial<CalendarEvent>>({
title: detailEvent.title,
description: detailEvent.description,
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
@@ -623,7 +689,7 @@ export default function CalendarPage() {
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);
@@ -685,16 +751,62 @@ export default function CalendarPage() {
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent]);
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent, enableCalendarTasks]);
const visibleEvents = useMemo(() =>
events.filter((e) => {
const birthdayEvents = useMemo(() => {
if (!showBirthdayCalendar || !dateRange) return [];
return generateBirthdayEvents(contacts, dateRange.start, dateRange.end);
}, [showBirthdayCalendar, contacts, dateRange]);
const birthdayCalendarName = (() => {
try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
})();
const allCalendars = useMemo(() => {
if (!showBirthdayCalendar) return calendars;
return [...calendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
}, [calendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
const visibleEvents = useMemo(() => {
const filtered = events.filter((e) => {
if (!e.start || !e.calendarIds) return false;
const calIds = Object.keys(e.calendarIds);
return calIds.some((id) => selectedCalendarIds.includes(id));
}),
[events, selectedCalendarIds]
);
});
if (showBirthdayCalendar && selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
return [...filtered, ...birthdayEvents];
}
return filtered;
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents]);
useEffect(() => {
const hiddenEvents = events.filter((event) => {
if (!event.start || !event.calendarIds) {
return true;
}
return !Object.keys(event.calendarIds).some((calendarId) => selectedCalendarIds.includes(calendarId));
});
if (hiddenEvents.length === 0) {
return;
}
debug.log('calendar', 'Calendar visibility summary', {
totalEvents: events.length,
visibleEvents: visibleEvents.length,
hiddenEvents: hiddenEvents.length,
selectedCalendarIds,
hiddenSamples: hiddenEvents.slice(0, 5).map((event) => ({
id: event.id,
originalId: event.originalId,
title: event.title,
calendarIds: event.calendarIds,
originalCalendarIds: event.originalCalendarIds,
accountId: event.accountId,
})),
});
}, [events, selectedCalendarIds, visibleEvents]);
if (!isAuthenticated || !supportsCalendar) return null;
@@ -714,7 +826,7 @@ export default function CalendarPage() {
<CalendarMonthView
selectedDate={selectedDate}
events={visibleEvents}
calendars={calendars}
calendars={allCalendars}
onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
@@ -730,7 +842,7 @@ export default function CalendarPage() {
<CalendarWeekView
selectedDate={selectedDate}
events={visibleEvents}
calendars={calendars}
calendars={allCalendars}
onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
@@ -749,7 +861,7 @@ export default function CalendarPage() {
<CalendarDayView
selectedDate={selectedDate}
events={visibleEvents}
calendars={calendars}
calendars={allCalendars}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
@@ -766,7 +878,7 @@ export default function CalendarPage() {
<CalendarAgendaView
selectedDate={selectedDate}
events={visibleEvents}
calendars={calendars}
calendars={allCalendars}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
@@ -804,7 +916,7 @@ export default function CalendarPage() {
})();
return (
<div className="relative flex-1 flex flex-col overflow-hidden">
<div className="relative flex min-h-0 flex-1 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">
@@ -857,13 +969,18 @@ export default function CalendarPage() {
showWeekNumbers={showWeekNumbers}
/>
<CalendarSidebarPanel
calendars={calendars}
calendars={allCalendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
onColorChange={client ? (calendarId, color) => {
if (calendarId === BIRTHDAY_CALENDAR_ID) {
updateSetting('birthdayCalendarColor', color);
return;
}
updateCalendar(client, calendarId, { color });
} : undefined}
onSubscribe={() => setShowSubscriptionModal(true)}
onEditSubscription={(subId) => setEditingSubscription(subId)}
client={client}
/>
</div>
@@ -887,11 +1004,12 @@ export default function CalendarPage() {
onPrev={navigatePrev}
onNext={navigateNext}
onToday={goToToday}
onViewModeChange={setViewMode}
onViewModeChange={(mode) => { setMobileReturnToMonth(false); setViewMode(mode); }}
onCreateEvent={() => openCreateModal()}
onImport={() => setShowImportModal(true)}
onSubscribe={() => setShowSubscriptionModal(true)}
isMobile={isMobile}
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
calendars={calendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
@@ -904,7 +1022,16 @@ export default function CalendarPage() {
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{renderView()}
<div
key={swipeKey}
className={cn(
"flex flex-1 min-w-0",
isMobile && swipeDirection === 'left' && "animate-slide-in-right",
isMobile && swipeDirection === 'right' && "animate-slide-in-left",
)}
>
{renderView()}
</div>
{/* Desktop event panel */}
{!isMobile && showEventModal && (
@@ -1020,6 +1147,18 @@ export default function CalendarPage() {
/>
)}
{editingSubscription && client && (() => {
const sub = icalSubscriptions.find(s => s.id === editingSubscription);
if (!sub) return null;
return (
<ICalSubscriptionModal
client={client}
editSubscription={sub}
onClose={() => setEditingSubscription(null)}
/>
);
})()}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<RecurrenceScopeDialog
isOpen={!!pendingScopeAction}
+53 -6
View File
@@ -13,12 +13,13 @@ import { ContactGroupForm } from "@/components/contacts/contact-group-form";
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { RenameDialog } from "@/components/files/rename-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-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 { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
@@ -72,12 +73,16 @@ export default function ContactsPage() {
bulkDeleteContacts,
bulkAddToGroup,
moveContactToAddressBook,
renameAddressBook,
renameKeyword,
importContacts,
} = useContactStore();
const [view, setView] = useState<View>("list");
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
const [showImportDialog, setShowImportDialog] = useState(false);
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
@@ -122,7 +127,7 @@ export default function ContactsPage() {
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
const selectedContact = contacts.find((c) => c.id === selectedContactId) || 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(() => {
@@ -274,7 +279,7 @@ export default function ContactsPage() {
toast.success(t("toast.created"));
} else {
const localContact: ContactCard = {
id: `local-${crypto.randomUUID()}`,
id: `local-${generateUUID()}`,
addressBookIds: {},
...data,
};
@@ -307,7 +312,7 @@ export default function ContactsPage() {
}
};
const handleSelectGroup = (id: string) => {
const _handleSelectGroup = (id: string) => {
setSelectedGroupId(id);
setActiveCategory({ groupId: id });
setView("group-detail");
@@ -601,7 +606,7 @@ export default function ContactsPage() {
</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 && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
)}
@@ -631,6 +636,8 @@ export default function ContactsPage() {
onDeleteGroup={handleDeleteGroupFromSidebar}
onDropContacts={handleDropContacts}
onDropContactsToCategory={handleDropContactsToCategory}
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
/>
</div>
<ResizeHandle
@@ -701,7 +708,7 @@ export default function ContactsPage() {
className="touch-manipulation"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t("back_to_mail")}
{t("back_to_contacts")}
</Button>
</div>
)}
@@ -725,6 +732,46 @@ export default function ContactsPage() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<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"));
}
}}
/>
)}
{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 && (
<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">
+42 -21
View File
@@ -11,21 +11,24 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useFileStore } from "@/stores/file-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 { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsMobile } from "@/hooks/use-media-query";
import { usePolicyStore } from "@/stores/policy-store";
import { FileBrowser } from "@/components/files/file-browser";
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog";
import { AlertTriangle } from "lucide-react";
export default function FilesPage() {
const router = useRouter();
const t = useTranslations("files");
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
@@ -45,7 +48,7 @@ export default function FilesPage() {
navigateByPath,
refresh,
createDirectory,
uploadFile,
uploadFile: _uploadFile,
uploadFiles,
uploadFolder,
deleteResource,
@@ -156,39 +159,43 @@ export default function FilesPage() {
}
}, [createDirectory, t]);
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
const maxSizeUpload = client?.getMaxSizeUpload() || 0;
const handleUploadFiles = useCallback(async (files: File[]) => {
const oversized = files.filter(f => f.size > MAX_FILE_SIZE);
const valid = files.filter(f => f.size <= MAX_FILE_SIZE);
if (oversized.length > 0) {
toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" }));
if (maxSizeUpload > 0) {
const oversized = files.filter(f => f.size > maxSizeUpload);
files = files.filter(f => f.size <= maxSizeUpload);
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 {
await uploadFiles(valid);
toast.success(t("upload_success", { count: valid.length }));
await uploadFiles(files);
toast.success(t("upload_success", { count: files.length }));
} catch (err) {
console.error("Failed to upload files:", err);
toast.error(t("upload_error"));
}
}, [uploadFiles, t]);
}, [uploadFiles, t, maxSizeUpload]);
const handleUploadFolder = useCallback(async (files: File[]) => {
const oversized = files.filter(f => f.size > MAX_FILE_SIZE);
const valid = files.filter(f => f.size <= MAX_FILE_SIZE);
if (oversized.length > 0) {
toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" }));
if (maxSizeUpload > 0) {
const oversized = files.filter(f => f.size > maxSizeUpload);
files = files.filter(f => f.size <= maxSizeUpload);
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 {
await uploadFolder(valid);
toast.success(t("upload_success", { count: valid.length }));
await uploadFolder(files);
toast.success(t("upload_success", { count: files.length }));
} catch (err) {
console.error("Failed to upload folder:", err);
toast.error(t("upload_error"));
}
}, [uploadFolder, t]);
}, [uploadFolder, t, maxSizeUpload]);
const handleDelete = useCallback(async (name: string) => {
const confirmed = await confirmDialog({
@@ -388,12 +395,25 @@ export default function FilesPage() {
</div>
)}
<div className="flex-1 min-h-0">
{supportsFiles === false ? (
<div className="flex-1 min-h-0 flex flex-col">
{!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">
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
</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
currentPath={currentPath}
resources={resources}
@@ -438,6 +458,7 @@ export default function FilesPage() {
onToggleDetails={handleToggleDetails}
detailResource={detailResource}
/>
</div>
)}
</div>
</div>
+8 -5
View File
@@ -3,6 +3,7 @@ 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 { locales } from "@/i18n/routing";
@@ -28,11 +29,13 @@ export default async function LocaleLayout({
<IntlProvider locale={locale} messages={messages}>
<ThemeProvider>
<CalendarAlertProvider>
<EmbeddedBridgeProvider>
<TourProvider>
{children}
</TourProvider>
</EmbeddedBridgeProvider>
<RateLimitToastProvider>
<EmbeddedBridgeProvider>
<TourProvider>
{children}
</TourProvider>
</EmbeddedBridgeProvider>
</RateLimitToastProvider>
</CalendarAlertProvider>
</ThemeProvider>
</IntlProvider>
+94 -20
View File
@@ -10,13 +10,15 @@ 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 { getPathPrefix } from "@/lib/browser-navigation";
import { cn } from "@/lib/utils";
import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play } from "lucide-react";
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
const APP_VERSION = "1.4.7";
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
const THEME_OPTIONS = [
{ value: "light" as const, icon: Sun, label: "Light" },
@@ -24,6 +26,41 @@ const THEME_OPTIONS = [
{ value: "system" as const, icon: Monitor, label: "System" },
];
function VersionBadge() {
const [copied, setCopied] = useState(false);
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}`;
const handleCopy = () => {
navigator.clipboard.writeText(versionInfo).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div className="relative inline-flex justify-center">
<p className="peer text-center text-xs text-muted-foreground/40 cursor-default">
v{APP_VERSION}
</p>
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border opacity-0 peer-hover:opacity-100 hover:opacity-100 transition-opacity whitespace-nowrap z-10">
<div className="flex items-center gap-2">
<div className="space-y-0.5">
<p>Version: <span className="font-medium">{APP_VERSION}</span></p>
<p>Build: <span className="font-medium">{GIT_COMMIT}</span></p>
</div>
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label="Copy version info"
>
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
</div>
</div>
);
}
export default function LoginPage() {
const router = useRouter();
const t = useTranslations("login");
@@ -32,13 +69,14 @@ export default function LoginPage() {
const isAddAccountMode = searchParams.get("mode") === "add-account";
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode } = useConfig();
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const [formData, setFormData] = useState({
username: "",
password: "",
});
const [jmapEndpoint, setJmapEndpoint] = useState("");
const [totpCode, setTotpCode] = useState("");
const [showTotpField, setShowTotpField] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
@@ -73,6 +111,12 @@ export default function LoginPage() {
}
}, [appName, serverUrl]);
useEffect(() => {
if (serverUrl && !jmapEndpoint) {
setJmapEndpoint(serverUrl);
}
}, [serverUrl, jmapEndpoint]);
useEffect(() => {
try {
if (sessionStorage.getItem('session_expired') === 'true') {
@@ -91,6 +135,14 @@ export default function LoginPage() {
prevError.current = error;
}, [error]);
// Auto-show and focus TOTP field when server requires it
useEffect(() => {
if (error === 'totp_required') {
setShowTotpField(true);
setTimeout(() => totpInputRef.current?.focus(), 100);
}
}, [error]);
useEffect(() => {
if (!serverUrl) return;
const saved = localStorage.getItem("webmail_usernames");
@@ -180,7 +232,8 @@ export default function LoginPage() {
const startServerSideSso = useCallback(async () => {
setOauthLoading(true);
try {
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
const prefix = getPathPrefix(params.locale as string);
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
const res = await fetch('/api/auth/sso/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -264,7 +317,7 @@ export default function LoginPage() {
);
}
if (!serverUrl && !demoMode) {
if (!serverUrl && !demoMode && !allowCustomJmapEndpoint) {
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">
@@ -362,7 +415,7 @@ export default function LoginPage() {
sessionStorage.setItem("oauth_code_verifier", verifier);
sessionStorage.setItem("oauth_state", state);
sessionStorage.setItem("oauth_server_url", serverUrl!);
sessionStorage.setItem("oauth_server_url", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!);
if (isAddAccountMode) {
sessionStorage.setItem("oauth_add_account_mode", "true");
}
@@ -382,8 +435,9 @@ export default function LoginPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl;
const success = await login(
serverUrl,
effectiveServerUrl,
formData.username,
formData.password,
totpCode || undefined,
@@ -563,9 +617,7 @@ export default function LoginPage() {
)}
</div>
)}
<p className="text-center text-xs text-muted-foreground/40">
v{APP_VERSION}
</p>
<VersionBadge />
</div>
</div>
</div>
@@ -733,9 +785,9 @@ export default function LoginPage() {
)}
</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">
<div className="p-3.5 bg-warning/10 border border-warning/20 rounded-xl flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" />
<p className="text-sm text-warning">
{t("error.oauth_discovery_failed")}
</p>
</div>
@@ -749,6 +801,27 @@ export default function LoginPage() {
/* Login Form */
<form onSubmit={handleSubmit} className="space-y-5">
<fieldset disabled={isLoading} className="space-y-4">
{/* JMAP Endpoint field (when custom endpoints are allowed) */}
{allowCustomJmapEndpoint && (
<div className="space-y-1.5">
<label htmlFor="jmap-endpoint" className="block text-sm font-medium text-foreground">
{t("jmap_endpoint_label")}
</label>
<Input
id="jmap-endpoint"
type="url"
value={jmapEndpoint}
onChange={(e) => setJmapEndpoint(e.target.value)}
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("jmap_endpoint_placeholder")}
required
/>
<p className="text-[11px] text-muted-foreground leading-snug">
{t("jmap_endpoint_cors_hint")}
</p>
</div>
)}
{/* Username field */}
<div className="space-y-1.5">
<label htmlFor="username" className="block text-sm font-medium text-foreground">
@@ -861,7 +934,10 @@ export default function LoginPage() {
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"
className={cn(
"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",
error === 'totp_required' && "border-primary ring-2 ring-primary/30"
)}
placeholder={t("totp_placeholder")}
autoComplete="one-time-code"
aria-label={t("totp_label")}
@@ -941,9 +1017,9 @@ export default function LoginPage() {
)}
{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">
<div className="mt-2 p-3 bg-warning/10 border border-warning/20 rounded-xl flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" />
<p className="text-sm text-warning">
{t("error.oauth_discovery_failed")}
</p>
</div>
@@ -1035,9 +1111,7 @@ export default function LoginPage() {
)}
</div>
)}
<p className="text-center text-xs text-muted-foreground/40">
v{APP_VERSION}
</p>
<VersionBadge />
</div>
</div>
</div>
+301 -64
View File
@@ -14,11 +14,13 @@ import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore } from "@/stores/email-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useUIStore } from "@/stores/ui-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound";
import { cn } from "@/lib/utils";
@@ -30,6 +32,7 @@ import {
ComposerErrorFallback,
} from "@/components/error";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
import { DragDropProvider } from "@/contexts/drag-drop-context";
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
import { WelcomeBanner } from "@/components/ui/welcome-banner";
@@ -37,23 +40,29 @@ 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 { useIdentitySync } from "@/hooks/use-identity-sync";
import { Input } from "@/components/ui/input";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview";
import { appendPlainTextSignature } from "@/lib/signature-utils";
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square } from "lucide-react";
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button";
import { useConfig } from "@/hooks/use-config";
import { usePluginStore } from "@/stores/plugin-store";
import { useThemeStore } from "@/stores/theme-store";
export default function Home() {
const t = useTranslations();
const tCommon = useTranslations('common');
const { appName } = useConfig();
const mailLayout = useSettingsStore((state) => state.mailLayout);
const [showComposer, setShowComposer] = useState(false);
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
const [composerDraftText, setComposerDraftText] = useState("");
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
const [composerSessionId, setComposerSessionId] = useState(0);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
@@ -66,10 +75,37 @@ export default function Home() {
const [conversationThread, setConversationThread] = useState<ThreadGroup | null>(null);
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost } = useAuthStore();
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
const { identities } = useIdentityStore();
useIdentitySync();
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore();
// Load trusted senders address book when feature is enabled
useEffect(() => {
if (trustedSendersAddressBook && client && !trustedSendersLoaded) {
loadTrustedSendersBook(client);
}
}, [trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]);
useEffect(() => {
if (!isRateLimited || !rateLimitUntil) {
setRateLimitSecondsLeft(null);
return;
}
const updateCountdown = () => {
const seconds = Math.max(1, Math.ceil((rateLimitUntil - Date.now()) / 1000));
setRateLimitSecondsLeft(seconds);
};
updateCountdown();
const timer = setInterval(updateCountdown, 1000);
return () => clearInterval(timer);
}, [isRateLimited, rateLimitUntil]);
// Mobile/tablet responsive hooks
const { isMobile, isTablet } = useDeviceDetection();
@@ -96,6 +132,7 @@ export default function Home() {
markAsRead,
toggleStar,
moveToMailbox,
moveThreadToMailbox,
searchEmails,
searchQuery,
setSearchQuery,
@@ -120,6 +157,102 @@ export default function Home() {
fetchEmailContent,
} = useEmailStore();
// Browser back / forward integration. The restore handler reads the
// latest values from a ref so we don't have to recreate the callback on
// every render (and so the popstate listener is never stale).
const navRestoreStateRef = useRef({
client,
emails,
mailboxes,
selectedMailbox,
selectedEmailId: selectedEmail?.id ?? null,
conversationThreadId: null as string | null,
});
navRestoreStateRef.current.client = client;
navRestoreStateRef.current.emails = emails;
navRestoreStateRef.current.mailboxes = mailboxes;
navRestoreStateRef.current.selectedMailbox = selectedMailbox;
navRestoreStateRef.current.selectedEmailId = selectedEmail?.id ?? null;
navRestoreStateRef.current.conversationThreadId = conversationThread?.threadId ?? null;
const handleNavRestore = useCallback(async (state: NavSnapshot) => {
const ctx = navRestoreStateRef.current;
// Restore sidebar overlay state.
setSidebarOpen(state.sidebarOpen);
// Restore composer visibility.
if (!state.composerOpen) {
setShowComposer(false);
}
// Derive the mobile view from the saved snapshot. The view is a
// function of which content the user is looking at: an email, a
// thread, the composer, or the bare list.
const derivedView: "list" | "viewer" =
state.emailId || state.threadId || state.composerOpen ? "viewer" : "list";
setActiveView(derivedView);
// Restore mailbox selection. selectMailbox clears the current email,
// which is fine because we re-apply the saved email below.
if (state.mailboxId && state.mailboxId !== ctx.selectedMailbox) {
selectMailbox(state.mailboxId);
if (ctx.client) {
try {
await fetchEmails(ctx.client, state.mailboxId);
} catch (error) {
debug.error('Failed to fetch emails on history restore:', error);
}
}
}
// Restore conversation thread (mobile only). We can clear it directly,
// but reopening requires the thread group; if the user pressed forward
// to return to a thread, we silently skip — back navigation always works.
if ((state.threadId ?? null) !== ctx.conversationThreadId) {
if (state.threadId === null) {
setConversationThread(null);
setConversationEmails([]);
}
}
// Restore email selection.
if (state.emailId !== ctx.selectedEmailId) {
if (state.emailId === null) {
selectEmail(null);
} else {
// Try the in-memory list first; the existing useEffect will fetch
// body content if it's missing.
const found = ctx.emails.find(e => e.id === state.emailId);
if (found) {
selectEmail(found);
} else if (ctx.client) {
// Email isn't in the current list (e.g. mailbox just changed).
// Fetch it directly.
try {
const mailbox = ctx.mailboxes.find(mb => mb.id === state.mailboxId);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const fullEmail = await ctx.client.getEmail(state.emailId, accountId);
if (fullEmail) selectEmail(fullEmail);
} catch (error) {
debug.error('Failed to fetch email on history restore:', error);
}
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useBrowserNavigation({
mailboxId: selectedMailbox,
emailId: selectedEmail?.id ?? null,
threadId: conversationThread?.threadId ?? null,
composerOpen: showComposer,
sidebarOpen,
onRestore: handleNavRestore,
enabled: isAuthenticated && mailboxes.length > 0,
});
// Keyboard shortcuts handlers
const keyboardHandlers = useMemo(() => ({
onNextEmail: () => {
@@ -266,6 +399,13 @@ export default function Home() {
});
}, [checkAuth]);
// Initialize plugins on mount (re-activates enabled plugins after refresh)
// Also syncs server-managed plugins and themes to the client
useEffect(() => {
usePluginStore.getState().initializePlugins();
useThemeStore.getState().syncServerThemes();
}, []);
// Hydrate persisted column widths from localStorage
useEffect(() => {
try {
@@ -322,13 +462,13 @@ export default function Home() {
if (pushEnabled) {
setPushConnected(true);
debug.log('[Push] Push notifications successfully enabled');
debug.log('push', '[Push] Push notifications successfully enabled');
} else {
debug.log('[Push] Push notifications not available on this server');
debug.log('push', '[Push] Push notifications not available on this server');
}
} catch (error) {
// Push notifications are optional - don't break the app if they fail
debug.log('[Push] Failed to setup push notifications:', error);
debug.log('push', '[Push] Failed to setup push notifications:', error);
}
} catch (error) {
console.error('Error loading email data:', error);
@@ -362,7 +502,7 @@ export default function Home() {
useEffect(() => {
// Clear any existing timeout when email changes
if (markAsReadTimeoutRef.current) {
debug.log('[Mark as Read] Clearing previous timeout');
debug.log('email', '[Mark as Read] Clearing previous timeout');
clearTimeout(markAsReadTimeoutRef.current);
markAsReadTimeoutRef.current = null;
}
@@ -374,20 +514,20 @@ export default function Home() {
// Get current setting value
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
debug.log('[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
debug.log('email', '[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
if (markAsReadDelay === -1) {
// Never mark as read automatically
debug.log('[Mark as Read] Never mode - email will stay unread');
debug.log('email', '[Mark as Read] Never mode - email will stay unread');
} else if (markAsReadDelay === 0) {
// Mark as read instantly
debug.log('[Mark as Read] Instant mode - marking as read now');
debug.log('email', '[Mark as Read] Instant mode - marking as read now');
markAsRead(client, selectedEmail.id, true);
} else {
// Mark as read after delay
debug.log('[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms');
debug.log('email', '[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms');
markAsReadTimeoutRef.current = setTimeout(() => {
debug.log('[Mark as Read] Timeout fired - marking as read now');
debug.log('email', '[Mark as Read] Timeout fired - marking as read now');
markAsRead(client, selectedEmail.id, true);
markAsReadTimeoutRef.current = null;
}, markAsReadDelay);
@@ -396,7 +536,7 @@ export default function Home() {
// Cleanup on unmount or when dependencies change
return () => {
if (markAsReadTimeoutRef.current) {
debug.log('[Mark as Read] Cleanup - clearing timeout');
debug.log('email', '[Mark as Read] Cleanup - clearing timeout');
clearTimeout(markAsReadTimeoutRef.current);
markAsReadTimeoutRef.current = null;
}
@@ -407,8 +547,11 @@ export default function Home() {
// Handle new email notifications - play sound
useEffect(() => {
if (newEmailNotification) {
playNotificationSound();
debug.log('New email received:', newEmailNotification.subject);
const { emailNotificationsEnabled, emailNotificationSound, notificationSoundChoice } = useSettingsStore.getState();
if (emailNotificationsEnabled && emailNotificationSound) {
playNotificationSound(notificationSoundChoice);
}
debug.log('email', 'New email received:', newEmailNotification.subject);
clearNewEmailNotification();
}
}, [newEmailNotification, clearNewEmailNotification]);
@@ -441,9 +584,27 @@ export default function Home() {
if (!client) return;
try {
const effectiveMode = pendingDraft?.mode ?? composerMode;
const originalEmailId = selectedEmail?.id;
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments);
setShowComposer(false);
// Mark the original email with $answered or $forwarded keyword
if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) {
try {
await client.setKeyword(originalEmailId, '$answered');
} catch (e) {
debug.error('Failed to set $answered keyword:', e);
}
} else if (originalEmailId && effectiveMode === 'forward') {
try {
await client.setKeyword(originalEmailId, '$forwarded');
} catch (e) {
debug.error('Failed to set $forwarded keyword:', e);
}
}
// Refresh the current mailbox to update the UI
await fetchEmails(client, selectedMailbox);
} catch (error) {
@@ -468,15 +629,40 @@ export default function Home() {
if (isMobile) setActiveView('viewer');
};
const handleEditDraft = (email?: Email) => {
const draft = email || selectedEmail;
const handleEditDraft = async (email?: Email) => {
if (!client) return;
const draftCandidate = email && typeof email === 'object' && typeof email.id === 'string'
? email
: selectedEmail;
let draft = draftCandidate;
if (!draft) return;
// The email list only fetches limited properties (no bodyValues/htmlBody/bcc).
// Fetch the full email so the composer gets all draft content.
if (!draft.bodyValues) {
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const fullDraft = await client.getEmail(draft.id, accountId);
if (!fullDraft) return;
draft = fullDraft;
}
const bodyText = draft.bodyValues
? Object.values(draft.bodyValues).map(v => v.value).join('\n')
: '';
const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId]
? draft.bodyValues[draft.htmlBody[0].partId].value
: undefined;
// Try to find the identity that matches the draft's from address to preserve it
const draftFromEmail = draft.from?.[0]?.email;
const matchedIdentity = draftFromEmail
? identities.find(id => id.email === draftFromEmail)
: null;
// Increment session ID to force the composer to remount with fresh state,
// even if it was already open (e.g. right-clicking a draft while composing).
setComposerSessionId(id => id + 1);
setPendingDraft({
to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '',
cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '',
@@ -485,7 +671,7 @@ export default function Home() {
body: htmlBody || bodyText,
showCc: (draft.cc?.length || 0) > 0,
showBcc: (draft.bcc?.length || 0) > 0,
selectedIdentityId: null,
selectedIdentityId: matchedIdentity?.id ?? null,
subAddressTag: '',
mode: 'compose',
draftId: draft.id,
@@ -544,8 +730,8 @@ export default function Home() {
}
};
const handleArchive = async () => {
if (!client || !selectedEmail) return;
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
if (!client || !emailToArchive) return;
// Find archive mailbox
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
@@ -555,10 +741,10 @@ export default function Home() {
try {
if (archiveMode === 'single') {
await moveToMailbox(client, selectedEmail.id, archiveMailbox.id);
await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id);
} else {
// Determine year/month from the email's received date
const emailDate = new Date(selectedEmail.receivedAt);
const emailDate = new Date(emailToArchive.receivedAt);
const year = emailDate.getFullYear().toString();
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
@@ -573,7 +759,7 @@ export default function Home() {
}
if (archiveMode === 'year') {
await moveToMailbox(client, selectedEmail.id, yearMailbox.id);
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
} else {
// archiveMode === 'month' — find or create month subfolder under year
const yearId = yearMailbox.originalId || yearMailbox.id;
@@ -584,9 +770,16 @@ export default function Home() {
monthMailbox = await client.createMailbox(month, yearId);
await fetchMailboxes(client);
}
await moveToMailbox(client, selectedEmail.id, monthMailbox.id);
await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id);
}
}
if (conversationThread?.threadId === emailToArchive.threadId) {
setConversationThread(null);
setConversationEmails([]);
}
void fetchMailboxes(client);
} catch (error) {
console.error("Failed to archive email:", error);
}
@@ -658,16 +851,22 @@ export default function Home() {
const keywords = { ...email.keywords };
// Remove old label and legacy color tags - set to false for JMAP to remove them
Object.keys(keywords).forEach(key => {
if (key.startsWith("$label:") || key.startsWith("$color:")) {
keywords[key] = false;
if (color === null) {
// Remove all label/color tags
Object.keys(keywords).forEach(key => {
if (key.startsWith("$label:") || key.startsWith("$color:")) {
keywords[key] = false;
}
});
} else {
const jmapKey = `$label:${color}`;
if (keywords[jmapKey] === true) {
// Toggle off if already active
keywords[jmapKey] = false;
} else {
// Add the tag without disturbing others
keywords[jmapKey] = true;
}
});
// Add new label tag if specified (using new $label: prefix)
if (color) {
keywords[`$label:${color}`] = true;
}
// Update email keywords via JMAP
@@ -858,6 +1057,8 @@ export default function Home() {
// Append signature from the primary identity
const finalBody = appendPlainTextSignature(body, primaryIdentity);
const originalEmailId = selectedEmail.id;
// Send reply with just the body text
await sendEmail(
client,
@@ -872,6 +1073,13 @@ export default function Home() {
primaryIdentity?.name || undefined
);
// Mark the original email as answered
try {
await client.setKeyword(originalEmailId, '$answered');
} catch (e) {
debug.error('Failed to set $answered keyword:', e);
}
// Refresh emails to show the sent reply
await fetchEmails(client, selectedMailbox);
};
@@ -890,6 +1098,10 @@ export default function Home() {
// Get current mailbox name for mobile header
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
const isFocusedMailLayout = mailLayout === 'focus';
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent;
// Handle email selection with mobile view switching
const handleEmailSelect = async (email: { id: string }) => {
@@ -932,14 +1144,24 @@ export default function Home() {
}
};
// Handle back navigation from viewer on mobile
// Handle back navigation from viewer on mobile.
// Delegate to the browser history stack so this button is equivalent to
// the OS back button / mouse back button — popstate then restores the
// previous snapshot via handleNavRestore. The viewer is only reachable
// from a state that pushed history, so back() always lands on an app entry.
const handleMobileBack = () => {
// If in conversation view, clear it
if (typeof window !== 'undefined') {
window.history.back();
return;
}
if (conversationThread) {
setConversationThread(null);
setConversationEmails([]);
}
selectEmail(null);
if (isTablet) {
setTabletListVisible(true);
}
setActiveView("list");
};
@@ -1020,6 +1242,13 @@ export default function Home() {
return (
<DragDropProvider>
<div className="flex flex-col h-dvh bg-background overflow-hidden">
{isRateLimited && rateLimitSecondsLeft !== null && (
<div className="flex items-center justify-center gap-2 bg-amber-500/10 border-b border-amber-500/30 text-amber-700 dark:text-amber-300 text-sm py-1.5 px-4 flex-shrink-0">
<AlertTriangle className="h-3.5 w-3.5" />
<span>{tCommon('rate_limited_title')}</span>
<span className="text-amber-700/80 dark:text-amber-300/80">{tCommon('rate_limited_detail', { seconds: rateLimitSecondsLeft })}</span>
</div>
)}
{connectionLost && (
<div className="flex items-center justify-center gap-2 bg-destructive/10 border-b border-destructive/30 text-destructive text-sm py-1.5 px-4 flex-shrink-0">
<RotateCcw className="h-3.5 w-3.5 animate-spin" />
@@ -1113,12 +1342,12 @@ export default function Home() {
"max-md:flex-1 max-md:border-r-0",
isMobile && activeView !== "list" && "max-md:hidden",
// Tablet/Desktop: fixed width with collapse animation
"md:flex-shrink-0 md:shadow-sm",
shouldHideViewerPane ? "md:flex-1 md:border-r-0" : "md:flex-shrink-0",
"md:shadow-sm",
!isResizing && "transition-all duration-200 ease-out",
// Tablet: collapse when email selected
isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
)}
style={!isMobile && !(isTablet && !tabletListVisible) ? { width: emailListWidth } : undefined}
style={!isMobile && !shouldCollapseListPane && !shouldHideViewerPane ? { width: emailListWidth } : undefined}
>
{/* Mobile Header for List View */}
<MobileHeader
@@ -1377,8 +1606,7 @@ export default function Home() {
await handleDelete();
}}
onArchive={async (email) => {
selectEmail(email);
await handleArchive();
await handleArchive(email);
}}
onSetColorTag={(emailId, color) => {
handleSetColorTag(emailId, color);
@@ -1404,24 +1632,27 @@ export default function Home() {
</ErrorBoundary>
</div>
{/* Floating Compose Button (mobile) */}
{isMobile && (
<Button
onClick={() => {
setComposerMode('compose');
setShowComposer(true);
setActiveView('viewer');
}}
className="absolute bottom-4 right-4 z-40 h-14 w-14 rounded-full shadow-lg"
aria-label={t('sidebar.compose')}
>
<PenSquare className="h-6 w-6" />
</Button>
)}
{/* Floating Compose Button */}
<Button
onClick={() => {
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
}}
className={cn(
"absolute z-40 rounded-full shadow-lg",
isMobile ? "bottom-4 right-4 h-14 w-14" : "bottom-4 right-4 h-12 w-12"
)}
aria-label={t('sidebar.compose')}
title={t('sidebar.compose_hint')}
data-tour="compose-button"
>
<PenSquare className={isMobile ? "h-6 w-6" : "h-5 w-5"} />
</Button>
</div>
{/* Email list resize handle (desktop only) */}
{!isMobile && !isTablet && (
{!isMobile && !isTablet && !isFocusedMailLayout && (
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
@@ -1433,12 +1664,13 @@ export default function Home() {
{/* Email Viewer / Composer - full screen on mobile, flex on tablet/desktop */}
<div
className={cn(
"flex flex-col h-full bg-background",
"flex flex-col h-full bg-background flex-1 min-w-0",
// Mobile: full screen overlay when active
"max-md:fixed max-md:inset-0 max-md:z-30",
isMobile && activeView !== "viewer" && "max-md:hidden",
// Tablet/Desktop: flex grow, min-w-0 allows truncation of long subjects
"md:flex-1 md:min-w-0 md:relative"
// Tablet/Desktop: relative
"md:relative",
shouldHideViewerPane && "md:hidden"
)}
>
{/* Inline Composer - shown in viewer pane */}
@@ -1451,11 +1683,14 @@ export default function Home() {
}}
>
<EmailComposer
key={composerSessionId}
mode={pendingDraft?.mode ?? composerMode}
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
replyTo={pendingDraft !== null ? pendingDraft.replyTo : (selectedEmail ? {
from: selectedEmail.from,
replyToAddresses: selectedEmail.replyTo,
to: selectedEmail.to,
cc: selectedEmail.cc,
bcc: selectedEmail.bcc,
subject: selectedEmail.subject,
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
@@ -1553,7 +1788,7 @@ export default function Home() {
onReplyAll={handleReplyAll}
onForward={handleForward}
onDelete={handleDelete}
onArchive={handleArchive}
onArchive={() => handleArchive()}
onToggleStar={handleToggleStar}
onSetColorTag={handleSetColorTag}
onMarkAsSpam={handleMarkAsSpam}
@@ -1565,14 +1800,15 @@ export default function Home() {
}}
onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply}
onBack={() => {
setTabletListVisible(true);
selectEmail(null);
}}
onBack={handleMobileBack}
onNavigateNext={handleNavigateNext}
onNavigatePrev={handleNavigatePrev}
onShowShortcuts={() => setShowShortcutsModal(true)}
onEditDraft={handleEditDraft}
onCompose={() => {
setComposerMode('compose');
setShowComposer(true);
}}
currentUserEmail={client?.getUsername()}
currentUserName={client?.getUsername()?.split("@")[0]}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
@@ -1626,6 +1862,7 @@ export default function Home() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} />
<TotpReauthDialog />
</div>
</DragDropProvider>
);
+47 -6
View File
@@ -24,6 +24,8 @@ import {
BookUser,
KeyRound,
PanelLeftClose,
Bell,
Puzzle,
type LucideIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -34,6 +36,7 @@ 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 { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
import { FilterSettings } from '@/components/settings/filter-settings';
import { TemplateSettings } from '@/components/settings/template-settings';
import { AdvancedSettings } from '@/components/settings/advanced-settings';
@@ -44,6 +47,9 @@ 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 { NotificationSettings } from '@/components/settings/notification-settings';
import { ThemesSettings } from '@/components/settings/themes-settings';
import { PluginsSettings } from '@/components/settings/plugins-settings';
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useIsDesktop } from '@/hooks/use-media-query';
@@ -53,9 +59,10 @@ 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 { usePolicyStore } from '@/stores/policy-store';
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 Tab = 'appearance' | 'email' | 'notifications' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'themes' | 'plugins' | 'advanced';
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
interface TabDef {
@@ -63,11 +70,13 @@ interface TabDef {
label: string;
icon: LucideIcon;
group: TabGroup;
experimental?: boolean;
}
const tabIcons: Record<Tab, LucideIcon> = {
appearance: Palette,
email: Mail,
notifications: Bell,
account: User,
security: Shield,
identities: UserPen,
@@ -81,6 +90,8 @@ const tabIcons: Record<Tab, LucideIcon> = {
keywords: Tags,
files: HardDrive,
sidebar_apps: PanelLeftClose,
themes: Palette,
plugins: Puzzle,
advanced: Wrench,
};
@@ -95,6 +106,7 @@ export default function SettingsPage() {
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig();
const { isFeatureEnabled } = usePolicyStore();
const [activeTab, setActiveTab] = useState<Tab>(() => {
try {
const saved = localStorage.getItem('settings-active-tab');
@@ -119,6 +131,19 @@ export default function SettingsPage() {
});
}, [checkAuth]);
// Listen for tab change events from child components
useEffect(() => {
const handler = (e: Event) => {
const tab = (e as CustomEvent).detail as Tab;
if (tab) {
setActiveTab(tab);
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
}
};
window.addEventListener('settings-tab-change', handler);
return () => window.removeEventListener('settings-tab-change', handler);
}, []);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
@@ -138,19 +163,22 @@ export default function SettingsPage() {
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: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, 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' },
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' as TabGroup }] : []),
...(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' },
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' as TabGroup }] : []),
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' },
{ id: 'keywords', label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' },
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' as TabGroup }] : []),
...(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' },
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'system' as TabGroup, experimental: true }] : []),
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'system' as TabGroup, experimental: true }] : []),
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
];
@@ -177,19 +205,22 @@ export default function SettingsPage() {
<>
{activeTab === 'appearance' && <AppearanceSettings />}
{activeTab === 'email' && <EmailSettings />}
{activeTab === 'notifications' && <NotificationSettings />}
{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 === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{activeTab === 'filters' && <FilterSettings />}
{activeTab === 'templates' && <TemplateSettings />}
{activeTab === 'folders' && <FolderSettings />}
{activeTab === 'keywords' && <KeywordSettings />}
{activeTab === 'files' && <FilesSettingsComponent />}
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />}
{activeTab === 'themes' && <ThemesSettings />}
{activeTab === 'plugins' && <PluginsSettings />}
{activeTab === 'advanced' && <AdvancedSettings />}
</>
);
@@ -274,6 +305,11 @@ export default function SettingsPage() {
<span className="flex items-center gap-3">
<Icon className="w-4 h-4 text-muted-foreground" />
{tab.label}
{tab.experimental && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-warning/15 text-warning">
Experimental
</span>
)}
</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
@@ -380,6 +416,11 @@ export default function SettingsPage() {
activeTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{tab.label}
{tab.experimental && (
<span className="ml-auto text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-warning/15 text-warning shrink-0">
Experimental
</span>
)}
</button>
);
})}
+217
View File
@@ -0,0 +1,217 @@
'use client';
import { useEffect, useState } from 'react';
import { Save, Loader2, RotateCcw } from 'lucide-react';
interface ConfigEntry {
value: unknown;
source: 'admin' | 'env' | 'default';
}
export default function AdminAuthPage() {
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 fetch('/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 fetch('/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 fetch('/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 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 items-center justify-between">
<div>
<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>
)}
{/* OAuth */}
<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" />
<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" />
</Section>
{/* SSO */}
<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>
{/* Session & Security */}
<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 items-center justify-between 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">
<input type={type} value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} placeholder={placeholder}
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" />
{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 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 items-center justify-between 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">
<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 items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
<div className="flex items-center gap-2">
<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>
);
}
+299
View File
@@ -0,0 +1,299 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
interface ConfigEntry {
value: unknown;
source: 'admin' | 'env' | 'default';
}
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' },
];
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' },
];
export default function AdminBrandingPage() {
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 [uploading, setUploading] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
useEffect(() => {
fetchConfig();
}, []);
async function fetchConfig() {
setLoading(true);
const res = await fetch('/api/admin/config');
if (res.ok) setConfig(await res.json());
setLoading(false);
}
function handleChange(key: string, value: string) {
setEdits(prev => ({ ...prev, [key]: value }));
setMessage(null);
}
function currentValue(key: string): string {
if (key in edits) return edits[key] as string;
return (config[key]?.value as string) ?? '';
}
async function handleSave() {
if (Object.keys(edits).length === 0) return;
setSaving(true);
setMessage(null);
const res = await fetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits),
});
if (res.ok) {
setMessage({ type: 'success', text: '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) {
setUploading(slot);
setMessage(null);
const formData = new FormData();
formData.append('file', file);
formData.append('slot', slot);
const res = await fetch('/api/admin/branding', {
method: 'POST',
body: formData,
});
if (res.ok) {
const data = await res.json();
setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` });
// Remove any pending URL edit for this slot since upload sets it
setEdits(prev => {
const next = { ...prev };
delete next[slot];
return next;
});
// Update config to reflect the uploaded URL
setConfig(prev => ({
...prev,
[slot]: { value: data.url, source: 'admin' },
}));
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Upload failed' });
}
setUploading(null);
}
async function handleDeleteUpload(slot: string) {
setMessage(null);
const res = await fetch('/api/admin/branding', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slot }),
});
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) {
const res = await fetch('/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 isUploadedFile = (key: string): boolean => {
const val = currentValue(key);
return val.startsWith('/api/admin/branding/');
};
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 items-center justify-between">
<div>
<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>
{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 items-center justify-between gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label>
{config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder="Enter URL or upload a file"
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"
/>
<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}
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
title="Upload file"
>
{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>
)}
{config[field.key]?.source === 'admin' && !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>
{/* Preview */}
{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={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">Company Information</h2>
</div>
<div className="divide-y divide-border">
{TEXT_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label>
{config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
)}
</div>
<div className="flex items-center gap-2">
<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-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{config[field.key]?.source === 'admin' && (
<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>
);
}
+116
View File
@@ -0,0 +1,116 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Lock } from 'lucide-react';
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 fetch('/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 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
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>
);
}
+276
View File
@@ -0,0 +1,276 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import Link from 'next/link';
import {
LayoutDashboard,
Settings,
Palette,
Shield,
Scale,
ScrollText,
LogOut,
KeyRound,
Puzzle,
SwatchBook,
Mail,
Calendar,
BookUser,
HardDrive,
ArrowLeft,
Store,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useAuthStore } from '@/stores/auth-store';
const NAV_GROUPS = [
{
label: 'Overview',
items: [
{ href: '/admin', label: 'Dashboard', icon: LayoutDashboard },
],
},
{
label: 'Configuration',
items: [
{ href: '/admin/settings', label: 'Settings', icon: Settings },
{ href: '/admin/branding', label: 'Branding', icon: Palette },
{ href: '/admin/auth', label: 'Authentication', icon: Shield },
{ href: '/admin/policy', label: 'Policy', icon: Scale },
],
},
{
label: 'Extensions',
items: [
{ href: '/admin/plugins', label: 'Plugins', icon: Puzzle },
{ href: '/admin/themes', label: 'Themes', icon: SwatchBook },
{ href: '/admin/marketplace', label: 'Marketplace', icon: Store },
],
},
{
label: 'System',
items: [
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
],
},
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const logoUrl = resolvedTheme === 'dark'
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
useEffect(() => {
if (pathname !== '/admin/login') {
checkAuth();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
function getJmapHeaders(): Record<string, string> {
return getActiveAccountSlotHeaders();
}
async function checkAuth() {
try {
const jmapHeaders = getJmapHeaders();
const res = await fetch('/api/admin/auth', { headers: jmapHeaders });
const data = await res.json();
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 fetch('/api/admin/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
body: JSON.stringify({ stalwartAuth: true }),
});
if (loginRes.ok) {
setAuthenticated(true);
return;
}
}
router.replace('/admin/login');
} catch {
router.replace('/admin/login');
}
}
async function handleLogout() {
await fetch('/api/admin/auth', { method: 'DELETE' });
router.replace('/admin/login');
}
// Don't gate the login page
if (pathname === '/admin/login') {
return <>{children}</>;
}
if (authenticated === null) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="animate-pulse text-muted-foreground text-sm">Loading...</div>
</div>
);
}
return (
<div className="min-h-screen flex bg-background">
{/* Slim webmail nav rail */}
<nav className="w-14 bg-secondary flex flex-col items-center py-3 gap-2 border-r 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="/"
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="/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="/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>
<a
href="/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="/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 */}
<aside className="w-60 border-r border-border bg-secondary flex flex-col sticky top-0 h-screen">
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
)}
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
</div>
<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(({ href, label, icon: Icon }) => {
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
'w-full text-left 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'
)}
>
<Icon className={cn(
'w-4 h-4 shrink-0',
active ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{label}
</Link>
);
})}
</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-left 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-left 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>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">
<div className="max-w-4xl mx-auto p-6">
{children}
</div>
</main>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
'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';
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 = resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl;
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await fetch('/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">
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-8 h-8 object-contain" />
) : (
<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>
);
}
+149
View File
@@ -0,0 +1,149 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { RefreshCw } from 'lucide-react';
import type { AuditEntry } from '@/lib/admin/types';
export default function AdminLogsPage() {
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 fetch(`/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 items-center justify-between">
<div>
<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>
{/* Filter */}
<div className="flex items-center gap-3">
<select
value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
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"
>
<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>
{/* Table */}
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Time</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Action</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">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>
{/* Pagination */}
{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);
}
+354
View File
@@ -0,0 +1,354 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react';
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;
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 default function AdminMarketplacePage() {
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 fetch(`/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]);
// Debounced search
const [searchInput, setSearchInput] = useState('');
useEffect(() => {
const t = setTimeout(() => {
setQuery(searchInput);
setPage(1);
}, 300);
return () => clearTimeout(t);
}, [searchInput]);
async function handleInstall(ext: Extension) {
setInstalling(ext.slug);
setMessage(null);
try {
const res = await fetch('/api/admin/marketplace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug: ext.slug,
version: ext.latestVersion || '1.0.0',
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: `"${ext.name}" installed successfully${warnings}` });
// Mark as installed in the UI
setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e));
} else {
setMessage({ type: 'error', text: data.error || 'Installation failed' });
}
} catch {
setMessage({ type: 'error', text: '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>
)}
{/* Search & Filters */}
<div className="flex items-center gap-3">
<div className="relative flex-1">
<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 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
/>
</div>
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5">
{(['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 State */}
{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 State */}
{loading && !error && (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Searching extensions...</span>
</div>
)}
{/* Empty State */}
{!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>
)}
{/* Extension Grid */}
{!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>
{/* Pagination */}
{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';
return (
<div className="border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
<div className="p-4">
{/* Header */}
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0">
{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">{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>
{/* Description */}
<p className="text-xs text-muted-foreground mt-3 line-clamp-2">
{extension.description}
</p>
{/* Tags */}
{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>
)}
{/* Footer */}
<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>
{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">
<Check className="w-3 h-3" />
Installed
</span>
) : (
<button
onClick={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" />
)}
Install
</button>
)}
</div>
</div>
</div>
);
}
+211
View File
@@ -0,0 +1,211 @@
'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';
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 default function AdminDashboardPage() {
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 }> | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
const [pluginCount, setPluginCount] = useState(0);
const [themeCount, setThemeCount] = useState(0);
const [policyRuleCount, setPolicyRuleCount] = useState(0);
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
useEffect(() => {
fetchDashboardData();
}, []);
async function fetchDashboardData() {
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([
fetch('/api/admin/auth'),
fetch('/api/admin/audit?limit=10'),
fetch('/api/config'),
fetch('/api/admin/config'),
fetch('/api/admin/plugins').catch(() => null),
fetch('/api/admin/themes').catch(() => null),
fetch('/api/admin/policy').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 (configData?.jmapServerUrl) {
try {
const jmapRes = await fetch('/api/config');
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
} catch {
setJmapHealth('error');
}
}
const w: string[] = [];
if (adminConfigRes.ok) {
const sources = await adminConfigRes.json();
setConfigSources(sources);
const sessionSecret = sources?.sessionSecret;
if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') {
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 */}
{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>
))}
{status && !status.lastLogin && (
<div 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" />
<div>
<p className="text-sm font-medium text-warning">First login detected</p>
<p className="text-sm text-warning/80 mt-0.5">
Remember to remove ADMIN_PASSWORD from your .env file now that the hash is stored securely.
</p>
</div>
</div>
)}
{/* Server Info */}
<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="JMAP Connection">
<span className={`inline-flex items-center gap-1.5 text-sm font-medium ${
jmapHealth === 'ok' ? 'text-green-600 dark:text-green-400' : jmapHealth === 'error' ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground'
}`}>
<span className={`w-2 h-2 rounded-full ${
jmapHealth === 'ok' ? 'bg-green-500' : jmapHealth === 'error' ? 'bg-red-500' : 'bg-muted-foreground/40'
}`} />
{jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : 'Unknown'}
</span>
</SettingItem>
<SettingItem label="Last Login">
<span className="text-sm text-foreground">
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
</span>
</SettingItem>
</SettingsSection>
{/* Features */}
<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>
{/* Extensions */}
<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>
{/* Recent Activity */}
<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);
}
+285
View File
@@ -0,0 +1,285 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
import Link from 'next/link';
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>;
}
export default function PluginConfigPage() {
const params = useParams();
const pluginId = params.id as string;
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(() => {
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pluginId]);
// Initialize form values from config + schema defaults when data loads
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 fetchData() {
setLoading(true);
try {
const [pluginsRes, configRes] = await Promise.all([
fetch('/api/admin/plugins'),
fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
]);
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 {
setLoading(false);
}
}
async function handleSaveAll() {
if (!plugin?.configSchema) return;
setSaving(true);
setMessage(null);
// Validate required fields
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 {
// Save each changed field
let hasError = false;
for (const [key, field] of Object.entries(plugin.configSchema)) {
const newVal = formValues[key] ?? '';
const oldVal = config[key] !== undefined ? String(config[key]) : '';
// Skip unchanged fields (and skip secret fields that show as empty when they have a stored value)
if (newVal === oldVal) continue;
if (field.type === 'secret' && !newVal && config[key]) continue;
// Convert types
let value: unknown = newVal;
if (field.type === 'boolean') value = newVal === 'true';
else if (field.type === 'number') value = Number(newVal);
// Delete if clearing a non-required field
if (!newVal && !field.required) {
const res = await fetch(`/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 fetch(`/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 mr-2" />
Loading...
</div>
);
}
if (!plugin) {
return (
<div className="space-y-4">
<Link href="/admin/plugins" 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
</Link>
<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">
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="w-4 h-4" />
</Link>
<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 ml-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 pr-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>
)}
</div>
);
}
+457
View File
@@ -0,0 +1,457 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import Link from 'next/link';
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';
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 default function AdminPluginsPage() {
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);
useEffect(() => { fetchPlugins(); fetchPolicy(); }, []);
async function fetchPolicy() {
try {
const res = await fetch('/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 fetch('/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 fetch('/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 fetch('/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 fetch('/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);
// If force-enabling, also ensure the plugin is enabled
const body: Record<string, unknown> = { id, forceEnabled };
if (forceEnabled) body.enabled = true;
const res = await fetch('/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));
// Also update policy
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 fetch('/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 fetch('/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 fetch('/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 (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 items-center justify-between">
<div>
<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 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>
)}
{/* Plugin Policy */}
<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 items-center justify-between 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 items-center justify-between 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 items-center justify-between 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>
{/* Force enable / disable all */}
{plugins.length > 0 && (
<div className="px-4 py-3 flex items-center justify-between 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>
{/* Deployed Plugins */}
<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 items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<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">
<Link
href={`/admin/plugins/${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" />
</Link>
<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>
);
}
+218
View File
@@ -0,0 +1,218 @@
'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';
// Feature gates managed on their own admin pages (excluded from this list)
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' },
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
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.' },
};
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', 'permanent'] },
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] },
{ 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 default function AdminPolicyPage() {
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 fetch('/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 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 fetch('/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 items-center justify-between">
<div>
<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>
)}
{/* Feature Gates */}
<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 items-center justify-between gap-4">
<div>
<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={`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>
{/* Setting Restrictions */}
{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 items-center justify-between gap-4">
<span className="text-sm text-foreground">{setting.label}</span>
<div className="flex items-center gap-3">
<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>
);
}
+248
View File
@@ -0,0 +1,248 @@
'use client';
import { useEffect, useState } from 'react';
import { Save, RotateCcw, Loader2 } from 'lucide-react';
interface ConfigEntry {
value: unknown;
source: 'admin' | 'env' | 'default';
}
export default function AdminSettingsPage() {
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 fetch('/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 fetch('/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 fetch('/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 items-center justify-between">
<div>
<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>
)}
{/* General */}
<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-l-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} />
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
<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} />
</SettingsSection>
{/* Logging */}
<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>
{/* Settings Sync */}
<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 items-center justify-between gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground whitespace-nowrap">{label}</label>
<SourceBadge source={source} />
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={value ?? ''}
onChange={(e) => onChange(configKey, e.target.value)}
placeholder={placeholder}
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"
/>
{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 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 items-center justify-between 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">
<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 items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
<div className="flex items-center gap-2">
<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>
);
}
+552
View File
@@ -0,0 +1,552 @@
'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';
const BUILTIN_THEME_OPTIONS = [
{ id: 'builtin-nord', name: 'Nord' },
{ id: 'builtin-catppuccin', name: 'Catppuccin' },
{ id: 'builtin-solarized', name: 'Solarized' },
];
interface ThemeEntry {
id: string;
name: string;
version: string;
author: string;
description: string;
variants: string[];
enabled: boolean;
forceEnabled?: boolean;
installedAt: string;
updatedAt: string;
}
export default function AdminThemesPage() {
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 fetch('/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 fetch('/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 fetch('/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 fetch('/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 fetch('/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 fetch('/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 fetch('/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 fetch('/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 fetch('/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 items-center justify-between">
<div>
<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 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>
)}
{/* Theme Policy */}
<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">
{/* Master toggle */}
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<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={`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>
{/* User uploads toggle */}
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<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={`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>
{/* Force enable / disable all */}
{themes.length > 0 && (
<div className="px-4 py-3 flex items-center justify-between 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 themes 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>
)}
{/* Default Theme */}
<div className="px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div>
<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 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>
{/* Built-in themes */}
<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>
{/* Admin-deployed themes */}
{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>
{/* Deployed Themes */}
<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 items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<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>
);
}
+17 -2
View File
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/**
* GET /api/account/stalwart/auth
* Proxy to Stalwart GET /api/account/auth
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart auth info failed', { status: response.status });
const detail = parseStalwartError(text);
logger.warn('Stalwart auth info failed', { status: response.status, detail });
return NextResponse.json(
{ error: 'Failed to fetch auth info', details: text },
{ error: detail || 'Failed to fetch auth info' },
{ status: response.status }
);
}
+17 -2
View File
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/**
* GET /api/account/stalwart/crypto
* Proxy to Stalwart GET /api/account/crypto
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart crypto info failed', { status: response.status });
const detail = parseStalwartError(text);
logger.warn('Stalwart crypto info failed', { status: response.status, detail });
return NextResponse.json(
{ error: 'Failed to fetch crypto info', details: text },
{ error: detail || 'Failed to fetch crypto info' },
{ status: response.status }
);
}
+13 -3
View File
@@ -2,8 +2,9 @@ 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 { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
const COOKIE_OPTIONS = {
httpOnly: true,
@@ -69,10 +70,19 @@ export async function POST(request: NextRequest) {
}
// If session cookie exists, update it with the new password
const cookieStore = await cookies();
if (creds.hasSessionCookie) {
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, newToken, COOKIE_OPTIONS);
cookieStore.set(sessionCookieName(creds.slot), newToken, COOKIE_OPTIONS);
}
if (creds.authHeader.startsWith('Basic ')) {
setStalwartAuthContextInStore(cookieStore, creds.slot, {
serverUrl: creds.serverUrl,
username: creds.username,
authHeader: `Basic ${Buffer.from(`${creds.username}:${newPassword}`).toString('base64')}`,
});
}
return NextResponse.json({ ok: true });
+17 -2
View File
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/**
* GET /api/account/stalwart/principal
* Proxy to Stalwart GET /api/principal/{username}
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart principal fetch failed', { status: response.status });
const detail = parseStalwartError(text);
logger.warn('Stalwart principal fetch failed', { status: response.status, detail });
return NextResponse.json(
{ error: 'Failed to fetch principal', details: text },
{ error: detail || 'Failed to fetch principal' },
{ status: response.status }
);
}
+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();
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 });
}
}
+151
View File
@@ -0,0 +1,151 @@
import { NextRequest, NextResponse } from 'next/server';
import { initAdminPassword, verifyAdminPassword, updateLastLogin, isAdminEnabled, getAdminMeta } from '@/lib/admin/password';
import { setAdminSessionCookie, clearAdminSessionCookie, requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { checkRateLimit } from '@/lib/admin/rate-limit';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Check if the current user is a Stalwart admin by probing an admin-only endpoint.
*/
async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
try {
const creds = await getStalwartCredentials(request);
if (!creds) return false;
// Probe admin-only endpoint: listing principals requires admin privileges
const response = await fetch(`${creds.apiUrl}/api/principal?limit=1`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
const isAdmin = response.ok;
logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin });
return isAdmin;
} catch (error) {
logger.debug('Stalwart admin check error', { error: error instanceof Error ? error.message : 'Unknown' });
return false;
}
}
/**
* POST /api/admin/auth — Login
*/
export async function POST(request: NextRequest) {
try {
const ip = getClientIP(request);
const body = await request.json();
// Stalwart-based admin authentication
if (body.stalwartAuth === true) {
const isStalwartAdmin = await checkStalwartAdmin(request);
if (!isStalwartAdmin) {
await auditLog('admin.login_failed', { method: 'stalwart' }, ip);
return NextResponse.json({ error: 'Not a Stalwart admin' }, { status: 403 });
}
await setAdminSessionCookie();
await auditLog('admin.login', { method: 'stalwart' }, ip);
return NextResponse.json({ ok: true });
}
// Password-based admin authentication
await initAdminPassword();
if (!isAdminEnabled()) {
return NextResponse.json({ error: 'Admin dashboard is not configured' }, { status: 404 });
}
// Rate limit check
const limit = checkRateLimit(ip);
if (!limit.allowed) {
const retryAfter = Math.ceil(limit.retryAfterMs / 1000);
await auditLog('admin.login_blocked', { reason: 'rate_limit' }, ip);
return NextResponse.json(
{ error: 'Too many login attempts. Try again later.' },
{ status: 429, headers: { 'Retry-After': String(retryAfter) } }
);
}
const { password } = body;
if (!password || typeof password !== 'string') {
return NextResponse.json({ error: 'Password is required' }, { status: 400 });
}
const valid = await verifyAdminPassword(password);
if (!valid) {
await auditLog('admin.login_failed', {}, ip);
logger.warn('Admin login failed', { ip });
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
}
await setAdminSessionCookie();
await updateLastLogin();
await auditLog('admin.login', {}, ip);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Admin login error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* GET /api/admin/auth — Check session status
* Also checks if the user is a Stalwart admin (admin panel enabled even without password).
*/
export async function GET(request: NextRequest) {
try {
await initAdminPassword();
const adminEnabled = isAdminEnabled();
const isStalwartAdmin = await checkStalwartAdmin(request);
// If neither password-based admin nor Stalwart admin, admin is disabled
if (!adminEnabled && !isStalwartAdmin) {
return NextResponse.json({ enabled: false, authenticated: false, stalwartAdmin: false }, {
headers: { 'Cache-Control': 'no-store' },
});
}
const result = await requireAdminAuth();
if ('error' in result) {
return NextResponse.json({
enabled: adminEnabled,
authenticated: false,
stalwartAdmin: isStalwartAdmin,
}, {
headers: { 'Cache-Control': 'no-store' },
});
}
const meta = getAdminMeta();
return NextResponse.json({
enabled: adminEnabled,
authenticated: true,
stalwartAdmin: isStalwartAdmin,
lastLogin: meta?.lastLogin,
passwordChangedAt: meta?.passwordChangedAt,
}, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Admin status error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* DELETE /api/admin/auth — Logout
*/
export async function DELETE(request: NextRequest) {
try {
const ip = getClientIP(request);
await clearAdminSessionCookie();
await auditLog('admin.logout', {}, ip);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Admin logout error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', '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(BRANDING_DIR, safe);
// Ensure resolved path is still within BRANDING_DIR
const resolved = path.resolve(filePath);
if (!resolved.startsWith(path.resolve(BRANDING_DIR))) {
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);
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600, must-revalidate',
'Content-Length': String(buffer.length),
},
});
} catch {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
}
+146
View File
@@ -0,0 +1,146 @@
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 { logger } from '@/lib/logger';
import { writeFile, unlink, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', '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',
]);
/** Slots that correspond to branding config keys */
const VALID_SLOTS = new Set([
'faviconUrl',
'appLogoLightUrl',
'appLogoDarkUrl',
'loginLogoLightUrl',
'loginLogoDarkUrl',
]);
function sanitizeFilename(name: string): string {
// Strip directory traversal, keep only safe chars
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
}
/**
* 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")
*/
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth();
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;
if (!file || !slot) {
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
}
if (!VALID_SLOTS.has(slot)) {
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { 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 },
);
}
// Determine extension from mime type
const extMap: Record<string, string> = {
'image/svg+xml': '.svg',
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'image/x-icon': '.ico',
'image/vnd.microsoft.icon': '.ico',
};
const ext = extMap[file.type] || '.png';
const safeName = sanitizeFilename(`${slot}${ext}`);
const filePath = path.join(BRANDING_DIR, safeName);
// Ensure branding directory exists
if (!existsSync(BRANDING_DIR)) {
await mkdir(BRANDING_DIR, { recursive: true });
}
// Write file to disk
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
// Update config to point to the served URL
const servedUrl = `/api/admin/branding/${safeName}`;
await configManager.ensureLoaded();
await configManager.setAdminConfig({ [slot]: servedUrl });
await auditLog('branding_upload', { slot, filename: safeName, size: file.size, mimeType: file.type }, ip);
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 }
*/
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const ip = getClientIP(request);
const { slot } = await request.json();
if (!slot || !VALID_SLOTS.has(slot)) {
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
}
// Find and remove matching files for this slot
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
let removed = false;
for (const ext of possibleExts) {
const filePath = path.join(BRANDING_DIR, `${slot}${ext}`);
if (existsSync(filePath)) {
await unlink(filePath);
removed = true;
}
}
// Clear the config override so it falls back to default/env
await configManager.ensureLoaded();
await configManager.removeAdminOverride(slot);
await auditLog('branding_delete', { slot, 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();
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 });
}
}
+94
View File
@@ -0,0 +1,94 @@
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 } from '@/lib/admin/types';
import { logger } from '@/lib/logger';
/**
* GET /api/admin/config — Get full config with sources (admin-protected)
*/
export async function GET() {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
await configManager.ensureLoaded();
const config = configManager.getAllWithSources();
return NextResponse.json(config, {
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();
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 });
}
// 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();
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 });
}
}
+252
View File
@@ -0,0 +1,252 @@
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,
getPluginRegistry,
getThemeRegistry,
type ServerPlugin,
type ServerTheme,
} from '@/lib/admin/plugin-registry';
import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001';
/**
* 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();
if ('error' in result) return result.error;
const { searchParams } = request.nextUrl;
const url = new URL('/api/v1/extensions', DIRECTORY_URL);
// 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 installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id));
const installedThemes = new Set(themeRegistry.themes.map(t => t.id));
if (data.data) {
data.data = data.data.map((ext: Record<string, unknown>) => ({
...ext,
installed: ext.type === 'theme'
? installedThemes.has(ext.slug as string)
: installedPlugins.has(ext.slug as string),
}));
}
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();
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 bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, DIRECTORY_URL);
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();
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 theme: ServerTheme = {
id: (manifest.id as string) || slug,
name: (manifest.name as string) || slug,
version: (manifest.version as string) || version,
author: (manifest.author as string) || 'Unknown',
description: (manifest.description as string) || '',
variants: (manifest.variants as string[]) || ['light', 'dark'],
enabled: true,
installedAt: now,
updatedAt: now,
};
await saveTheme(theme, css);
await auditLog('marketplace.install_theme', { id: theme.id, name: theme.name, version: theme.version, slug }, ip);
return NextResponse.json({ success: true, theme, warnings });
} 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(', ')}`);
}
const plugin: ServerPlugin = {
id: (manifest.id as string) || slug,
name: (manifest.name as string) || slug,
version: (manifest.version as string) || version,
author: (manifest.author as string) || 'Unknown',
description: (manifest.description as string) || '',
type: (manifest.type as string) || 'hook',
permissions,
entrypoint,
enabled: true,
installedAt: now,
updatedAt: now,
};
await savePlugin(plugin, code);
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug }, ip);
return NextResponse.json({ success: true, plugin, warnings });
}
} catch (error) {
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Installation failed' }, { status: 500 });
}
}
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
/**
* 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 });
}
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 });
}
return new NextResponse(code, {
headers: {
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'public, max-age=3600, must-revalidate',
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
},
});
} catch {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
}
+120
View File
@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPlugin } from '@/lib/admin/plugin-registry';
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
import { requireAdminAuth } from '@/lib/admin/session';
/**
* GET /api/admin/plugins/[id]/config — Read all config for a plugin
*
* Returns the full config object for admin-configured plugin settings.
* This endpoint is accessible from the client-side plugin API.
*/
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 plugin = await getPlugin(id);
if (!plugin) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
const config = await getPluginConfig(id);
return NextResponse.json(config, {
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();
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 getPlugin(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 });
}
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();
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 });
}
}
+247
View File
@@ -0,0 +1,247 @@
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';
// 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() {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const registry = await getPluginRegistry();
return NextResponse.json(registry.plugins, {
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();
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 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,
permissions: (manifest.permissions as string[]) || [],
entrypoint: manifest.entrypoint as string,
enabled: true,
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
: {}),
installedAt: now,
updatedAt: now,
};
await savePlugin(plugin, code);
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, 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();
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');
const updated = await updatePluginMeta(id, updates);
if (!updated) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
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();
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 });
}
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();
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 });
}
}
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* GET /api/admin/stalwart-check
* Check if the currently logged-in user is a Stalwart admin.
* Probes the admin-only principal-list endpoint — if the user can access it, they're an admin.
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ isStalwartAdmin: false }, {
headers: { 'Cache-Control': 'no-store' },
});
}
// Probe an admin-only endpoint: listing principals requires admin privileges.
// Use limit=1 to minimize payload.
const url = `${creds.apiUrl}/api/principal?limit=1`;
const response = await fetch(url, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
const isStalwartAdmin = response.ok;
logger.info('Stalwart admin check', { username: creds.username, status: response.status, isStalwartAdmin });
return NextResponse.json({ isStalwartAdmin }, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Stalwart admin check error', {
error: error instanceof Error ? error.message : 'Unknown',
});
return NextResponse.json({ isStalwartAdmin: false }, {
headers: { 'Cache-Control': 'no-store' },
});
}
}
+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() {
try {
const result = await requireAdminAuth();
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();
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();
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();
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 });
}
}
+76 -1
View File
@@ -4,6 +4,11 @@ import { logger } from '@/lib/logger';
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import {
clearStalwartAuthContextInStore,
setStalwartAuthContextInStore,
} from '@/lib/stalwart/auth-context';
const COOKIE_OPTIONS = {
...getCookieOptions(),
@@ -31,12 +36,23 @@ export async function POST(request: NextRequest) {
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
const cookieName = sessionCookieName(slot);
const token = encryptSession(serverUrl, username, password);
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
const token = encryptSession(normalizedServerUrl, username, password);
const cookieStore = await cookies();
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: normalizedServerUrl,
username,
authHeader,
});
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
@@ -56,9 +72,66 @@ export async function GET(request: NextRequest) {
const credentials = decryptSession(token);
if (!credentials) {
cookieStore.delete(cookieName);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
});
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
const { serverUrl, username } = credentials;
return NextResponse.json(
{ serverUrl, username },
{ headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
);
} catch (error) {
logger.error('Session read error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PUT — retrieve full credentials (including password) for session restoration.
* Protected by multiple Sec-Fetch-* headers to ensure only same-origin
* browser fetch() requests succeed. Non-browser clients cannot forge these.
*/
export async function PUT(request: NextRequest) {
try {
// Require all Sec-Fetch-* headers to match a same-origin fetch() call.
// Browsers set these automatically and they cannot be overridden by JS.
const secFetchSite = request.headers.get('sec-fetch-site');
const secFetchMode = request.headers.get('sec-fetch-mode');
const secFetchDest = request.headers.get('sec-fetch-dest');
if (secFetchSite !== 'same-origin' || secFetchMode !== 'cors' || secFetchDest !== 'empty') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const slot = getSlot(request);
const cookieName = sessionCookieName(slot);
const cookieStore = await cookies();
const token = cookieStore.get(cookieName)?.value;
if (!token) {
return NextResponse.json({ error: 'No session' }, { status: 401 });
}
const credentials = decryptSession(token);
if (!credentials) {
cookieStore.delete(cookieName);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
});
return NextResponse.json(credentials, {
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
});
@@ -77,10 +150,12 @@ export async function DELETE(request: NextRequest) {
// Delete all session cookies (slots 0-4)
for (let i = 0; i <= 4; i++) {
cookieStore.delete(sessionCookieName(i));
clearStalwartAuthContextInStore(cookieStore, i);
}
} else {
const slot = getSlot(request);
cookieStore.delete(sessionCookieName(slot));
clearStalwartAuthContextInStore(cookieStore, slot);
}
return NextResponse.json({ ok: true });
+2 -1
View File
@@ -7,13 +7,14 @@ import { getRequiredConfig } from '@/lib/oauth/token-exchange';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
export async function POST(request: NextRequest) {
try {
if (!process.env.SESSION_SECRET) {
if (!process.env.SESSION_SECRET && !readFileEnv(process.env.SESSION_SECRET_FILE)) {
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
function getSlot(request: NextRequest, bodySlot: unknown): number {
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
return bodySlot;
}
const raw = request.nextUrl.searchParams.get('slot');
if (raw === null) return 0;
const slot = parseInt(raw, 10);
return Number.isNaN(slot) || slot < 0 || slot > 4 ? 0 : slot;
}
export async function POST(request: NextRequest) {
try {
const { serverUrl, username, authHeader, slot: bodySlot } = await request.json();
if (!serverUrl || !username || !authHeader) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const slot = getSlot(request, bodySlot);
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl,
username,
authHeader,
});
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
logger.error('Failed to store Stalwart auth context', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+189
View File
@@ -0,0 +1,189 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
/**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
*
* This allows 2FA users who log in with basic auth + TOTP to upgrade
* to token-based auth, avoiding session expiry when the TOTP rotates.
*
* Tries three strategies:
* 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set)
* 2. ROPC grant without client_id
* 3. ROPC grant authenticated via Basic Auth header (Stalwart-style)
*/
async function tryTokenRequest(
tokenEndpoint: string,
params: URLSearchParams,
extraHeaders?: Record<string, string>,
): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> {
try {
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders };
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers,
body: params.toString(),
});
if (!response.ok) {
const errorText = await response.text();
return { ok: false, status: response.status, error: errorText.substring(0, 500) };
}
const tokens = await response.json();
if (!tokens.access_token) {
return { ok: false, status: 502, error: 'Response missing access_token' };
}
return { ok: true, tokens };
} catch (err) {
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
}
}
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
// 1. Try OAuth discovery
const metadata = await discoverOAuth(serverUrl);
if (metadata?.token_endpoint) return metadata.token_endpoint;
// 2. Try common Stalwart token endpoint paths directly
const candidates = [
`${serverUrl}/auth/token`,
`${serverUrl}/api/oauth/token`,
];
for (const url of candidates) {
try {
// A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists
const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' });
if (probe.status !== 404 && probe.status !== 405) {
return url;
}
} catch {
// Network error — endpoint not reachable
}
}
return null;
}
export async function POST(request: NextRequest) {
try {
const { serverUrl, username, password, slot: bodySlot } = await request.json();
if (!serverUrl || !username || !password) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
}
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
// Use the server-side JMAP_SERVER_URL if set (may differ from the
// public URL the browser uses, e.g. inside Docker).
const internalServerUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || serverUrl;
const tokenEndpoint = await findTokenEndpoint(internalServerUrl);
if (!tokenEndpoint) {
// Also try with the client-provided URL in case the internal one differs
const clientEndpoint = internalServerUrl !== serverUrl ? await findTokenEndpoint(serverUrl) : null;
if (!clientEndpoint) {
logger.warn('TOTP token exchange: no token endpoint found', { serverUrl, internalServerUrl });
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
}
return await attemptAllStrategies(clientEndpoint, username, password, slot);
}
return await attemptAllStrategies(tokenEndpoint, username, password, slot);
} catch (error) {
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
async function attemptAllStrategies(
tokenEndpoint: string,
username: string,
password: string,
slot: number,
): Promise<NextResponse> {
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
const clientId = process.env.OAUTH_CLIENT_ID;
const clientSecret = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const attempts: Array<{ strategy: string; error: string }> = [];
// Strategy 1: ROPC with client_id (if configured)
if (clientId) {
const params = new URLSearchParams({ grant_type: 'password', username, password, client_id: clientId });
if (clientSecret) params.set('client_secret', clientSecret);
const result = await tryTokenRequest(tokenEndpoint, params);
if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
}
// Strategy 2: ROPC without client_id
{
const params = new URLSearchParams({ grant_type: 'password', username, password });
const result = await tryTokenRequest(tokenEndpoint, params);
if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
}
// Strategy 3: Basic Auth header on token endpoint (some servers accept this)
{
const params = new URLSearchParams({ grant_type: 'password' });
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
if (result.ok) {
logger.info('TOTP token exchange succeeded (Basic Auth header)');
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'Basic Auth header', error: result.error });
}
// Strategy 4: client_credentials with Basic Auth (last resort)
{
const params = new URLSearchParams({ grant_type: 'client_credentials' });
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
if (result.ok) {
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
}
logger.warn('TOTP token exchange: all strategies failed', { attempts });
return NextResponse.json({
error: 'token_exchange_failed',
detail: 'All token exchange strategies failed',
attempts,
}, { status: 502 });
}
async function storeAndRespond(
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
slot: number,
): Promise<NextResponse> {
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot);
const cookieStore = await cookies();
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
}
return NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in || 3600,
has_refresh_token: !!tokens.refresh_token,
});
}
+41 -28
View File
@@ -1,43 +1,56 @@
import { NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env';
/**
* Runtime configuration endpoint
*
* This endpoint serves configuration values that can be set at runtime
* via environment variables, enabling post-build configuration for
* Docker deployments.
* via environment variables or admin dashboard overrides, enabling
* post-build configuration for Docker deployments.
*
* Priority order:
* 1. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
* 2. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
* 3. Default values
* 1. Admin dashboard overrides (data/admin/config.json)
* 2. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
* 3. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
* 4. Default values
*/
export async function GET() {
logger.debug('Config requested');
await configManager.ensureLoaded();
const appName = configManager.get<string>('appName') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
const jmapServerUrl = configManager.get<string>('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
const oauthOnly = oauthEnabled && configManager.get<boolean>('oauthOnly', false);
const stalwartFeaturesEnabled = configManager.get<boolean>('stalwartFeaturesEnabled', true);
const allowedFrameAncestors = configManager.get<string>('allowedFrameAncestors', '');
return NextResponse.json({
appName: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail',
jmapServerUrl: process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '',
oauthEnabled: process.env.OAUTH_ENABLED === 'true',
oauthOnly: process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true',
oauthClientId: process.env.OAUTH_CLIENT_ID || '',
oauthIssuerUrl: process.env.OAUTH_ISSUER_URL || '',
rememberMeEnabled: !!process.env.SESSION_SECRET,
settingsSyncEnabled: process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET,
stalwartFeaturesEnabled: process.env.STALWART_FEATURES !== 'false',
devMode: process.env.DEV_MOCK_JMAP === 'true',
faviconUrl: process.env.FAVICON_URL || '/branding/Bulwark_Favicon.svg',
appLogoLightUrl: process.env.APP_LOGO_LIGHT_URL || '',
appLogoDarkUrl: process.env.APP_LOGO_DARK_URL || '',
loginLogoLightUrl: process.env.LOGIN_LOGO_LIGHT_URL || '/branding/Bulwark_Logo_Color.svg',
loginLogoDarkUrl: process.env.LOGIN_LOGO_DARK_URL || '/branding/Bulwark_Logo_White.svg',
loginCompanyName: process.env.LOGIN_COMPANY_NAME || '',
loginImprintUrl: process.env.LOGIN_IMPRINT_URL || '',
loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '',
loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '',
demoMode: process.env.DEMO_MODE === 'true',
autoSsoEnabled: process.env.AUTO_SSO_ENABLED === 'true',
embeddedMode: !!process.env.ALLOWED_FRAME_ANCESTORS && process.env.ALLOWED_FRAME_ANCESTORS !== "'none'",
parentOrigin: process.env.NEXT_PUBLIC_PARENT_ORIGIN || '',
appName,
jmapServerUrl,
oauthEnabled,
oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
stalwartFeaturesEnabled,
devMode: configManager.get<boolean>('devMode', false),
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
appLogoLightUrl: configManager.get<string>('appLogoLightUrl', ''),
appLogoDarkUrl: configManager.get<string>('appLogoDarkUrl', ''),
loginLogoLightUrl: configManager.get<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
loginLogoDarkUrl: configManager.get<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
loginCompanyName: configManager.get<string>('loginCompanyName', ''),
loginImprintUrl: configManager.get<string>('loginImprintUrl', ''),
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get<string>('parentOrigin', ''),
});
}
+1 -1
View File
@@ -89,7 +89,7 @@ function localDateTime(dayOffset: number, hour: number, minute: number): string
}
/** Parse a JMAP duration like "PT1H30M", "P1D", "PT45M" into milliseconds. */
function parseDurationMs(dur: string): number {
function _parseDurationMs(dur: string): number {
let ms = 0;
const dayMatch = dur.match(/(\d+)D/);
const hourMatch = dur.match(/(\d+)H/);
+352 -26
View File
@@ -40,32 +40,358 @@ function isValidDomain(domain: string): boolean {
// Known multi-part TLDs where the registrable domain includes one extra label.
const MULTI_PART_TLDS = new Set([
"co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "net.uk",
"co.jp", "or.jp", "ne.jp", "ac.jp", "go.jp",
"co.kr", "or.kr", "go.kr", "ac.kr",
"co.in", "net.in", "org.in", "ac.in", "gov.in",
"co.nz", "org.nz", "net.nz", "govt.nz", "ac.nz",
"co.za", "org.za", "net.za", "gov.za", "ac.za",
"com.au", "net.au", "org.au", "edu.au", "gov.au",
"com.br", "net.br", "org.br", "edu.br", "gov.br",
"com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn",
"com.mx", "net.mx", "org.mx", "gob.mx", "edu.mx",
"com.ar", "net.ar", "org.ar", "gob.ar", "edu.ar",
"com.tw", "net.tw", "org.tw", "edu.tw", "gov.tw",
"com.hk", "net.hk", "org.hk", "edu.hk", "gov.hk",
"com.sg", "net.sg", "org.sg", "edu.sg", "gov.sg",
"com.my", "net.my", "org.my", "edu.my", "gov.my",
"com.ph", "net.ph", "org.ph", "edu.ph", "gov.ph",
"com.pk", "net.pk", "org.pk", "edu.pk", "gov.pk",
"com.ng", "net.ng", "org.ng", "edu.ng", "gov.ng",
"co.il", "org.il", "net.il", "ac.il", "gov.il",
"co.th", "or.th", "ac.th", "go.th", "in.th",
"co.id", "or.id", "ac.id", "go.id", "web.id",
"com.tr", "net.tr", "org.tr", "edu.tr", "gov.tr",
"com.ua", "net.ua", "org.ua", "edu.ua", "gov.ua",
"com.eg", "net.eg", "org.eg", "edu.eg", "gov.eg",
"com.sa", "net.sa", "org.sa", "edu.sa", "gov.sa",
"co.ke", "or.ke", "ac.ke", "go.ke", "ne.ke",
// .ac
"com.ac", "gov.ac", "mil.ac", "net.ac", "org.ac",
// .ae
"ac.ae", "co.ae", "gov.ae", "mil.ae", "name.ae", "net.ae", "org.ae", "pro.ae", "sch.ae",
// .af
"com.af", "edu.af", "gov.af", "net.af", "org.af",
// .al
"com.al", "edu.al", "gov.al", "mil.al", "net.al", "org.al",
// .ao
"co.ao", "ed.ao", "gv.ao", "it.ao", "og.ao", "pb.ao",
// .ar
"com.ar", "edu.ar", "gob.ar", "gov.ar", "int.ar", "mil.ar", "net.ar", "org.ar", "tur.ar",
// .at
"ac.at", "co.at", "gv.at", "or.at",
// .au
"asn.au", "com.au", "csiro.au", "edu.au", "gov.au", "id.au", "net.au", "org.au",
// .ba
"co.ba", "com.ba", "edu.ba", "gov.ba", "mil.ba", "net.ba", "org.ba", "rs.ba",
"unbi.ba", "unmo.ba", "unsa.ba", "untz.ba", "unze.ba",
// .bb
"biz.bb", "co.bb", "com.bb", "edu.bb", "gov.bb", "info.bb", "net.bb", "org.bb",
"store.bb", "tv.bb",
// .bh
"biz.bh", "cc.bh", "com.bh", "edu.bh", "gov.bh", "info.bh", "net.bh", "org.bh",
// .bn
"com.bn", "edu.bn", "gov.bn", "net.bn", "org.bn",
// .bo
"com.bo", "edu.bo", "gob.bo", "gov.bo", "int.bo", "mil.bo", "net.bo", "org.bo", "tv.bo",
// .br
"adm.br", "adv.br", "agr.br", "am.br", "arq.br", "art.br", "ato.br", "b.br",
"bio.br", "blog.br", "bmd.br", "cim.br", "cng.br", "cnt.br", "com.br", "coop.br",
"ecn.br", "edu.br", "eng.br", "esp.br", "etc.br", "eti.br", "far.br", "flog.br",
"fm.br", "fnd.br", "fot.br", "fst.br", "g12.br", "ggf.br", "gov.br", "imb.br",
"ind.br", "inf.br", "jor.br", "jus.br", "lel.br", "mat.br", "med.br", "mil.br",
"mus.br", "net.br", "nom.br", "not.br", "ntr.br", "odo.br", "org.br", "ppg.br",
"pro.br", "psc.br", "psi.br", "qsl.br", "rec.br", "slg.br", "srv.br", "tmp.br",
"trd.br", "tur.br", "tv.br", "vet.br", "vlog.br", "wiki.br", "zlg.br",
// .bs
"com.bs", "edu.bs", "gov.bs", "net.bs", "org.bs",
// .bz
"com.bz", "edu.bz", "gov.bz", "net.bz", "org.bz",
// .ca
"ab.ca", "bc.ca", "mb.ca", "nb.ca", "nf.ca", "nl.ca", "ns.ca", "nt.ca",
"nu.ca", "on.ca", "pe.ca", "qc.ca", "sk.ca", "yk.ca",
// .ck
"biz.ck", "co.ck", "edu.ck", "gen.ck", "gov.ck", "info.ck", "net.ck", "org.ck",
// .cn
"ac.cn", "ah.cn", "bj.cn", "com.cn", "cq.cn", "edu.cn", "fj.cn", "gd.cn",
"gov.cn", "gs.cn", "gx.cn", "gz.cn", "ha.cn", "hb.cn", "he.cn", "hi.cn",
"hl.cn", "hn.cn", "jl.cn", "js.cn", "jx.cn", "ln.cn", "mil.cn", "net.cn",
"nm.cn", "nx.cn", "org.cn", "qh.cn", "sc.cn", "sd.cn", "sh.cn", "sn.cn",
"sx.cn", "tj.cn", "tw.cn", "xj.cn", "xz.cn", "yn.cn", "zj.cn",
// .co
"com.co", "edu.co", "gov.co", "mil.co", "net.co", "nom.co", "org.co",
// .cr
"ac.cr", "co.cr", "ed.cr", "fi.cr", "go.cr", "or.cr", "sa.cr",
// .cy
"ac.cy", "biz.cy", "com.cy", "ekloges.cy", "gov.cy", "ltd.cy", "name.cy",
"net.cy", "org.cy", "parliament.cy", "press.cy", "pro.cy", "tm.cy",
// .do
"art.do", "com.do", "edu.do", "gob.do", "gov.do", "mil.do", "net.do", "org.do",
"sld.do", "web.do",
// .dz
"art.dz", "asso.dz", "com.dz", "edu.dz", "gov.dz", "net.dz", "org.dz", "pol.dz",
// .ec
"com.ec", "edu.ec", "fin.ec", "gov.ec", "info.ec", "med.ec", "mil.ec", "net.ec",
"org.ec", "pro.ec",
// .eg
"com.eg", "edu.eg", "eun.eg", "gov.eg", "mil.eg", "name.eg", "net.eg", "org.eg", "sci.eg",
// .er
"com.er", "edu.er", "gov.er", "ind.er", "mil.er", "net.er", "org.er", "rochest.er", "w.er",
// .es
"com.es", "edu.es", "gob.es", "nom.es", "org.es",
// .et
"biz.et", "com.et", "edu.et", "gov.et", "info.et", "name.et", "net.et", "org.et",
// .fj
"ac.fj", "biz.fj", "com.fj", "info.fj", "mil.fj", "name.fj", "net.fj", "org.fj", "pro.fj",
// .fk
"ac.fk", "co.fk", "gov.fk", "net.fk", "nom.fk", "org.fk",
// .fr
"asso.fr", "com.fr", "gouv.fr", "nom.fr", "prd.fr", "presse.fr", "tm.fr",
// .gg
"co.gg", "net.gg", "org.gg",
// .gh
"com.gh", "edu.gh", "gov.gh", "mil.gh", "org.gh",
// .gn
"ac.gn", "com.gn", "gov.gn", "net.gn", "org.gn",
// .gr
"com.gr", "edu.gr", "gov.gr", "mil.gr", "net.gr", "org.gr",
// .gt
"com.gt", "edu.gt", "gob.gt", "ind.gt", "mil.gt", "net.gt", "org.gt",
// .gu
"com.gu", "edu.gu", "gov.gu", "net.gu", "org.gu",
// .hk
"com.hk", "edu.hk", "gov.hk", "idv.hk", "net.hk", "org.hk",
// .id
"ac.id", "co.id", "go.id", "mil.id", "net.id", "or.id", "sch.id", "web.id",
// .il
"ac.il", "co.il", "gov.il", "idf.il", "k12.il", "muni.il", "net.il", "org.il",
// .in
"4fd.in", "ac.in", "co.in", "edu.in", "ernet.in", "firm.in", "gen.in", "gov.in",
"ind.in", "mil.in", "net.in", "nic.in", "org.in", "res.in",
// .iq
"com.iq", "edu.iq", "gov.iq", "mil.iq", "net.iq", "org.iq",
// .ir
"ac.ir", "co.ir", "dnssec.ir", "gov.ir", "id.ir", "net.ir", "org.ir", "sch.ir",
// .it
"edu.it", "gov.it",
// .je
"co.je", "net.je", "org.je",
// .jo
"com.jo", "edu.jo", "gov.jo", "mil.jo", "name.jo", "net.jo", "org.jo", "sch.jo",
// .jp
"ac.jp", "ad.jp", "co.jp", "ed.jp", "go.jp", "gr.jp", "lg.jp", "ne.jp", "or.jp",
// .ke
"ac.ke", "co.ke", "go.ke", "info.ke", "me.ke", "mobi.ke", "ne.ke", "or.ke", "sc.ke",
// .kh
"com.kh", "edu.kh", "gov.kh", "mil.kh", "net.kh", "org.kh", "per.kh",
// .ki
"biz.ki", "com.ki", "de.ki", "edu.ki", "gov.ki", "info.ki", "mob.ki", "net.ki",
"org.ki", "tel.ki",
// .km
"asso.km", "com.km", "coop.km", "edu.km", "gouv.km", "medecin.km", "mil.km",
"nom.km", "notaires.km", "pharmaciens.km", "presse.km", "tm.km", "veterinaire.km",
// .kn
"edu.kn", "gov.kn", "net.kn", "org.kn",
// .kr
"ac.kr", "busan.kr", "chungbuk.kr", "chungnam.kr", "co.kr", "daegu.kr",
"daejeon.kr", "es.kr", "gangwon.kr", "go.kr", "gwangju.kr", "gyeongbuk.kr",
"gyeonggi.kr", "gyeongnam.kr", "hs.kr", "incheon.kr", "jeju.kr", "jeonbuk.kr",
"jeonnam.kr", "kg.kr", "mil.kr", "ms.kr", "ne.kr", "or.kr", "pe.kr", "re.kr",
"sc.kr", "seoul.kr", "ulsan.kr",
// .kw
"com.kw", "edu.kw", "gov.kw", "net.kw", "org.kw",
// .ky
"com.ky", "edu.ky", "gov.ky", "net.ky", "org.ky",
// .kz
"com.kz", "edu.kz", "gov.kz", "mil.kz", "net.kz", "org.kz",
// .lb
"com.lb", "edu.lb", "gov.lb", "net.lb", "org.lb",
// .lk
"assn.lk", "com.lk", "edu.lk", "gov.lk", "grp.lk", "hotel.lk", "int.lk", "ltd.lk",
"net.lk", "ngo.lk", "org.lk", "sch.lk", "soc.lk", "web.lk",
// .lr
"com.lr", "edu.lr", "gov.lr", "net.lr", "org.lr",
// .lv
"asn.lv", "com.lv", "conf.lv", "edu.lv", "gov.lv", "id.lv", "mil.lv", "net.lv", "org.lv",
// .ly
"com.ly", "edu.ly", "gov.ly", "id.ly", "med.ly", "net.ly", "org.ly", "plc.ly", "sch.ly",
// .ma
"ac.ma", "co.ma", "gov.ma", "net.ma", "org.ma", "press.ma",
// .mc
"asso.mc", "tm.mc",
// .me
"ac.me", "co.me", "edu.me", "gov.me", "its.me", "net.me", "org.me", "priv.me",
// .mg
"com.mg", "edu.mg", "gov.mg", "mil.mg", "nom.mg", "org.mg", "prd.mg", "tm.mg",
// .mk
"com.mk", "edu.mk", "gov.mk", "inf.mk", "name.mk", "net.mk", "org.mk", "pro.mk",
// .ml
"com.ml", "edu.ml", "gov.ml", "net.ml", "org.ml", "presse.ml",
// .mn
"edu.mn", "gov.mn", "org.mn",
// .mo
"com.mo", "edu.mo", "gov.mo", "net.mo", "org.mo",
// .mt
"com.mt", "edu.mt", "gov.mt", "net.mt", "org.mt",
// .mu
"ac.mu", "co.mu", "com.mu", "gov.mu", "net.mu", "or.mu", "org.mu",
// .mv
"aero.mv", "biz.mv", "com.mv", "coop.mv", "edu.mv", "gov.mv", "info.mv",
"int.mv", "mil.mv", "museum.mv", "name.mv", "net.mv", "org.mv", "pro.mv",
// .mw
"ac.mw", "co.mw", "com.mw", "coop.mw", "edu.mw", "gov.mw", "int.mw",
"museum.mw", "net.mw", "org.mw",
// .mx
"com.mx", "edu.mx", "gob.mx", "net.mx", "org.mx",
// .my
"com.my", "edu.my", "gov.my", "mil.my", "name.my", "net.my", "org.my", "sch.my",
// .mz
"ac.mz", "co.mz", "edu.mz", "gov.mz", "org.mz",
// .na
"co.na", "com.na",
// .nf
"arts.nf", "com.nf", "firm.nf", "info.nf", "net.nf", "other.nf", "per.nf",
"rec.nf", "store.nf", "web.nf",
// .ng
"biz.ng", "com.ng", "edu.ng", "gov.ng", "mil.ng", "mobi.ng", "name.ng",
"net.ng", "org.ng", "sch.ng",
// .ni
"ac.ni", "co.ni", "com.ni", "edu.ni", "gob.ni", "mil.ni", "net.ni", "nom.ni", "org.ni",
// .np
"com.np", "edu.np", "gov.np", "mil.np", "net.np", "org.np",
// .nr
"biz.nr", "com.nr", "edu.nr", "gov.nr", "info.nr", "net.nr", "org.nr",
// .nz
"ac.nz", "co.nz", "cri.nz", "geek.nz", "gen.nz", "govt.nz", "health.nz",
"iwi.nz", "maori.nz", "mil.nz", "net.nz", "org.nz", "parliament.nz", "school.nz",
// .om
"ac.om", "biz.om", "co.om", "com.om", "edu.om", "gov.om", "med.om", "mil.om",
"museum.om", "net.om", "org.om", "pro.om", "sch.om",
// .pa
"abo.pa", "ac.pa", "com.pa", "edu.pa", "gob.pa", "ing.pa", "med.pa", "net.pa",
"nom.pa", "org.pa", "sld.pa",
// .pe
"com.pe", "edu.pe", "gob.pe", "mil.pe", "net.pe", "nom.pe", "org.pe", "sld.pe",
// .ph
"com.ph", "edu.ph", "gov.ph", "i.ph", "mil.ph", "net.ph", "ngo.ph", "org.ph",
// .pk
"biz.pk", "com.pk", "edu.pk", "fam.pk", "gob.pk", "gok.pk", "gon.pk", "gop.pk",
"gos.pk", "gov.pk", "net.pk", "org.pk", "web.pk",
// .pl
"art.pl", "bialystok.pl", "biz.pl", "com.pl", "edu.pl", "gda.pl", "gdansk.pl",
"gorzow.pl", "gov.pl", "info.pl", "katowice.pl", "krakow.pl", "lodz.pl",
"lublin.pl", "mil.pl", "net.pl", "ngo.pl", "olsztyn.pl", "org.pl", "poznan.pl",
"pwr.pl", "radom.pl", "slupsk.pl", "szczecin.pl", "torun.pl", "warszawa.pl",
"waw.pl", "wroc.pl", "wroclaw.pl", "zgora.pl",
// .pr
"ac.pr", "biz.pr", "com.pr", "edu.pr", "est.pr", "gov.pr", "info.pr", "isla.pr",
"name.pr", "net.pr", "org.pr", "pro.pr", "prof.pr",
// .ps
"com.ps", "edu.ps", "gov.ps", "net.ps", "org.ps", "plo.ps", "sec.ps",
// .pt
"com.pt", "edu.pt", "gov.pt", "int.pt", "net.pt", "nome.pt", "org.pt", "publ.pt",
// .pw
"belau.pw", "co.pw", "ed.pw", "go.pw", "ne.pw", "or.pw",
// .py
"com.py", "edu.py", "gov.py", "mil.py", "net.py", "org.py",
// .qa
"com.qa", "edu.qa", "gov.qa", "mil.qa", "net.qa", "org.qa",
// .re
"asso.re", "com.re", "nom.re",
// .ro
"arts.ro", "com.ro", "firm.ro", "info.ro", "nom.ro", "nt.ro", "org.ro",
"rec.ro", "store.ro", "tm.ro", "www.ro",
// .rs
"ac.rs", "co.rs", "edu.rs", "gov.rs", "in.rs", "org.rs",
// .ru
"ac.ru", "adygeya.ru", "altai.ru", "amur.ru", "arkhangelsk.ru", "astrakhan.ru",
"bashkiria.ru", "belgorod.ru", "bir.ru", "bryansk.ru", "buryatia.ru", "cbg.ru",
"chel.ru", "chelyabinsk.ru", "chita.ru", "chukotka.ru", "chuvashia.ru", "com.ru",
"dagestan.ru", "e-burg.ru", "edu.ru", "gov.ru", "grozny.ru", "int.ru",
"irkutsk.ru", "ivanovo.ru", "izhevsk.ru", "jar.ru", "joshkar-ola.ru",
"kalmykia.ru", "kaluga.ru", "kamchatka.ru", "karelia.ru", "kazan.ru", "kchr.ru",
"kemerovo.ru", "khabarovsk.ru", "khakassia.ru", "khv.ru", "kirov.ru",
"koenig.ru", "komi.ru", "kostroma.ru", "kranoyarsk.ru", "kuban.ru", "kurgan.ru",
"kursk.ru", "lipetsk.ru", "magadan.ru", "mari.ru", "mari-el.ru", "marine.ru",
"mil.ru", "mordovia.ru", "mosreg.ru", "msk.ru", "murmansk.ru", "nalchik.ru",
"net.ru", "nnov.ru", "nov.ru", "novosibirsk.ru", "nsk.ru", "omsk.ru",
"orenburg.ru", "org.ru", "oryol.ru", "penza.ru", "perm.ru", "pp.ru", "pskov.ru",
"ptz.ru", "rnd.ru", "ryazan.ru", "sakhalin.ru", "samara.ru", "saratov.ru",
"simbirsk.ru", "smolensk.ru", "spb.ru", "stavropol.ru", "stv.ru", "surgut.ru",
"tambov.ru", "tatarstan.ru", "tom.ru", "tomsk.ru", "tsaritsyn.ru", "tsk.ru",
"tula.ru", "tuva.ru", "tver.ru", "tyumen.ru", "udm.ru", "udmurtia.ru",
"ulan-ude.ru", "vladikavkaz.ru", "vladimir.ru", "vladivostok.ru", "volgograd.ru",
"vologda.ru", "voronezh.ru", "vrn.ru", "vyatka.ru", "yakutia.ru", "yamal.ru",
"yekaterinburg.ru", "yuzhno-sakhalinsk.ru",
// .rw
"ac.rw", "co.rw", "com.rw", "edu.rw", "gouv.rw", "gov.rw", "int.rw", "mil.rw", "net.rw",
// .sa
"com.sa", "edu.sa", "gov.sa", "med.sa", "net.sa", "org.sa", "pub.sa", "sch.sa",
// .sb
"com.sb", "edu.sb", "gov.sb", "net.sb", "org.sb",
// .sc
"com.sc", "edu.sc", "gov.sc", "net.sc", "org.sc",
// .sd
"com.sd", "edu.sd", "gov.sd", "info.sd", "med.sd", "net.sd", "org.sd", "tv.sd",
// .se
"a.se", "ac.se", "b.se", "bd.se", "c.se", "d.se", "e.se", "f.se", "g.se",
"h.se", "i.se", "k.se", "l.se", "m.se", "n.se", "o.se", "org.se", "p.se",
"parti.se", "pp.se", "press.se", "r.se", "s.se", "t.se", "tm.se", "u.se",
"w.se", "x.se", "y.se", "z.se",
// .sg
"com.sg", "edu.sg", "gov.sg", "idn.sg", "net.sg", "org.sg", "per.sg",
// .sh
"co.sh", "com.sh", "edu.sh", "gov.sh", "net.sh", "nom.sh", "org.sh",
// .sl
"com.sl", "edu.sl", "gov.sl", "net.sl", "org.sl",
// .sn
"art.sn", "com.sn", "edu.sn", "gouv.sn", "org.sn", "perso.sn", "univ.sn",
// .st
"co.st", "com.st", "consulado.st", "edu.st", "embaixada.st", "gov.st", "mil.st",
"net.st", "org.st", "principe.st", "saotome.st", "store.st",
// .sv
"com.sv", "edu.sv", "gob.sv", "org.sv", "red.sv",
// .sy
"com.sy", "edu.sy", "gov.sy", "mil.sy", "net.sy", "news.sy", "org.sy",
// .sz
"ac.sz", "co.sz", "org.sz",
// .th
"ac.th", "co.th", "go.th", "in.th", "mi.th", "net.th", "or.th",
// .tj
"ac.tj", "biz.tj", "co.tj", "com.tj", "edu.tj", "go.tj", "gov.tj", "info.tj",
"int.tj", "mil.tj", "name.tj", "net.tj", "nic.tj", "org.tj", "test.tj", "web.tj",
// .tn
"agrinet.tn", "com.tn", "defense.tn", "edunet.tn", "ens.tn", "fin.tn", "gov.tn",
"ind.tn", "info.tn", "intl.tn", "mincom.tn", "nat.tn", "net.tn", "org.tn",
"perso.tn", "rnrt.tn", "rns.tn", "rnu.tn", "tourism.tn",
// .tr
"av.tr", "bbs.tr", "bel.tr", "biz.tr", "com.tr", "dr.tr", "edu.tr", "gen.tr",
"gov.tr", "info.tr", "k12.tr", "name.tr", "net.tr", "org.tr", "pol.tr",
"tel.tr", "tsk.tr", "tv.tr", "web.tr",
// .tt
"aero.tt", "biz.tt", "cat.tt", "co.tt", "com.tt", "coop.tt", "edu.tt", "gov.tt",
"info.tt", "int.tt", "jobs.tt", "mil.tt", "mobi.tt", "museum.tt", "name.tt",
"net.tt", "org.tt", "pro.tt", "tel.tt", "travel.tt",
// .tw
"club.tw", "com.tw", "ebiz.tw", "edu.tw", "game.tw", "gov.tw", "idv.tw",
"mil.tw", "net.tw", "org.tw",
// .tz
"ac.tz", "co.tz", "go.tz", "ne.tz", "or.tz",
// .ua
"biz.ua", "cherkassy.ua", "chernigov.ua", "chernovtsy.ua", "ck.ua", "cn.ua",
"co.ua", "com.ua", "crimea.ua", "cv.ua", "dn.ua", "dnepropetrovsk.ua",
"donetsk.ua", "dp.ua", "edu.ua", "gov.ua", "if.ua", "in.ua",
"ivano-frankivsk.ua", "kh.ua", "kharkov.ua", "kherson.ua", "khmelnitskiy.ua",
"kiev.ua", "kirovograd.ua", "km.ua", "kr.ua", "ks.ua", "kv.ua", "lg.ua",
"lugansk.ua", "lutsk.ua", "lviv.ua", "me.ua", "mk.ua", "net.ua",
"nikolaev.ua", "od.ua", "odessa.ua", "org.ua", "pl.ua", "poltava.ua", "pp.ua",
"rovno.ua", "rv.ua", "sebastopol.ua", "sumy.ua", "te.ua", "ternopil.ua",
"uzhgorod.ua", "vinnica.ua", "vn.ua", "zaporizhzhe.ua", "zhitomir.ua",
"zp.ua", "zt.ua",
// .ug
"ac.ug", "co.ug", "go.ug", "ne.ug", "or.ug", "org.ug", "sc.ug",
// .uk
"ac.uk", "bl.uk", "british-library.uk", "co.uk", "cym.uk", "gov.uk", "govt.uk",
"icnet.uk", "jet.uk", "lea.uk", "ltd.uk", "me.uk", "mil.uk", "mod.uk",
"national-library-scotland.uk", "nel.uk", "net.uk", "nhs.uk", "nic.uk",
"nls.uk", "org.uk", "orgn.uk", "parliament.uk", "plc.uk", "police.uk",
"sch.uk", "scot.uk", "soc.uk",
// .us
"4fd.us", "dni.us", "fed.us", "isa.us", "kids.us", "nsn.us",
// .uy
"com.uy", "edu.uy", "gub.uy", "mil.uy", "net.uy", "org.uy",
// .ve
"co.ve", "com.ve", "edu.ve", "gob.ve", "info.ve", "mil.ve", "net.ve", "org.ve", "web.ve",
// .vi
"co.vi", "com.vi", "k12.vi", "net.vi", "org.vi",
// .vn
"ac.vn", "biz.vn", "com.vn", "edu.vn", "gov.vn", "health.vn", "info.vn",
"int.vn", "name.vn", "net.vn", "org.vn", "pro.vn",
// .ye
"co.ye", "com.ye", "gov.ye", "ltd.ye", "me.ye", "net.ye", "org.ye", "plc.ye",
// .yu
"ac.yu", "co.yu", "edu.yu", "gov.yu", "org.yu",
// .za
"ac.za", "agric.za", "alt.za", "bourse.za", "city.za", "co.za", "cybernet.za",
"db.za", "edu.za", "gov.za", "grondar.za", "iaccess.za", "imt.za", "inca.za",
"landesign.za", "law.za", "mil.za", "net.za", "ngo.za", "nis.za", "nom.za",
"olivetti.za", "org.za", "pix.za", "school.za", "tm.za", "web.za",
// .zm
"ac.zm", "co.zm", "com.zm", "edu.zm", "gov.zm", "net.zm", "org.zm", "sch.zm",
]);
function getRootDomain(domain: string): string {
+86 -21
View File
@@ -1,9 +1,39 @@
import { lookup } from 'node:dns/promises';
import { BlockList, isIP } from 'node:net';
import { NextRequest, NextResponse } from 'next/server';
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const FETCH_TIMEOUT_MS = 15000;
function isValidExternalUrl(urlString: string): boolean {
const blockedAddressRanges = new BlockList();
blockedAddressRanges.addAddress('0.0.0.0');
blockedAddressRanges.addAddress('127.0.0.1');
blockedAddressRanges.addSubnet('10.0.0.0', 8);
blockedAddressRanges.addSubnet('172.16.0.0', 12);
blockedAddressRanges.addSubnet('192.168.0.0', 16);
blockedAddressRanges.addSubnet('169.254.0.0', 16);
blockedAddressRanges.addAddress('::', 'ipv6');
blockedAddressRanges.addAddress('::1', 'ipv6');
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
function normalizeHostname(hostname: string): string {
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
}
function isBlockedIpAddress(hostname: string): boolean {
const normalized = normalizeHostname(hostname);
const family = isIP(normalized);
if (family === 4) {
return blockedAddressRanges.check(normalized, 'ipv4');
}
if (family === 6) {
return blockedAddressRanges.check(normalized, 'ipv6');
}
return false;
}
async function isValidExternalUrl(urlString: string): Promise<boolean> {
let url: URL;
try {
url = new URL(urlString);
@@ -15,21 +45,16 @@ function isValidExternalUrl(urlString: string): boolean {
return false;
}
const hostname = url.hostname.toLowerCase();
const hostname = normalizeHostname(url.hostname);
// Block private/internal hostnames
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0' ||
hostname.endsWith('.localhost') ||
hostname.endsWith('.local') ||
hostname.endsWith('.internal') ||
hostname.endsWith('.arpa') ||
hostname.startsWith('10.') ||
hostname.startsWith('192.168.') ||
hostname.startsWith('169.254.') ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
hostname.endsWith('.localdomain')
) {
return false;
}
@@ -39,7 +64,24 @@ function isValidExternalUrl(urlString: string): boolean {
return false;
}
return true;
if (isBlockedIpAddress(hostname)) {
return false;
}
if (isIP(hostname)) {
return true;
}
try {
const records = await lookup(hostname, { all: true, verbatim: true });
if (records.length === 0) {
return false;
}
return records.every((record) => !isBlockedIpAddress(record.address));
} catch {
return false;
}
}
export async function POST(request: NextRequest) {
@@ -56,7 +98,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
}
if (!isValidExternalUrl(url)) {
if (!(await isValidExternalUrl(url))) {
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
}
@@ -64,20 +106,43 @@ export async function POST(request: NextRequest) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const response = await fetch(url, {
signal: controller.signal,
headers: {
'Accept': 'text/calendar, application/ics, text/plain, */*',
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
},
redirect: 'follow',
});
const MAX_REDIRECTS = 5;
let currentUrl = url;
let response: Response | undefined;
for (let i = 0; i <= MAX_REDIRECTS; i++) {
if (!(await isValidExternalUrl(currentUrl))) {
clearTimeout(timeout);
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
}
response = await fetch(currentUrl, {
signal: controller.signal,
headers: {
'Accept': 'text/calendar, application/ics, text/plain, */*',
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
},
redirect: 'manual',
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) {
clearTimeout(timeout);
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
}
// Resolve relative redirects
currentUrl = new URL(location, currentUrl).toString();
continue;
}
break;
}
clearTimeout(timeout);
if (!response.ok) {
if (!response || !response.ok) {
return NextResponse.json(
{ error: `Remote server returned ${response.status}` },
{ error: `Remote server returned ${response?.status ?? 'unknown'}` },
{ status: 502 }
);
}
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
import { logger } from '@/lib/logger';
/**
* GET /api/plugins — Public endpoint for clients to discover server-managed plugins & themes
*
* Returns all enabled plugins and themes so the client can sync them to IndexedDB.
* No admin auth required — this is how regular users receive plugins/themes.
*/
export async function GET() {
try {
const [pluginRegistry, themeRegistry] = await Promise.all([
getPluginRegistry(),
getThemeRegistry(),
]);
// Only serve enabled plugins
const plugins = pluginRegistry.plugins
.filter(p => p.enabled)
.map(p => ({
id: p.id,
name: p.name,
version: p.version,
author: p.author,
description: p.description,
type: p.type,
permissions: p.permissions,
entrypoint: p.entrypoint,
forceEnabled: p.forceEnabled || false,
settingsSchema: undefined, // Will be read from the bundle's manifest
}));
// Only serve enabled themes
const themes = themeRegistry.themes
.filter(t => t.enabled)
.map(t => ({
id: t.id,
name: t.name,
version: t.version,
author: t.author,
description: t.description,
variants: t.variants,
forceEnabled: t.forceEnabled || false,
}));
return NextResponse.json(
{ plugins, themes },
{ 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 });
}
}
+103 -21
View File
@@ -3,36 +3,89 @@ import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { decryptSession } from '@/lib/auth/crypto';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env';
function classifyError(error: unknown): { message: string; status: number } {
const code = (error as NodeJS.ErrnoException).code;
const msg = error instanceof Error ? error.message : 'Unknown error';
switch (code) {
case 'EACCES':
case 'EPERM':
return {
message: 'Write permission denied on settings data directory. Check filesystem permissions for the SETTINGS_DATA_DIR (or data/settings/).',
status: 500,
};
case 'EROFS':
return {
message: 'Filesystem is read-only. Settings cannot be saved. Ensure the data directory is on a writable volume.',
status: 500,
};
case 'ENOSPC':
return {
message: 'No disk space available to save settings.',
status: 507,
};
case 'ENOENT':
return {
message: 'Settings data directory does not exist and could not be created. Check SETTINGS_DATA_DIR configuration.',
status: 500,
};
default:
if (msg.includes('SESSION_SECRET')) {
return {
message: 'Server configuration error: SESSION_SECRET is not set.',
status: 500,
};
}
return {
message: `Internal server error: ${msg}`,
status: 500,
};
}
}
function isEnabled(): boolean {
return process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET;
return process.env.SETTINGS_SYNC_ENABLED === 'true' && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE));
}
/** Strip trailing slashes so differently-formatted URLs still match. */
function normalizeUrl(url: string): string {
return url.replace(/\/+$/, '');
}
/**
* Verify identity against session cookies across all account slots.
* With multi-account, the requesting account may be on any slot (0-4).
* Returns true if any slot matches OR if no session cookies exist at all.
* Checks both basic-auth session cookies and stalwart auth context cookies
* (used by OAuth/SSO and TOTP-upgraded sessions).
* Returns true only if a matching cookie is found.
*/
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
const cookieStore = await cookies();
let hasAnyCookie = false;
const normalizedServerUrl = normalizeUrl(serverUrl);
for (let slot = 0; slot <= 4; slot++) {
// Check basic-auth session cookie
const token = cookieStore.get(sessionCookieName(slot))?.value;
if (!token) continue;
hasAnyCookie = true;
if (token) {
const session = decryptSession(token);
if (session && session.username === username && normalizeUrl(session.serverUrl) === normalizedServerUrl) {
return true;
}
}
const session = decryptSession(token);
if (session && session.username === username && session.serverUrl === serverUrl) {
return true; // Found a matching slot
// Check stalwart auth context cookie (set for all auth modes)
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
if (ctx && ctx.username === username && normalizeUrl(ctx.serverUrl) === normalizedServerUrl) {
return true;
}
}
// No cookies at all → can't verify, allow (same-origin protection applies)
if (!hasAnyCookie) return true;
// Cookies exist but none matched → identity mismatch
// No matching session found (or no cookies at all)reject
return false;
}
@@ -53,15 +106,13 @@ export async function GET(request: NextRequest) {
try {
const settings = await loadUserSettings(username, serverUrl);
if (!settings) {
return NextResponse.json({ error: 'No settings found' }, { status: 404 });
}
return NextResponse.json({ settings });
return NextResponse.json({ settings: settings || null });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
const code = (error as NodeJS.ErrnoException).code;
logger.error('Settings load error', { error: message, code });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
const classified = classifyError(error);
return NextResponse.json({ error: classified.message }, { status: classified.status });
}
}
@@ -84,13 +135,41 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
}
await saveUserSettings(username, serverUrl, settings);
// Enforce admin policy — strip locked settings so users can't override them
await configManager.ensureLoaded();
const policy = configManager.getPolicy();
const filteredSettings = { ...settings };
for (const key of Object.keys(filteredSettings)) {
const restriction = policy.restrictions[key];
if (!restriction) continue;
if (restriction.locked) {
delete filteredSettings[key];
continue;
}
const value = filteredSettings[key];
if (restriction.allowedValues && restriction.allowedValues.length > 0) {
if (!restriction.allowedValues.includes(value)) {
delete filteredSettings[key];
}
}
if (typeof value === 'number') {
if (restriction.min !== undefined && value < restriction.min) {
delete filteredSettings[key];
}
if (restriction.max !== undefined && value > restriction.max) {
delete filteredSettings[key];
}
}
}
await saveUserSettings(username, serverUrl, filteredSettings);
return NextResponse.json({ ok: true });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
const code = (error as NodeJS.ErrnoException).code;
logger.error('Settings save error', { error: message, code });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
const classified = classifyError(error);
return NextResponse.json({ error: classified.message }, { status: classified.status });
}
}
@@ -112,7 +191,10 @@ export async function DELETE(request: NextRequest) {
await deleteUserSettings(username, serverUrl);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Settings delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
const message = error instanceof Error ? error.message : 'Unknown error';
const code = (error as NodeJS.ErrnoException).code;
logger.error('Settings delete error', { error: message, code });
const classified = classifyError(error);
return NextResponse.json({ error: classified.message }, { status: classified.status });
}
}
+42 -12
View File
@@ -4,6 +4,32 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials';
const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']);
function normalizeDavRelativePath(rawPath: string): string {
const sanitized = rawPath.replace(/\\/g, '/').split(/[?#]/, 1)[0] ?? '';
const segments = sanitized.split('/').filter(Boolean);
return segments.map((segment) => {
let decoded: string;
try {
decoded = decodeURIComponent(segment);
} catch {
throw new Error('Invalid WebDAV path encoding');
}
if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\') || decoded.includes('\0')) {
throw new Error('Invalid WebDAV path segment');
}
return encodeURIComponent(decoded);
}).join('/');
}
function buildDavTargetUrl(baseUrl: string, username: string, rawPath: string): string {
const rootUrl = new URL(`${baseUrl.replace(/\/$/, '')}/dav/file/${encodeURIComponent(username)}/`);
const relativePath = normalizeDavRelativePath(rawPath);
return relativePath ? new URL(relativePath, rootUrl).toString() : rootUrl.toString();
}
/**
* POST /api/webdav
* Proxies WebDAV requests to the Stalwart server.
@@ -29,11 +55,8 @@ export async function POST(request: NextRequest) {
}
const davPath = request.headers.get('X-WebDAV-Path') || '/';
const cleanPath = davPath.replace(/^\/+/, '');
const baseUrl = creds.apiUrl.replace(/\/$/, '');
const targetUrl = cleanPath
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanPath}`
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
// Build headers for the upstream request
const upstreamHeaders: Record<string, string> = {
@@ -50,19 +73,20 @@ export async function POST(request: NextRequest) {
// For MOVE/COPY, construct the full Destination URL from the relative path
const destination = request.headers.get('X-WebDAV-Destination');
if (destination) {
const cleanDest = destination.replace(/^\/+/, '');
upstreamHeaders['Destination'] = cleanDest
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanDest}`
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
upstreamHeaders['Destination'] = buildDavTargetUrl(baseUrl, creds.username, destination);
}
const overwrite = request.headers.get('Overwrite');
if (overwrite) upstreamHeaders['Overwrite'] = overwrite;
// Forward request body for methods that need it
let body: ArrayBuffer | null = null;
if (method === 'PROPFIND' || method === 'PUT') {
// Forward request body for methods that need it.
// PUT streams directly to upstream to avoid buffering large uploads in memory.
// PROPFIND bodies are small XML and are read fully.
let body: ArrayBuffer | ReadableStream<Uint8Array> | null = null;
if (method === 'PROPFIND') {
body = await request.arrayBuffer();
} else if (method === 'PUT') {
body = request.body;
}
const response = await fetch(targetUrl, {
@@ -70,7 +94,9 @@ export async function POST(request: NextRequest) {
headers: upstreamHeaders,
body,
redirect: 'follow',
});
// `duplex: 'half'` is required by undici when sending a streaming request body.
...(method === 'PUT' ? { duplex: 'half' } : {}),
} as Parameters<typeof fetch>[1] & { duplex?: 'half' });
// For file downloads (GET), stream the response back
if (method === 'GET') {
@@ -104,6 +130,10 @@ export async function POST(request: NextRequest) {
status: response.status,
});
} catch (error) {
if (error instanceof Error && error.message.startsWith('Invalid WebDAV path')) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
+135 -47
View File
@@ -23,6 +23,39 @@
--color-popover: #ffffff;
--color-popover-foreground: #0f172a;
/* Sidebar */
--color-sidebar: #f8fafc;
--color-sidebar-foreground: #0f172a;
--color-sidebar-border: #e2e8f0;
--color-sidebar-accent: #f1f5f9;
--color-sidebar-accent-foreground: #0f172a;
/* Card */
--color-card: #ffffff;
--color-card-foreground: #0f172a;
/* Semantic status colors */
--color-success: #22c55e;
--color-success-foreground: #ffffff;
--color-warning: #eab308;
--color-warning-foreground: #ffffff;
--color-info: #3b82f6;
--color-info-foreground: #ffffff;
/* Selection states */
--color-selection: #dbeafe;
--color-selection-foreground: #1e40af;
/* Unread indicator */
--color-unread: #3b82f6;
/* Chart colors */
--color-chart-1: #3b82f6;
--color-chart-2: #22c55e;
--color-chart-3: #f59e0b;
--color-chart-4: #ef4444;
--color-chart-5: #8b5cf6;
/* Settings variables */
--font-size-base: 16px;
--list-item-height: 48px;
@@ -54,6 +87,39 @@
--color-destructive-foreground: #fafafa;
--color-popover: #1c1c1c;
--color-popover-foreground: #fafafa;
/* Sidebar */
--color-sidebar: #0a0a0a;
--color-sidebar-foreground: #fafafa;
--color-sidebar-border: #262626;
--color-sidebar-accent: #1a1a1a;
--color-sidebar-accent-foreground: #fafafa;
/* Card */
--color-card: #141414;
--color-card-foreground: #fafafa;
/* Semantic status colors */
--color-success: #16a34a;
--color-success-foreground: #ffffff;
--color-warning: #ca8a04;
--color-warning-foreground: #ffffff;
--color-info: #60a5fa;
--color-info-foreground: #ffffff;
/* Selection states */
--color-selection: rgba(59, 130, 246, 0.25);
--color-selection-foreground: #93c5fd;
/* Unread indicator */
--color-unread: #60a5fa;
/* Chart colors */
--color-chart-1: #60a5fa;
--color-chart-2: #4ade80;
--color-chart-3: #fbbf24;
--color-chart-4: #f87171;
--color-chart-5: #a78bfa;
}
@theme inline {
@@ -74,6 +140,27 @@
--color-destructive-foreground: var(--color-destructive-foreground);
--color-popover: var(--color-popover);
--color-popover-foreground: var(--color-popover-foreground);
--color-sidebar: var(--color-sidebar);
--color-sidebar-foreground: var(--color-sidebar-foreground);
--color-sidebar-border: var(--color-sidebar-border);
--color-sidebar-accent: var(--color-sidebar-accent);
--color-sidebar-accent-foreground: var(--color-sidebar-accent-foreground);
--color-card: var(--color-card);
--color-card-foreground: var(--color-card-foreground);
--color-success: var(--color-success);
--color-success-foreground: var(--color-success-foreground);
--color-warning: var(--color-warning);
--color-warning-foreground: var(--color-warning-foreground);
--color-info: var(--color-info);
--color-info-foreground: var(--color-info-foreground);
--color-selection: var(--color-selection);
--color-selection-foreground: var(--color-selection-foreground);
--color-unread: var(--color-unread);
--color-chart-1: var(--color-chart-1);
--color-chart-2: var(--color-chart-2);
--color-chart-3: var(--color-chart-3);
--color-chart-4: var(--color-chart-4);
--color-chart-5: var(--color-chart-5);
}
* {
@@ -121,6 +208,11 @@ body {
padding: 1rem 1.25rem;
}
.email-content-text a {
color: var(--color-primary);
text-decoration: underline;
}
.email-content {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
@@ -163,21 +255,16 @@ body {
}
.email-content blockquote {
border-left: 3px solid #d1d5db;
border-left: 3px solid var(--color-border);
padding-left: 1rem;
margin: 1rem 0;
color: #4b5563;
color: var(--color-muted-foreground);
font-style: italic;
}
.dark .email-content blockquote {
border-left-color: #4b5563;
color: #9ca3af;
}
.email-content pre {
background-color: #f5f5f7;
border: 1px solid #e5e7eb;
background-color: var(--color-muted);
border: 1px solid var(--color-border);
border-radius: 0.375rem;
padding: 1rem;
font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;
@@ -186,23 +273,13 @@ body {
margin: 1rem 0;
}
.dark .email-content pre {
background-color: #1f2937;
border-color: #374151;
}
.email-content code {
background-color: #f3f4f6;
background-color: var(--color-muted);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;
font-size: 0.875rem;
color: #dc2626;
}
.dark .email-content code {
background-color: #374151;
color: #f87171;
color: var(--color-destructive);
}
.email-content h1,
@@ -282,26 +359,15 @@ body {
.email-content table[border="1"] th,
.email-content table[border="1"] td {
padding: 0.625rem;
border: 1px solid #e5e7eb;
border: 1px solid var(--color-border);
text-align: left;
}
.email-content table.data-table th,
.email-content table[border="1"] th {
background-color: #f9fafb;
background-color: var(--color-muted);
font-weight: 600;
color: #374151;
}
.dark .email-content table.data-table th,
.dark .email-content table[border="1"] th {
background-color: #1f2937;
color: #d1d5db;
}
.dark .email-content table.data-table td,
.dark .email-content table[border="1"] td {
border-color: #374151;
color: var(--color-foreground);
}
/* Reset styles for layout tables (commonly used in HTML emails) */
@@ -323,28 +389,19 @@ body {
.email-content hr {
border: none;
border-top: 1px solid #e5e7eb;
border-top: 1px solid var(--color-border);
margin: 1.5rem 0;
}
.dark .email-content hr {
border-top-color: #374151;
}
/* Email thread quoted text */
.email-content .quoted-text {
border-left: 3px solid #d1d5db;
border-left: 3px solid var(--color-border);
padding-left: 1rem;
margin: 1rem 0;
color: #4b5563;
color: var(--color-muted-foreground);
opacity: 0.8;
}
.dark .email-content .quoted-text {
border-left-color: #4b5563;
color: #9ca3af;
}
/* Toast animations */
@keyframes toast-enter {
from {
@@ -477,6 +534,37 @@ body {
animation: slide-in-from-left 0.3s ease-out;
}
/* Calendar swipe animations (subtler slide for view transitions) */
@keyframes cal-slide-in-right {
from {
opacity: 0;
transform: translateX(30%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes cal-slide-in-left {
from {
opacity: 0;
transform: translateX(-30%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-slide-in-right {
animation: cal-slide-in-right 0.25s ease-out;
}
.animate-slide-in-left {
animation: cal-slide-in-left 0.25s ease-out;
}
/* Reduced motion: respect user OS preference */
@media (prefers-reduced-motion: reduce) {
*,
+19 -2
View File
@@ -1,3 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Gravit.io -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clipPath_Sdk6Fary7vPYZxd20WRVIKca0qb2ogEk"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clipPath_Sdk6Fary7vPYZxd20WRVIKca0qb2ogEk)"><rect width="1000" height="1000" style="fill:rgb(0,0,0)" fill-opacity="0"/><path d=" M 483.028 563.647 L 63.713 187.183 C 59.029 182.978 55.226 174.454 55.226 168.159 L 55.226 122.542 C 55.226 116.247 60.206 109.988 66.339 108.573 L 215.181 74.225 C 221.314 72.809 226.293 76.77 226.293 83.065 L 226.293 176.92 L 352.034 147.895 C 358.167 146.479 363.147 140.219 363.147 133.925 L 363.147 51.483 C 363.147 45.189 368.126 38.93 374.259 37.514 L 488.888 11.061 C 495.021 9.646 504.979 9.646 511.112 11.061 L 625.741 37.514 C 631.874 38.93 636.853 45.189 636.853 51.483 L 636.853 133.925 C 636.853 140.219 641.833 146.479 647.966 147.895 L 773.707 176.92 L 773.707 83.065 C 773.707 76.77 778.686 72.809 784.819 74.225 L 933.661 108.573 C 939.794 109.988 944.774 116.247 944.774 122.542 L 944.774 168.159 C 944.774 174.454 940.971 182.978 936.287 187.183 L 516.972 563.647 C 507.605 572.056 492.395 572.056 483.028 563.647 Z " fill="rgb(219,45,84)"/><path d=" M 944.774 332.832 L 944.774 396.969 C 944.774 403.263 941.16 411.987 936.709 416.437 L 884.411 468.736 C 879.96 473.186 876.347 481.91 876.347 488.204 L 876.347 682.08 C 876.345 718.462 866.664 750.017 847.953 778.668 L 658.833 589.547 L 944.774 332.832 Z " fill="rgb(219,45,84)"/><path d=" M 55.226 332.832 L 55.226 385.564 C 55.226 398.153 62.453 415.6 71.355 424.501 L 107.525 460.671 C 116.426 469.573 123.653 487.02 123.653 499.609 L 123.653 682.08 C 123.655 718.462 133.336 750.017 152.047 778.668 L 341.167 589.547 L 55.226 332.832 Z " fill="rgb(219,45,84)"/><path d=" M 765.645 857.641 C 701.996 901.327 612.26 941.932 500 990 Q 500 990 500 990 C 387.74 941.932 298.004 901.327 234.355 857.641 L 427.917 664.079 C 435.604 668.703 443.74 672.579 452.215 675.636 C 467.543 681.179 483.703 684.007 500 683.996 C 516.297 684.007 532.457 681.179 547.785 675.636 C 556.261 672.579 564.396 668.703 572.083 664.079 L 765.645 857.641 Z " fill="rgb(219,45,84)"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000">
<defs>
<clipPath id="_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV"><rect width="1000" height="1000"/></clipPath>
<style>
.icon-bg { fill: #ffffff; }
.icon-mark { fill: rgb(219,45,84); }
@media (prefers-color-scheme: dark) {
.icon-bg { fill: #18181b; }
}
</style>
</defs>
<g clip-path="url(#_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV)">
<rect width="1000" height="1000" class="icon-bg"/>
<path d=" M 489.315 575.068 L 225.342 338.071 C 222.394 335.424 220 330.058 220 326.095 L 220 297.377 C 220 293.415 223.135 289.474 226.996 288.583 L 320.697 266.96 C 324.558 266.069 327.692 268.563 327.692 272.525 L 327.692 331.61 L 406.851 313.338 C 410.712 312.446 413.846 308.506 413.846 304.543 L 413.846 252.643 C 413.846 248.681 416.981 244.741 420.842 243.85 L 493.004 227.197 C 496.865 226.306 503.135 226.306 506.996 227.197 L 579.158 243.85 C 583.019 244.741 586.154 248.681 586.154 252.643 L 586.154 304.543 C 586.154 308.506 589.288 312.446 593.149 313.338 L 672.308 331.61 L 672.308 272.525 C 672.308 268.563 675.442 266.069 679.303 266.96 L 773.004 288.583 C 776.865 289.474 780 293.415 780 297.377 L 780 326.095 C 780 330.058 777.606 335.424 774.658 338.071 L 510.685 575.068 C 504.788 580.362 495.212 580.362 489.315 575.068 Z " class="icon-mark"/>
<path d=" M 780 429.762 L 780 470.138 C 780 474.101 777.725 479.593 774.923 482.394 L 742 515.318 C 739.198 518.12 736.923 523.612 736.923 527.574 L 736.923 649.625 C 736.922 672.529 730.827 692.394 719.048 710.431 L 599.991 591.373 L 780 429.762 Z " class="icon-mark"/>
<path d=" M 220 429.762 L 220 462.959 C 220 470.884 224.55 481.867 230.153 487.471 L 252.924 510.241 C 258.527 515.845 263.077 526.829 263.077 534.754 L 263.077 649.625 C 263.078 672.529 269.173 692.394 280.952 710.431 L 400.009 591.373 L 220 429.762 Z " class="icon-mark"/>
<path d=" M 667.232 760.147 C 627.163 787.649 570.672 813.211 500 843.472 Q 500 843.472 500 843.472 C 429.328 813.211 372.837 787.649 332.768 760.147 L 454.622 638.293 C 459.461 641.204 464.582 643.644 469.918 645.569 C 479.567 649.058 489.741 650.839 500 650.832 C 510.259 650.839 520.433 649.058 530.082 645.569 C 535.418 643.644 540.539 641.204 545.378 638.293 L 667.232 760.147 Z " class="icon-mark"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+21
View File
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import { getLocale } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import "./globals.css";
const geistSans = Geist({
@@ -20,6 +22,15 @@ export async function generateMetadata(): Promise<Metadata> {
return {
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
description: "Minimalist webmail client using JMAP protocol",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
},
formatDetection: {
telephone: false,
},
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
};
}
@@ -36,6 +47,14 @@ export default async function RootLayout({
return (
<html lang={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} />
)}
@@ -63,7 +82,9 @@ export default async function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ServiceWorkerRegistration />
{children}
<PWAInstallPrompt />
</body>
</html>
);
+9 -2
View File
@@ -8,12 +8,19 @@ export default function NotFound() {
useEffect(() => {
if (!isAuthenticated) {
window.location.href = "/login";
// Don't redirect admin routes to the webmail login page
const isAdminRoute = window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/');
if (!isAdminRoute) {
window.location.href = "/login";
}
}
}, [isAuthenticated]);
if (!isAuthenticated) {
return null;
// Allow admin routes to render the 404 without redirecting
const isAdmin = typeof window !== 'undefined' &&
(window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/'));
if (!isAdmin) return null;
}
return (
+6 -6
View File
@@ -2,11 +2,11 @@
import { useMemo, useRef, useEffect, useCallback } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { format, parseISO, isToday, isTomorrow, startOfDay } from "date-fns";
import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react";
import { format, isToday, isTomorrow, startOfDay } from "date-fns";
import { MapPin, Users } from "lucide-react";
import { cn } from "@/lib/utils";
import { parseDuration, getEventColor } from "./event-card";
import { getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { getParticipantCount } from "@/lib/calendar-participants";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
@@ -49,7 +49,7 @@ export function CalendarAgendaView({
const grouped = useMemo(() => {
const sorted = [...events].sort((a, b) =>
new Date(a.start).getTime() - new Date(b.start).getTime()
getEventStartDate(a).getTime() - getEventStartDate(b).getTime()
);
const groups: DayGroup[] = [];
@@ -144,9 +144,9 @@ export function CalendarAgendaView({
const calId = getPrimaryCalendarId(ev);
const calendar = calId ? calendarMap.get(calId) : undefined;
const color = getEventColor(ev, calendar);
const start = parseISO(ev.start);
const start = getEventStartDate(ev);
const durMin = parseDuration(ev.duration);
const end = new Date(start.getTime() + durMin * 60000);
const end = getEventEndDate(ev);
const locationName = ev.locations
? Object.values(ev.locations)[0]?.name
: null;
+12 -14
View File
@@ -5,9 +5,9 @@ import { useTranslations, useFormatter } from "next-intl";
import { format, isSameDay, isToday, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
import { EventCard, parseDuration } from "./event-card";
import { EventCard } from "./event-card";
import { QuickEventInput } from "./quick-event-input";
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal";
@@ -66,7 +66,7 @@ export function CalendarDayView({
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
if (!spansThisDay) return;
if (ev.showWithoutTime) allDay.push(ev);
if (ev.showWithoutTime || isTimedEventFullDayOnDate(ev, selectedDate)) allDay.push(ev);
else timed.push(ev);
} catch { /* skip invalid dates */ }
});
@@ -127,10 +127,10 @@ export function CalendarDayView({
return format(new Date(2000, 0, 1, h), "HH:mm");
};
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents), [timedEvents]);
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents, selectedDate), [timedEvents, selectedDate]);
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
<div className="flex min-h-0 flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
{isMobile
@@ -182,7 +182,7 @@ export function CalendarDayView({
className={cn(
"flex-shrink-0 w-3.5 h-3.5 rounded-full border flex items-center justify-center",
isCompleted
? "bg-green-500 border-green-500 text-white"
? "bg-success border-success text-success-foreground"
: "border-muted-foreground/40 hover:border-primary"
)}
>
@@ -200,7 +200,7 @@ export function CalendarDayView({
</div>
)}
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
{HOURS.map((h) => (
@@ -241,11 +241,9 @@ export function CalendarDayView({
/>
))}
{layouted.map(({ event: ev, column, totalColumns }) => {
const start = parseISO(ev.start);
const startMin = start.getHours() * 60 + start.getMinutes();
const durMin = Math.max(15, parseDuration(ev.duration));
const top = (startMin / 60) * HOUR_HEIGHT;
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const durMin = Math.max(15, endMinutes - startMinutes);
const top = (startMinutes / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev);
@@ -288,8 +286,8 @@ export function CalendarDayView({
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2.5 h-2.5 rounded-full bg-red-500 -ml-1" />
<div className="flex-1 h-px bg-red-500" />
<div className="w-2.5 h-2.5 rounded-full bg-destructive -ml-1" />
<div className="flex-1 h-px bg-destructive" />
</div>
</div>
)}
+3 -2
View File
@@ -121,13 +121,14 @@ export function CalendarMonthView({
try {
const data = JSON.parse(json);
const originalStart = parseISO(data.originalStart);
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
const isAllDay = event?.showWithoutTime;
const newStart = new Date(day);
newStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), 0);
const newStartISO = format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
const newStartISO = isAllDay ? format(newStart, "yyyy-MM-dd") : format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
if (newStartISO === data.originalStart) return;
const client = useAuthStore.getState().client;
if (!client) return;
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
} catch {
+17 -1
View File
@@ -2,13 +2,14 @@
import { useState, useRef, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Globe, ListTodo, Plus, RefreshCw, Share2, Trash2 } from "lucide-react";
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useTaskStore } from "@/stores/task-store";
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { toast } from "@/stores/toast-store";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
@@ -18,6 +19,7 @@ interface CalendarSidebarPanelProps {
onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void;
onSubscribe?: () => void;
onEditSubscription?: (subscriptionId: string) => void;
client?: IJMAPClient | null;
}
@@ -27,6 +29,7 @@ export function CalendarSidebarPanel({
onToggleVisibility,
onColorChange,
onSubscribe,
onEditSubscription,
client,
}: CalendarSidebarPanelProps) {
const t = useTranslations("calendar");
@@ -162,6 +165,9 @@ export function CalendarSidebarPanel({
)}
</>
)}
{cal.id === BIRTHDAY_CALENDAR_ID && (
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)}
</button>
{/* Subscription context menu on right-click */}
@@ -173,6 +179,16 @@ export function CalendarSidebarPanel({
ref={contextMenuRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
>
<button
onClick={() => {
setContextMenuCalId(null);
onEditSubscription?.(sub.id);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
{tSub('edit')}
</button>
<button
onClick={() => handleRefreshSubscription(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
+128 -129
View File
@@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ListTodo } from "lucide-react";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft } from "lucide-react";
import { addDays, startOfWeek } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store";
@@ -40,6 +40,7 @@ export function CalendarToolbar({
onSubscribe,
isMobile,
firstDayOfWeek = 1,
onNavigateBack,
calendars,
selectedCalendarIds,
onToggleVisibility,
@@ -97,8 +98,7 @@ export function CalendarToolbar({
const [showImportDropdown, setShowImportDropdown] = useState(false);
const importDropdownRef = useRef<HTMLDivElement>(null);
const [showViewDropdown, setShowViewDropdown] = useState(false);
const viewDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showImportDropdown) return;
@@ -111,109 +111,76 @@ export function CalendarToolbar({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showImportDropdown]);
useEffect(() => {
if (!showViewDropdown) return;
function handleClickOutside(e: MouseEvent) {
if (viewDropdownRef.current && !viewDropdownRef.current.contains(e.target as Node)) {
setShowViewDropdown(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showViewDropdown]);
return (
<div className={cn("flex items-center gap-1.5 px-2 py-2 border-b border-border flex-wrap", !isMobile && "px-4 py-3 gap-2")}>
<div className={cn("border-b border-border", !isMobile && "flex items-center gap-2 px-4 py-3")}>
{/* ── MOBILE TOOLBAR ── */}
{isMobile && (
<div className="flex items-center gap-0.5">
<button onClick={onPrev} className="p-2 rounded hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</button>
<span className={cn("text-sm font-medium text-center min-w-[80px]")}>
{getDateLabel()}
</span>
<button onClick={onNext} className="p-2 rounded hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
<div className="flex flex-col gap-1 px-2 py-2">
{/* Row 1: Back / Date nav / Today */}
<div className="flex items-center gap-1">
{onNavigateBack && (
<button
onClick={onNavigateBack}
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
aria-label={t("back_to_month")}
>
<ArrowLeft className="w-4 h-4" />
</button>
)}
<button onClick={onPrev} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-semibold text-center flex-1 select-none truncate">
{getDateLabel()}
</span>
<button onClick={onNext} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</button>
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ml-0.5">
{t("views.today")}
</Button>
</div>
<Button variant="outline" size="sm" onClick={onToday} className="touch-manipulation">
{t("views.today")}
</Button>
{/* Row 2: View switcher pills + calendar toggle */}
<div className="flex items-center gap-1.5">
<div className="flex flex-1 border border-border rounded-md overflow-hidden">
{views.map((v) => (
<button
key={v}
onClick={() => onViewModeChange(v)}
className={cn(
"flex-1 py-1.5 text-[11px] font-medium transition-colors touch-manipulation",
v === viewMode
? "bg-primary text-primary-foreground"
: "text-muted-foreground active:bg-muted"
)}
>
{t(`views.${v}`)}
</button>
))}
</div>
{!isMobile && (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</Button>
<span className="text-base font-semibold ml-2 select-none">
{getDateLabel()}
</span>
</div>
)}
{isMobile && calendars && selectedCalendarIds && onToggleVisibility && (
<div className="relative" ref={dropdownRef}>
<Button
variant="outline"
size="sm"
onClick={() => setShowCalendarDropdown((v) => !v)}
aria-label={t("my_calendars")}
className="touch-manipulation"
>
<CalendarDays className="w-4 h-4" />
</Button>
{showCalendarDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
{t("my_calendars")}
</h3>
<div className="space-y-0.5">
{calendars.filter(c => !c.isShared).map((cal) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6";
return (
<button
key={cal.id}
onClick={() => onToggleVisibility(cal.id)}
className={cn(
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
"hover:bg-muted"
)}
>
<span
className={cn(
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
</button>
);
})}
</div>
{(() => {
const shared = calendars.filter(c => c.isShared);
const groups = new Map<string, { accountName: string; cals: typeof shared }>();
for (const c of shared) {
const key = c.accountId || c.accountName || c.id;
if (!groups.has(key)) groups.set(key, { accountName: c.accountName || key, cals: [] });
groups.get(key)!.cals.push(c);
}
return Array.from(groups.values()).map((group) => (
<div key={group.accountName} className="mt-2">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
{group.accountName}
{calendars && selectedCalendarIds && onToggleVisibility && (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowCalendarDropdown((v) => !v)}
className={cn(
"p-1.5 rounded-md border border-border transition-colors touch-manipulation",
showCalendarDropdown ? "bg-muted" : "hover:bg-muted"
)}
aria-label={t("my_calendars")}
>
<CalendarDays className="w-4 h-4" />
</button>
{showCalendarDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
{t("my_calendars")}
</h3>
<div className="space-y-0.5">
{group.cals.map((cal) => {
{calendars.filter(c => !c.isShared).map((cal) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6";
return (
@@ -239,44 +206,76 @@ export function CalendarToolbar({
);
})}
</div>
{(() => {
const shared = calendars.filter(c => c.isShared);
const groups = new Map<string, { accountName: string; cals: typeof shared }>();
for (const c of shared) {
const key = c.accountId || c.accountName || c.id;
if (!groups.has(key)) groups.set(key, { accountName: c.accountName || key, cals: [] });
groups.get(key)!.cals.push(c);
}
return Array.from(groups.values()).map((group) => (
<div key={group.accountName} className="mt-2">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
{group.accountName}
</h3>
<div className="space-y-0.5">
{group.cals.map((cal) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6";
return (
<button
key={cal.id}
onClick={() => onToggleVisibility(cal.id)}
className={cn(
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
"hover:bg-muted"
)}
>
<span
className={cn(
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
</button>
);
})}
</div>
</div>
));
})()}
</div>
));
})()}
</div>
)}
)}
</div>
)}
</div>
</div>
)}
{isMobile && (
<div className="relative" ref={viewDropdownRef}>
<Button
variant="outline"
size="sm"
onClick={() => setShowViewDropdown((v) => !v)}
className="touch-manipulation capitalize text-xs"
>
{t(`views.${viewMode}`)}
<ChevronLeft className="w-3 h-3 ml-1 rotate-[-90deg]" />
{/* ── DESKTOP TOOLBAR ── */}
{!isMobile && (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</Button>
{showViewDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-1 min-w-[120px]">
{views.map((v) => (
<button
key={v}
onClick={() => { onViewModeChange(v); setShowViewDropdown(false); }}
className={cn(
"flex items-center w-full px-3 py-2 rounded-md text-sm transition-colors touch-manipulation",
v === viewMode ? "bg-primary text-primary-foreground" : "hover:bg-muted text-foreground"
)}
>
{t(`views.${v}`)}
</button>
))}
</div>
)}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</Button>
<span className="text-base font-semibold ml-2 select-none">
{getDateLabel()}
</span>
</div>
)}
<div className="flex-1" />
{!isMobile && (
+56 -32
View File
@@ -7,9 +7,9 @@ import {
} from "date-fns";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
import { EventCard, parseDuration } from "./event-card";
import { EventCard } from "./event-card";
import { QuickEventInput } from "./quick-event-input";
import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, packWeekSegments } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal";
@@ -53,16 +53,13 @@ export function CalendarWeekView({
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
const scrollRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
const weekDays = useMemo(() => {
if (isMobile) {
// Show 3-day window centered on selected date
return Array.from({ length: 3 }, (_, i) => addDays(selectedDate, i - 1));
}
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
}, [selectedDate, weekStart, isMobile]);
}, [selectedDate, weekStart]);
const calendarMap = useMemo(() => {
const map = new Map<string, Calendar>();
@@ -80,7 +77,7 @@ export function CalendarWeekView({
const cursor = new Date(startDay);
while (cursor <= endDay) {
const key = format(cursor, "yyyy-MM-dd");
if (!ev.showWithoutTime) {
if (!ev.showWithoutTime && !isTimedEventFullDayOnDate(ev, cursor)) {
const arr = timed.get(key) || [];
arr.push(ev);
timed.set(key, arr);
@@ -92,10 +89,18 @@ export function CalendarWeekView({
return timed;
}, [events]);
const allDaySegments = useMemo(() => buildWeekSegments(
events.filter((event) => event.showWithoutTime),
weekDays,
), [events, weekDays]);
const allDaySegments = useMemo(() => {
const explicitAllDay = buildWeekSegmentsRaw(
events.filter((event) => event.showWithoutTime),
weekDays,
);
const timedFullDay = buildTimedFullDayWeekSegments(
events.filter((event) => !event.showWithoutTime),
weekDays,
);
return packWeekSegments([...explicitAllDay, ...timedFullDay]);
}, [events, weekDays]);
const allDayRowCount = useMemo(() => {
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
@@ -137,6 +142,17 @@ export function CalendarWeekView({
const now = new Date();
scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT);
}
// On mobile, scroll horizontally to center today's column
if (isMobile && rootRef.current) {
const todayIdx = weekDays.findIndex(d => isToday(d));
if (todayIdx >= 0) {
const gutter = 40;
const colWidth = (rootRef.current.scrollWidth - gutter) / 7;
const target = gutter + todayIdx * colWidth - rootRef.current.clientWidth / 2 + colWidth / 2;
rootRef.current.scrollLeft = Math.max(0, target);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [nowMinutes, setNowMinutes] = useState(() => {
@@ -175,20 +191,28 @@ export function CalendarWeekView({
return format(new Date(2000, 0, 1, h), "HH:mm");
};
const colCount = isMobile ? 3 : 7;
const colCount = 7;
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}>
{hasAllDay && (
<div
ref={rootRef}
className={cn(
"flex min-h-0 min-w-0 flex-col flex-1",
isMobile ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden"
)}
role="grid"
aria-label={t("views.week")}
>
<div className={cn("flex min-h-0 flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
<div className="flex border-b border-border">
<div
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10" : "w-14")}
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
>
{t("events.all_day")}
</div>
<div
className={cn("flex-1 relative grid gap-px bg-border", isMobile ? "grid-cols-3" : "grid-cols-7")}
className="flex-1 relative grid gap-px bg-border grid-cols-7"
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
>
{weekDays.map((day) => (
@@ -252,7 +276,7 @@ export function CalendarWeekView({
>
<span className={cn(
"w-2.5 h-2.5 rounded-full border flex-shrink-0 flex items-center justify-center",
isCompleted ? "bg-green-500 border-green-500" : "border-current"
isCompleted ? "bg-success border-success" : "border-current"
)}>
{isCompleted && <Check className="h-2 w-2 text-white" />}
</span>
@@ -271,8 +295,8 @@ export function CalendarWeekView({
)}
<div className="flex border-b border-border" role="row">
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")} />
<div className={cn("flex-1 border-l border-border", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}>
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")} />
<div className="flex-1 border-l border-border grid grid-cols-7">
{weekDays.map((day) => {
const todayCol = isToday(day);
const selected = isSameDay(day, selectedDate);
@@ -305,9 +329,9 @@ export function CalendarWeekView({
</div>
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")}>
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}>
{HOURS.map((h) => (
<div
key={h}
@@ -323,12 +347,12 @@ export function CalendarWeekView({
))}
</div>
<div className={cn("flex-1 border-l border-border relative", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}>
<div className="flex-1 border-l border-border relative grid grid-cols-7">
{weekDays.map((day) => {
const key = format(day, "yyyy-MM-dd");
const dayEvents = timedEvents.get(key) || [];
const todayCol = isToday(day);
const layouted = layoutOverlappingEvents(dayEvents);
const layouted = layoutOverlappingEvents(dayEvents, day);
return (
<div
@@ -355,11 +379,9 @@ export function CalendarWeekView({
/>
))}
{layouted.map(({ event: ev, column, totalColumns }) => {
const start = parseISO(ev.start);
const startMin = start.getHours() * 60 + start.getMinutes();
const durMin = Math.max(15, parseDuration(ev.duration));
const top = (startMin / 60) * HOUR_HEIGHT;
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const durMin = Math.max(15, endMinutes - startMinutes);
const top = (startMinutes / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev);
@@ -402,8 +424,8 @@ export function CalendarWeekView({
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-red-500 -ml-1" />
<div className="flex-1 h-px bg-red-500" />
<div className="w-2 h-2 rounded-full bg-destructive -ml-1" />
<div className="flex-1 h-px bg-destructive" />
</div>
</div>
)}
@@ -448,7 +470,8 @@ export function CalendarWeekView({
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && (
(() => {
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
let endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
if (endMin <= startMin) endMin = 1440;
const durationMin = Math.max(15, endMin - startMin);
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
const color = cal?.color || "hsl(var(--primary))";
@@ -479,5 +502,6 @@ export function CalendarWeekView({
</div>
</div>
</div>
</div>
);
}
+4 -3
View File
@@ -4,9 +4,10 @@ import { useCallback, useState, type CSSProperties, type DragEvent } from "react
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { format, parseISO } from "date-fns";
import { format } from "date-fns";
import { Users } from "lucide-react";
import { getParticipantCount } from "@/lib/calendar-participants";
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { useSettingsStore } from "@/stores/settings-store";
interface EventCardProps {
@@ -68,14 +69,14 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
const t = useTranslations("calendar");
const [isBeingDragged, setIsBeingDragged] = useState(false);
const color = getEventColor(event, calendar);
const startDate = parseISO(event.start);
const startDate = getEventStartDate(event);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const calendarName = calendar?.name || "";
const durationMinutes = parseDuration(event.duration);
const endTime = new Date(startDate.getTime() + durationMinutes * 60000);
const endTime = getEventEndDate(event);
const timeString = `${format(startDate, timeFmt)} ${format(endTime, timeFmt)}`;
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
+15 -14
View File
@@ -12,6 +12,7 @@ import { format, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import {
isOrganizer,
getUserParticipantId,
@@ -87,7 +88,7 @@ function formatDurationDisplay(minutes: number): string {
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
if (!event.alerts) return null;
const first = Object.values(event.alerts)[0];
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
if (!first || !first.trigger || first.trigger["@type"] !== "OffsetTrigger") return null;
const offset = first.trigger.offset;
if (offset === "PT0S") return t("alerts.at_time");
const minMatch = offset.match(/-?PT?(\d+)M$/);
@@ -138,9 +139,9 @@ export function EventDetailPopover({
const [isSavingNote, setIsSavingNote] = useState(false);
const color = getEventColor(event, calendar);
const startDate = parseISO(event.start);
const startDate = getEventStartDate(event);
const durationMinutes = parseDuration(event.duration);
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
const endDate = getEventEndDate(event);
const locationName = useMemo(() => {
if (!event.locations) return null;
@@ -297,7 +298,7 @@ export function EventDetailPopover({
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">
{calendar.name}
{event.status === "tentative" && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
{t("detail.tentative")}
</span>
)}
@@ -507,8 +508,8 @@ export function EventDetailPopover({
onClick={() => onRsvp("accepted")}
className={
userCurrentStatus === "accepted"
? "bg-green-600 hover:bg-green-700 text-white dark:bg-green-500 dark:hover:bg-green-600"
: "text-green-600 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950"
? "bg-success hover:bg-success/80 text-success-foreground"
: "text-success border-success/30 hover:bg-success/10"
}
>
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 mr-1" />}
@@ -520,8 +521,8 @@ export function EventDetailPopover({
onClick={() => onRsvp("tentative")}
className={
userCurrentStatus === "tentative"
? "bg-amber-600 hover:bg-amber-700 text-white dark:bg-amber-500 dark:hover:bg-amber-600"
: "border border-amber-500 text-amber-600 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950"
? "bg-warning hover:bg-warning/80 text-warning-foreground"
: "border border-warning/30 text-warning hover:bg-warning/10"
}
>
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 mr-1" />}
@@ -533,8 +534,8 @@ export function EventDetailPopover({
onClick={() => onRsvp("declined")}
className={
userCurrentStatus === "declined"
? "bg-red-600 hover:bg-red-700 text-white dark:bg-red-500 dark:hover:bg-red-600"
: "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
: "text-destructive hover:bg-destructive/10"
}
>
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 mr-1" />}
@@ -548,7 +549,7 @@ export function EventDetailPopover({
<div className="px-4 py-2.5 border-t border-border flex items-center gap-1.5">
{showDeleteConfirm ? (
<div className="flex items-center gap-2 w-full">
<span className="text-sm text-red-600 dark:text-red-400 flex-1">
<span className="text-sm text-destructive flex-1">
{t("form.delete_confirm")}
</span>
<Button
@@ -611,9 +612,9 @@ function ParticipantStatusBadge({
t: ReturnType<typeof useTranslations>;
}) {
const colors: Record<string, string> = {
accepted: "text-green-600 dark:text-green-400",
declined: "text-red-600 dark:text-red-400",
tentative: "text-amber-600 dark:text-amber-400",
accepted: "text-success",
declined: "text-destructive",
tentative: "text-warning",
"needs-action": "text-muted-foreground",
};
const labels: Record<string, string> = {
+88 -33
View File
@@ -8,7 +8,7 @@ import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Vid
import { format, parseISO, addHours, addDays } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { ParticipantInput } from "./participant-input";
import {
isOrganizer,
@@ -18,7 +18,9 @@ import {
getStatusCounts,
buildParticipantMap,
} from "@/lib/calendar-participants";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils";
export interface PendingEventPreview {
start: Date;
@@ -59,10 +61,12 @@ function buildDuration(startDate: Date, endDate: Date): string {
const minutes = totalMinutes % 60;
let dur = "P";
if (days > 0) dur += `${days}D`;
dur += "T";
if (hours > 0) dur += `${hours}H`;
if (minutes > 0) dur += `${minutes}M`;
if (dur === "PT") dur = "PT0M";
if (hours > 0 || minutes > 0) {
dur += "T";
if (hours > 0) dur += `${hours}H`;
if (minutes > 0) dur += `${minutes}M`;
}
if (dur === "P") dur = "PT0M";
return dur;
}
@@ -83,9 +87,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
const offset = first.trigger.offset;
if (offset === "PT0S") return t("alerts.at_time");
const minMatch = offset.match(/-?PT?(\d+)M$/);
const minMatch = offset.match(/-?PT(\d+)M$/);
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) });
const hourMatch = offset.match(/-?PT?(\d+)H$/);
const hourMatch = offset.match(/-?PT(\d+)H$/);
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) });
const dayMatch = offset.match(/-?P(\d+)D/);
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
@@ -157,7 +161,7 @@ export function EventModal({
}, [event, existingParticipants]);
const getInitialStart = (): Date => {
if (event?.start) return parseISO(event.start);
if (event?.start) return getEventStartDate(event);
if (defaultDate) {
const d = new Date(defaultDate);
if (defaultEndDate) return d;
@@ -175,9 +179,7 @@ export function EventModal({
if (event.showWithoutTime) {
return getEventDisplayEndDate(event);
}
const s = parseISO(event.start);
const dur = parseDuration(event.duration);
return new Date(s.getTime() + dur * 60000);
return getEventEndDate(event);
}
if (defaultEndDate) return new Date(defaultEndDate);
return addHours(getInitialStart(), 1);
@@ -188,6 +190,9 @@ export function EventModal({
const [location, setLocation] = useState(
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
);
const [virtualLocation, setVirtualLocation] = useState(
event?.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || "" : ""
);
const [startDate, setStartDate] = useState(formatDateInput(getInitialStart()));
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
@@ -209,9 +214,9 @@ export function EventModal({
if (first.trigger["@type"] === "OffsetTrigger") {
const offset = first.trigger.offset;
if (offset === "PT0S") return "at_time";
const minMatch = offset.match(/-?PT?(\d+)M$/);
const minMatch = offset.match(/-?PT(\d+)M$/);
if (minMatch) return minMatch[1] as AlertOption;
const hourMatch = offset.match(/-?PT?(\d+)H$/);
const hourMatch = offset.match(/-?PT(\d+)H$/);
if (hourMatch) return String(parseInt(hourMatch[1]) * 60) as AlertOption;
const dayMatch = offset.match(/-?P(\d+)D/);
if (dayMatch) return String(parseInt(dayMatch[1]) * 1440) as AlertOption;
@@ -263,7 +268,7 @@ export function EventModal({
? `${startDate}T00:00:00`
: `${startDate}T${startTime}:00`;
const start = new Date(startStr);
const start = allDay ? parseISO(startStr) : new Date(startStr);
let duration: string;
if (allDay) {
@@ -313,6 +318,20 @@ export function EventModal({
data.locations = null;
}
if (virtualLocation.trim()) {
data.virtualLocations = {
vl1: {
"@type": "VirtualLocation",
name: null,
description: null,
uri: virtualLocation.trim(),
features: null,
},
};
} else if (event && event.virtualLocations && Object.keys(event.virtualLocations).length > 0) {
data.virtualLocations = null;
}
if (recurrence !== "none") {
data.recurrenceRules = [{
"@type": "RecurrenceRule",
@@ -372,7 +391,7 @@ export function EventModal({
} finally {
setIsSaving(false);
}
}, [title, description, location, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return;
@@ -382,12 +401,14 @@ export function EventModal({
const handleDuplicate = useCallback(() => {
if (!event || !onDuplicate) return;
const start = parseISO(event.start);
const start = getEventStartDate(event);
const newStart = addDays(start, 1);
const newUid = generateUUID();
const data: Partial<CalendarEvent> = {
uid: newUid,
title: event.title,
description: event.description,
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
start: event.showWithoutTime ? format(newStart, "yyyy-MM-dd") : format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
duration: event.duration,
timeZone: event.timeZone,
showWithoutTime: event.showWithoutTime,
@@ -397,6 +418,7 @@ export function EventModal({
privacy: event.privacy,
};
if (event.locations) data.locations = structuredClone(event.locations);
if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations);
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
if (event.alerts) data.alerts = structuredClone(event.alerts);
if (event.participants) data.participants = structuredClone(event.participants);
@@ -450,9 +472,9 @@ export function EventModal({
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
if (isAttendeeMode && event) {
const startD = parseISO(event.start);
const startD = getEventStartDate(event);
const durMin = parseDuration(event.duration);
const endD = new Date(startD.getTime() + durMin * 60000);
const endD = getEventEndDate(event);
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
const participants = getParticipantList(event);
@@ -524,8 +546,8 @@ export function EventModal({
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
onClick={() => handleRsvp("accepted")}
className={userCurrentStatus === "accepted"
? "bg-green-600 hover:bg-green-700 text-white dark:bg-green-500 dark:hover:bg-green-600"
: "text-green-600 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950"}
? "bg-success hover:bg-success/80 text-success-foreground"
: "text-success border-success/30 hover:bg-success/10"}
>
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
{t("participants.accepted")}
@@ -535,8 +557,8 @@ export function EventModal({
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
onClick={() => handleRsvp("tentative")}
className={userCurrentStatus === "tentative"
? "bg-amber-600 hover:bg-amber-700 text-white dark:bg-amber-500 dark:hover:bg-amber-600"
: "border border-amber-500 text-amber-600 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950"}
? "bg-warning hover:bg-warning/80 text-warning-foreground"
: "border border-warning/30 text-warning hover:bg-warning/10"}
>
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
{t("participants.tentative")}
@@ -546,8 +568,8 @@ export function EventModal({
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
onClick={() => handleRsvp("declined")}
className={userCurrentStatus === "declined"
? "bg-red-600 hover:bg-red-700 text-white dark:bg-red-500 dark:hover:bg-red-600"
: "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"}
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
: "text-destructive hover:bg-destructive/10"}
>
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
{t("participants.declined")}
@@ -561,9 +583,9 @@ export function EventModal({
// View mode: read-only display of event details with Edit button
if (mode === "view" && event) {
const startD = parseISO(event.start);
const startD = getEventStartDate(event);
const durMin = parseDuration(event.duration);
const endD = new Date(startD.getTime() + durMin * 60000);
const endD = getEventEndDate(event);
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
const viewParticipants = getParticipantList(event);
@@ -695,8 +717,8 @@ export function EventModal({
{onDelete && (
showDeleteConfirm ? (
<div className="flex items-center gap-2">
<span className="text-sm text-red-600 dark:text-red-400">{t("form.delete_confirm")}</span>
<Button variant="outline" size="sm" onClick={() => { onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700">
<span className="text-sm text-destructive">{t("form.delete_confirm")}</span>
<Button variant="outline" size="sm" onClick={() => { onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-destructive border-destructive/30">
{t("events.delete")}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
@@ -704,7 +726,7 @@ export function EventModal({
</Button>
</div>
) : (
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-red-600 dark:text-red-400">
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-destructive">
<Trash2 className="w-4 h-4 mr-1" />
{t("events.delete")}
</Button>
@@ -774,6 +796,39 @@ export function EventModal({
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">
<span className="flex items-center gap-1.5">
<Video className="w-4 h-4" />
{t("form.meeting_link")}
</span>
</label>
<Input
type="url"
value={virtualLocation}
onChange={(e) => setVirtualLocation(e.target.value)}
placeholder="https://meet.example.com/..."
maxLength={2000}
/>
<PluginSlot
name="calendar-event-actions"
className="mt-2 flex flex-wrap gap-2"
extraProps={{
eventData: {
title,
description,
start: startDate + 'T' + startTime,
end: endDate + 'T' + endTime,
isAllDay: allDay,
location,
virtualLocation,
calendarId,
},
setVirtualLocation,
}}
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">
<span className="flex items-center gap-1.5">
@@ -991,9 +1046,9 @@ function StatusBadge({ status, isOrganizer, t }: {
return <span className="text-xs text-primary">{t("participants.organizer")}</span>;
}
const colors: Record<string, string> = {
accepted: "text-green-600 dark:text-green-400",
declined: "text-red-600 dark:text-red-400",
tentative: "text-amber-600 dark:text-amber-400",
accepted: "text-success",
declined: "text-destructive",
tentative: "text-warning",
"needs-action": "text-muted-foreground",
};
const labels: Record<string, string> = {
+4 -3
View File
@@ -7,6 +7,7 @@ import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
import { format, parseISO } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { getEventStartDate } from "@/lib/calendar-utils";
import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
@@ -17,7 +18,7 @@ interface ICalImportModalProps {
onClose: () => void;
}
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
type ImportStep = "select" | "preview" | "importing";
@@ -195,7 +196,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
const formatEventDate = (event: Partial<CalendarEvent>): string => {
if (!event.start) return "";
try {
const date = parseISO(event.start);
const date = getEventStartDate(event as CalendarEvent);
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
return event.showWithoutTime
? format(date, "MMM d, yyyy")
@@ -349,7 +350,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
)}
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 rounded-md px-3 py-2">
<div className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
{error}
</div>
)}
+29 -14
View File
@@ -5,24 +5,28 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Loader2, Globe } from "lucide-react";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { useCalendarStore } from "@/stores/calendar-store";
import { useCalendarStore, type ICalSubscription } from "@/stores/calendar-store";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { toast } from "@/stores/toast-store";
interface ICalSubscriptionModalProps {
client: IJMAPClient;
onClose: () => void;
editSubscription?: ICalSubscription;
}
export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) {
export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) {
const t = useTranslations("calendar.subscription");
const tCommon = useTranslations("common");
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
const updateICalSubscription = useCalendarStore((s) => s.updateICalSubscription);
const [url, setUrl] = useState("");
const [name, setName] = useState("");
const [color, setColor] = useState("#3b82f6");
const [refreshInterval, setRefreshInterval] = useState(60);
const isEdit = !!editSubscription;
const [url, setUrl] = useState(editSubscription?.url || "");
const [name, setName] = useState(editSubscription?.name || "");
const [color, setColor] = useState(editSubscription?.color || "#3b82f6");
const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
@@ -49,15 +53,26 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
setIsSubmitting(true);
try {
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
if (subscription) {
toast.success(t("success", { name: name.trim() }));
if (isEdit && editSubscription) {
const updates: { url?: string; name?: string; color?: string; refreshInterval?: number } = {};
if (trimmedUrl !== editSubscription.url) updates.url = trimmedUrl;
if (name.trim() !== editSubscription.name) updates.name = name.trim();
if (color !== editSubscription.color) updates.color = color;
if (refreshInterval !== editSubscription.refreshInterval) updates.refreshInterval = refreshInterval;
await updateICalSubscription(client, editSubscription.id, updates);
toast.success(t("updated", { name: name.trim() }));
onClose();
} else {
setError(t("error"));
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
if (subscription) {
toast.success(t("success", { name: name.trim() }));
onClose();
} else {
setError(t("error"));
}
}
} catch {
setError(t("error"));
setError(isEdit ? t("update_error") : t("error"));
} finally {
setIsSubmitting(false);
}
@@ -108,7 +123,7 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t("title")}</h2>
<h2 className="text-lg font-semibold">{isEdit ? t("edit_title") : t("title")}</h2>
</div>
<button
onClick={onClose}
@@ -192,10 +207,10 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
{t("subscribing")}
{isEdit ? t("saving") : t("subscribing")}
</>
) : (
t("subscribe")
isEdit ? t("save") : t("subscribe")
)}
</Button>
</div>
+9 -1
View File
@@ -10,6 +10,7 @@ import {
isSameDay, isSameMonth, isToday, format,
} from "date-fns";
import { cn } from "@/lib/utils";
import { getEventDayBounds } from "@/lib/calendar-utils";
import type { CalendarEvent } from "@/lib/jmap/types";
type PickerView = "days" | "months" | "years";
@@ -54,7 +55,14 @@ export function MiniCalendar({
const eventDates = useMemo(() => {
const set = new Set<string>();
events.forEach(e => {
try { set.add(format(new Date(e.start), "yyyy-MM-dd")); } catch { /* skip */ }
try {
const { startDay, endDay } = getEventDayBounds(e);
const cursor = new Date(startDay);
while (cursor <= endDay) {
set.add(format(cursor, "yyyy-MM-dd"));
cursor.setDate(cursor.getDate() + 1);
}
} catch { /* skip */ }
});
return set;
}, [events]);
+2 -2
View File
@@ -3,7 +3,7 @@
import { useMemo, useCallback, useState } from "react";
import { useTranslations } from "next-intl";
import { format, parseISO, isPast, isToday, isTomorrow } from "date-fns";
import { Check, Circle, Flag, CalendarDays, ListTodo, Plus } from "lucide-react";
import { Check, Flag, CalendarDays, ListTodo, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import type { CalendarTask, Calendar } from "@/lib/jmap/types";
import type { TaskViewFilter } from "@/stores/task-store";
@@ -204,7 +204,7 @@ export function TaskListView({
className={cn(
"mt-0.5 flex-shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors",
isCompleted
? "bg-green-500 border-green-500 text-white"
? "bg-success border-success text-success-foreground"
: "border-muted-foreground/40 hover:border-primary"
)}
aria-label={isCompleted ? t("tasks.mark_incomplete") : t("tasks.mark_complete")}
+3 -3
View File
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Trash2, CalendarDays, Bell, Flag } from "lucide-react";
import { format, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarTask, Calendar, CalendarEventAlert } from "@/lib/jmap/types";
interface TaskModalProps {
@@ -43,7 +43,7 @@ export function TaskModal({
onSave,
onDelete,
onClose,
isMobile,
isMobile: _isMobile,
}: TaskModalProps) {
const t = useTranslations("calendar");
const isEdit = !!task;
@@ -97,7 +97,7 @@ export function TaskModal({
due = `${dueDate}T${dueTime}:00`;
showWithoutTime = false;
} else {
due = `${dueDate}T00:00:00`;
due = dueDate;
showWithoutTime = true;
}
}
@@ -66,7 +66,7 @@ describe('ContactForm', () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
const inputs = screen.getAllByRole('textbox');
const _inputs = screen.getAllByRole('textbox');
const givenNameInput = screen.getByPlaceholderText('given_name');
fireEvent.change(givenNameInput, { target: { value: 'Jane' } });
@@ -52,7 +52,7 @@ describe('ContactListItem', () => {
it('applies selected styling', () => {
const { container } = render(<ContactListItem contact={contact} {...baseProps} isSelected={true} />);
const div = container.firstElementChild;
expect(div?.className).toContain('bg-blue-200');
expect(div?.className).toContain('bg-selection');
});
it('shows email as display name when no name exists', () => {
@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { ContactList } from '../contact-list';
import type { ContactCard } from '@/lib/jmap/types';
@@ -22,7 +22,7 @@ const bob = makeContact({
emails: { e0: { address: 'bob@example.com' } },
});
const group = makeContact({
const _group = makeContact({
id: '3',
kind: 'group',
name: { components: [{ kind: 'given', value: 'Team' }], isOrdered: true },
+1 -1
View File
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Link, Users, Briefcase, Heart, Languages, MessageCircle, User, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download } from "lucide-react";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download } from "lucide-react";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
+5 -19
View File
@@ -304,11 +304,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
if (contact?.addressBookIds) {
const ids = Object.keys(contact.addressBookIds).filter(k => contact.addressBookIds[k]);
if (ids.length > 0) {
// For shared contacts, the addressBookIds uses the original (non-namespaced) id
// but we need the namespaced id to match addressBooks entries
if (contact.isShared && contact.accountId) {
return `${contact.accountId}:${ids[0]}`;
}
// addressBookIds are already namespaced for shared contacts (e.g. "accountId:bookId")
// so we can use them directly to match addressBook entries
return ids[0];
}
}
@@ -914,7 +911,6 @@ function CategoryComboBox({
}) {
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Parse current keywords from comma-separated string
@@ -949,17 +945,6 @@ function CategoryComboBox({
onChange(next);
}, [currentKeywords, onChange]);
// Close dropdown on outside click
useEffect(() => {
if (!isOpen) return;
const handler = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [isOpen]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
@@ -973,7 +958,7 @@ function CategoryComboBox({
};
return (
<div ref={wrapperRef} className="relative">
<div className="relative">
{/* Keyword badges */}
{currentKeywords.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
@@ -1001,6 +986,7 @@ function CategoryComboBox({
value={inputValue}
onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }}
onFocus={() => setIsOpen(true)}
onBlur={() => setIsOpen(false)}
onKeyDown={handleKeyDown}
placeholder={currentKeywords.length === 0 ? placeholder : ""}
/>
@@ -1008,7 +994,7 @@ function CategoryComboBox({
{/* Dropdown */}
{isOpen && (suggestions.length > 0 || canAddNew) && (
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1">
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1" onMouseDown={(e) => e.preventDefault()}>
{suggestions.map(kw => (
<button
key={kw}
@@ -205,7 +205,7 @@ export function ContactImportDialog({
)}
</div>
{isDupe && (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-400 flex-shrink-0">
<span className="text-xs px-1.5 py-0.5 rounded bg-warning/15 text-warning flex-shrink-0">
{t("import.duplicate")}
</span>
)}
+2 -2
View File
@@ -59,9 +59,9 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
className={cn(
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
isSelected
? "bg-blue-200 dark:bg-blue-900/50 shadow-sm"
? "bg-selection shadow-sm"
: "bg-background hover:bg-muted hover:shadow-sm",
isChecked && !isSelected && "ring-2 ring-primary/20 bg-blue-100 dark:bg-blue-900/30",
isChecked && !isSelected && "ring-2 ring-primary/20 bg-selection/60",
)}
style={{ gap: 'var(--density-item-gap)', paddingInline: '16px', paddingBlock: 'var(--density-item-py)' }}
>
+111 -28
View File
@@ -2,7 +2,8 @@
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2 } from "lucide-react";
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
@@ -25,6 +26,8 @@ interface ContactsSidebarProps {
onDeleteGroup?: (groupId: string) => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
onRenameAddressBook?: (addressBook: AddressBook) => void;
onRenameKeyword?: (keyword: string) => void;
className?: string;
}
@@ -58,10 +61,15 @@ export function ContactsSidebar({
onDeleteGroup,
onDropContacts,
onDropContactsToCategory,
onRenameAddressBook,
onRenameKeyword,
className,
}: ContactsSidebarProps) {
const t = useTranslations("contacts");
const router = useRouter();
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
const { contextMenu: bookContextMenu, openContextMenu: openBookContextMenu, closeContextMenu: closeBookContextMenu, menuRef: bookMenuRef } = useContextMenu<AddressBook>();
const { contextMenu: keywordContextMenu, openContextMenu: openKeywordContextMenu, closeContextMenu: closeKeywordContextMenu, menuRef: keywordMenuRef } = useContextMenu<string>();
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
const [showMenu, setShowMenu] = useState(false);
@@ -246,19 +254,32 @@ export function ContactsSidebar({
{/* My Address Books */}
{personalBooks.length > 0 && (
<div className="mt-2">
<button
onClick={() => toggleSection("addressBooks")}
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
>
{collapsed.addressBooks ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
)}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t("address_books.title")}
</span>
</button>
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection("addressBooks")}
className="flex items-center gap-1 flex-1 text-left"
>
{collapsed.addressBooks ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
)}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t("address_books.title")}
</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
title={t("address_books.manage")}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
</div>
{!collapsed.addressBooks && personalBooks.map((book) => (
<AddressBookItem
key={book.id}
@@ -267,6 +288,7 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
@@ -362,6 +384,7 @@ export function ContactsSidebar({
isActive={isActive}
onSelect={() => onSelectCategory({ keyword })}
onDropContacts={onDropContactsToCategory}
onContextMenu={onRenameKeyword ? (e) => openKeywordContextMenu(e, keyword) : undefined}
/>
);
})}
@@ -372,20 +395,33 @@ export function ContactsSidebar({
{/* Shared accounts with address books */}
{sharedBookGroups.map((group) => (
<div key={group.accountId} className="mt-2">
<button
onClick={() => toggleSection(`shared-${group.accountId}`)}
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
>
{collapsed[`shared-${group.accountId}`] ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
)}
<Share2 className="w-3 h-3 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
{t("address_books.shared_prefix", { name: group.accountName })}
</span>
</button>
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection(`shared-${group.accountId}`)}
className="flex items-center gap-1 flex-1 min-w-0 text-left"
>
{collapsed[`shared-${group.accountId}`] ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
)}
<Share2 className="w-3 h-3 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
{t("address_books.shared_prefix", { name: group.accountName })}
</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
title={t("address_books.manage")}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
</div>
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
<AddressBookItem
key={book.id}
@@ -394,12 +430,53 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
))}
</div>
{/* Address book context menu */}
{bookContextMenu.data && onRenameAddressBook && (
<ContextMenu
ref={bookMenuRef}
isOpen={bookContextMenu.isOpen}
position={bookContextMenu.position}
onClose={closeBookContextMenu}
>
<ContextMenuItem
icon={Pencil}
label={t("address_books.rename")}
onClick={() => {
const book = bookContextMenu.data!;
closeBookContextMenu();
onRenameAddressBook(book);
}}
/>
</ContextMenu>
)}
{/* Keyword (category) context menu */}
{keywordContextMenu.data && onRenameKeyword && (
<ContextMenu
ref={keywordMenuRef}
isOpen={keywordContextMenu.isOpen}
position={keywordContextMenu.position}
onClose={closeKeywordContextMenu}
>
<ContextMenuItem
icon={Pencil}
label={t("rename_category")}
onClick={() => {
const kw = keywordContextMenu.data!;
closeKeywordContextMenu();
onRenameKeyword(kw);
}}
/>
</ContextMenu>
)}
{/* Group context menu */}
{groupContextMenu.data && (
<ContextMenu
@@ -438,12 +515,14 @@ function CategoryItem({
isActive,
onSelect,
onDropContacts,
onContextMenu,
}: {
keyword: string;
count: number;
isActive: boolean;
onSelect: () => void;
onDropContacts?: (contactIds: string[], keyword: string) => void;
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) {
const [isDragOver, setIsDragOver] = useState(false);
@@ -476,6 +555,7 @@ function CategoryItem({
return (
<button
onClick={onSelect}
onContextMenu={onContextMenu}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -503,12 +583,14 @@ function AddressBookItem({
contactCount,
onSelect,
onDropContacts,
onContextMenu,
}: {
book: AddressBook;
isActive: boolean;
contactCount: number;
onSelect: () => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) {
const [isDragOver, setIsDragOver] = useState(false);
@@ -541,6 +623,7 @@ function AddressBookItem({
return (
<button
onClick={onSelect}
onContextMenu={onContextMenu}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -38,6 +38,7 @@ describe('EmailListItem tag badge', () => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
@@ -104,4 +105,17 @@ describe('EmailListItem tag badge', () => {
render(<EmailListItem email={email} />);
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
it('renders inline preview text in focused mail layout', () => {
useSettingsStore.setState({
showPreview: true,
mailLayout: 'focus',
});
const email = makeEmail({ preview: 'Inline preview content' });
const { container } = render(<EmailListItem email={email} />);
expect(screen.getByText('Test Subject')).toBeInTheDocument();
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
expect(container.querySelector('p')).toBeNull();
});
});
+78 -77
View File
@@ -171,11 +171,11 @@ function getParticipationLabel(
function getParticipationTone(status: string | null): string {
switch (status) {
case 'accepted':
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
return 'bg-success/15 text-success';
case 'tentative':
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
return 'bg-warning/15 text-warning';
case 'declined':
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
return 'bg-destructive/15 text-destructive';
default:
return 'bg-muted text-muted-foreground';
}
@@ -731,9 +731,9 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
if (state === 'error') {
return (
<div className="rounded-lg border border-red-200 dark:border-red-900/50 bg-red-50/50 dark:bg-red-950/20 px-4 py-3 flex items-center gap-2.5">
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
<span className="text-sm text-red-600 dark:text-red-400">{t('parse_error')}</span>
<div className="rounded-lg border border-destructive/20 bg-destructive/10 px-4 py-3 flex items-center gap-2.5">
<AlertCircle className="w-4 h-4 text-destructive flex-shrink-0" />
<span className="text-sm text-destructive">{t('parse_error')}</span>
</div>
);
}
@@ -744,7 +744,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b border-border">
<div className="flex items-center gap-2 min-w-0">
{isCancellation ? (
<CalendarX className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
<CalendarX className="w-4 h-4 text-destructive flex-shrink-0" />
) : (
<Calendar className="w-4 h-4 text-primary flex-shrink-0" />
)}
@@ -754,49 +754,67 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
{/* Content */}
<div className="px-4 py-3 space-y-2.5">
{/* Event title */}
{summary?.title && (
<div className="flex items-start justify-between gap-2">
<h3 className={cn(
"text-base font-semibold leading-snug",
isCancellation ? "line-through text-muted-foreground" : "text-foreground"
)}>
{summary.title}
</h3>
{parsedEvent?.sequence != null && parsedEvent.sequence > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground flex-shrink-0 whitespace-nowrap">
{t('event_updated', { sequence: parsedEvent.sequence })}
</span>
<div className="lg:flex lg:gap-6">
{/* Left: Event info */}
<div className="lg:flex-1 space-y-2.5 min-w-0">
{/* Event title */}
{summary?.title && (
<div className="flex items-start justify-between gap-2">
<h3 className={cn(
"text-base font-semibold leading-snug",
isCancellation ? "line-through text-muted-foreground" : "text-foreground"
)}>
{summary.title}
</h3>
{parsedEvent?.sequence != null && parsedEvent.sequence > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground flex-shrink-0 whitespace-nowrap">
{t('event_updated', { sequence: parsedEvent.sequence })}
</span>
)}
</div>
)}
</div>
)}
{/* Event details */}
<div className="space-y-1">
{summary?.start && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
<span>
{formatDateTime(summary.start)}
{summary.end && ` ${formatDateTime(summary.end)}`}
</span>
{/* Event details */}
<div className="lg:flex lg:items-center lg:gap-4 lg:flex-wrap space-y-1 lg:space-y-0">
{summary?.start && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
<span>
{formatDateTime(summary.start)}
{summary.end && ` ${formatDateTime(summary.end)}`}
</span>
</div>
)}
{summary?.location && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
<span>{summary.location}</span>
</div>
)}
{summary?.organizer && (
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Users className="w-3.5 h-3.5 flex-shrink-0" />
{t('organizer', { name: summary.organizer })}
</span>
)}
{summary && summary.attendeeCount > 0 && (
<span className="text-sm text-muted-foreground">{t('attendees', { count: summary.attendeeCount })}</span>
)}
</div>
)}
{summary?.location && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
<span>{summary.location}</span>
</div>
)}
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
{summary?.organizer && (
<span className="flex items-center gap-1.5">
<Users className="w-3.5 h-3.5 flex-shrink-0" />
{t('organizer', { name: summary.organizer })}
</span>
</div>
{/* Right: Info & actor messages on large screens */}
<div className="lg:flex-shrink-0 lg:text-right lg:max-w-xs mt-2.5 lg:mt-0 space-y-1">
{bannerInfo && (
<p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
)}
{summary && summary.attendeeCount > 0 && (
<span>{t('attendees', { count: summary.attendeeCount })}</span>
{actorMessage && (
<p className="text-xs text-muted-foreground">{actorMessage}</p>
)}
{actorSummary?.participationComment && (
<p className="text-xs text-muted-foreground italic">
{t('actor_note', { comment: actorSummary.participationComment })}
</p>
)}
</div>
</div>
@@ -808,19 +826,19 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
<span className={cn(
"rounded-full px-2 py-0.5 text-[11px] font-medium",
parsedEvent.status === 'cancelled'
? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
? "bg-destructive/15 text-destructive"
: "bg-warning/15 text-warning"
)}>
{t(`event_status_${parsedEvent.status}`)}
</span>
)}
{existingEvent && (
<span className="rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400">
<span className="rounded-full bg-success/15 px-2 py-0.5 text-[11px] font-medium text-success">
{t('already_in_calendar')}
</span>
)}
{userIsOrganizer && (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
<span className="rounded-full bg-info/15 px-2 py-0.5 text-[11px] font-medium text-info">
{t('organizer_role')}
</span>
)}
@@ -833,30 +851,13 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
</span>
)}
{actionFeedback && (
<span className="rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400">
<span className="rounded-full bg-success/15 px-2 py-0.5 text-[11px] font-medium text-success">
{actionFeedback}
</span>
)}
</div>
)}
{/* Info text */}
{bannerInfo && (
<p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
)}
{/* Actor message */}
{actorMessage && (
<p className="text-xs text-muted-foreground">{actorMessage}</p>
)}
{/* Actor comment */}
{actorSummary?.participationComment && (
<p className="text-xs text-muted-foreground italic">
{t('actor_note', { comment: actorSummary.participationComment })}
</p>
)}
{/* Proposed changes */}
{proposedChanges.length > 0 && (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs">
@@ -877,8 +878,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
<div className={cn(
'flex items-start gap-1.5 text-xs rounded-md px-3 py-2',
trustAssessment.level === 'warning'
? 'bg-red-50 dark:bg-red-950/20 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-900/50'
: 'bg-amber-50 dark:bg-amber-950/20 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-900/50'
? 'bg-destructive/10 text-destructive border border-destructive/20'
: 'bg-warning/10 text-warning border border-warning/20'
)}>
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{trustMessage}</span>
@@ -887,7 +888,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
{/* Action error */}
{actionError && (
<div className="flex items-start gap-1.5 text-xs text-red-600 dark:text-red-400 rounded-md px-3 py-2 bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900/50">
<div className="flex items-start gap-1.5 text-xs text-destructive rounded-md px-3 py-2 bg-destructive/10 border border-destructive/20">
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{actionError}</span>
</div>
@@ -905,8 +906,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
className={cn(
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
currentRsvp === 'accepted'
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 border-green-200 dark:border-green-800"
: "text-muted-foreground hover:text-green-700 dark:hover:text-green-400 border-border hover:border-green-300 dark:hover:border-green-800 hover:bg-green-50 dark:hover:bg-green-950/20"
? "bg-success/15 text-success border-success/20"
: "text-muted-foreground hover:text-success border-border hover:border-success/30 hover:bg-success/10"
)}
>
<Check className="w-3.5 h-3.5" />
@@ -919,8 +920,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
className={cn(
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
currentRsvp === 'tentative'
? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 border-amber-200 dark:border-amber-800"
: "text-muted-foreground hover:text-amber-700 dark:hover:text-amber-400 border-border hover:border-amber-300 dark:hover:border-amber-800 hover:bg-amber-50 dark:hover:bg-amber-950/20"
? "bg-warning/15 text-warning border-warning/20"
: "text-muted-foreground hover:text-warning border-border hover:border-warning/30 hover:bg-warning/10"
)}
>
<HelpCircle className="w-3.5 h-3.5" />
@@ -933,8 +934,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
className={cn(
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
currentRsvp === 'declined'
? "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 border-red-200 dark:border-red-800"
: "text-muted-foreground hover:text-red-700 dark:hover:text-red-400 border-border hover:border-red-300 dark:hover:border-red-800 hover:bg-red-50 dark:hover:bg-red-950/20"
? "bg-destructive/15 text-destructive border-destructive/20"
: "text-muted-foreground hover:text-destructive border-border hover:border-destructive/30 hover:bg-destructive/10"
)}
>
<X className="w-3.5 h-3.5" />
+247 -56
View File
@@ -18,6 +18,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builder";
import type { MimeAttachment } from "@/lib/smime/mime-builder";
import { smimeSign } from "@/lib/smime/smime-sign";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
import { useContactStore } from "@/stores/contact-store";
import { useTemplateStore } from "@/stores/template-store";
@@ -28,13 +29,13 @@ import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
import { findReplyIdentityId } from "@/lib/reply-identity";
import { RichTextEditor } from "@/components/email/rich-text-editor";
/** Strip HTML tags and decode entities to get a plain-text version */
function htmlToPlainText(html: string): string {
const tmp = document.createElement('div');
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || '';
const doc = new DOMParser().parseFromString(html, 'text/html');
return doc.body.textContent || '';
}
export interface ComposerDraftData {
@@ -75,8 +76,10 @@ interface EmailComposerProps {
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: {
from?: { email?: string; name?: string }[];
replyToAddresses?: { email?: string; name?: string }[];
to?: { email?: string; name?: string }[];
cc?: { email?: string; name?: string }[];
bcc?: { email?: string; name?: string }[];
subject?: string;
body?: string;
htmlBody?: string;
@@ -98,17 +101,23 @@ export function EmailComposer({
const t = useTranslations('email_composer');
const tCommon = useTranslations('common');
const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
// Initialize with reply/forward data if provided
const getInitialTo = () => {
if (!replyTo) return "";
// RFC 5322: use Reply-To header if present, otherwise fall back to From
const replyTarget = replyTo.replyToAddresses?.length
? replyTo.replyToAddresses.filter(r => r.email).map(r => r.email).join(", ")
: replyTo.from?.[0]?.email || "";
if (mode === 'reply') {
const email = replyTo.from?.[0]?.email || "";
return email ? email + ', ' : "";
return replyTarget ? replyTarget + ', ' : "";
} else if (mode === 'replyAll') {
const from = replyTo.from?.[0]?.email || "";
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
const combined = [from, originalTo].filter(Boolean).join(", ");
const combined = [replyTarget, originalTo].filter(Boolean).join(", ");
return combined ? combined + ', ' : "";
}
return "";
@@ -133,6 +142,26 @@ export function EmailComposer({
};
const getInitialBody = () => {
if (plainTextMode) {
// Plain text mode: produce plain text body with no HTML
const prefix = initialDraftText || "";
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : '');
const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n');
if (mode === 'forward') {
return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
} else if (mode === 'reply' || mode === 'replyAll') {
return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`;
}
return prefix;
}
const prefix = initialDraftText ? `<p>${initialDraftText.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</p>` : "";
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
@@ -185,6 +214,8 @@ export function EmailComposer({
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
const [smimePassphraseInput, setSmimePassphraseInput] = useState('');
const [smimePassphraseError, setSmimePassphraseError] = useState('');
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
const saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate,
@@ -198,12 +229,43 @@ export function EmailComposer({
restoreFocus: true,
});
const attachmentWarningRef = useFocusTrap({
isActive: showAttachmentWarning,
onEscape: () => setShowAttachmentWarning(false),
restoreFocus: true,
});
const { client } = useAuthStore();
const identities = useIdentityStore((s) => s.identities);
const primaryIdentity = identities[0] ?? null;
const currentIdentity = selectedIdentityId
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
: primaryIdentity;
useEffect(() => {
if (!autoSelectReplyIdentity) return;
if (selectedIdentityId || initialData?.selectedIdentityId) return;
if (mode !== 'reply' && mode !== 'replyAll') return;
const matchedIdentityId = findReplyIdentityId(identities, {
to: replyTo?.to,
cc: replyTo?.cc,
bcc: replyTo?.bcc,
});
if (matchedIdentityId) {
setSelectedIdentityId(matchedIdentityId);
}
}, [
autoSelectReplyIdentity,
identities,
initialData?.selectedIdentityId,
mode,
replyTo?.bcc,
replyTo?.cc,
replyTo?.to,
selectedIdentityId,
]);
const composerSignatureHtml = currentIdentity?.htmlSignature
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
: currentIdentity?.textSignature
@@ -279,6 +341,17 @@ export function EmailComposer({
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, []);
// Auto-focus the To field when composing a new email or forwarding
useEffect(() => {
if (mode === 'forward' || mode === 'compose') {
// Small delay to ensure the input is rendered
const timer = setTimeout(() => {
toInputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}
}, [mode]);
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
@@ -286,10 +359,26 @@ export function EmailComposer({
const toInputRef = useRef<HTMLInputElement>(null);
const ccInputRef = useRef<HTMLInputElement>(null);
const bccInputRef = useRef<HTMLInputElement>(null);
const subjectInputRef = useRef<HTMLInputElement>(null);
const bodyRef = useRef<HTMLTextAreaElement>(null);
const editorContainerRef = useRef<HTMLDivElement>(null);
const toDropdownRef = useRef<HTMLDivElement>(null);
const ccDropdownRef = useRef<HTMLDivElement>(null);
const bccDropdownRef = useRef<HTMLDivElement>(null);
const focusSubject = useCallback(() => {
subjectInputRef.current?.focus();
}, []);
const focusBody = useCallback(() => {
if (plainTextMode) {
bodyRef.current?.focus();
} else {
const proseMirror = editorContainerRef.current?.querySelector('.ProseMirror') as HTMLElement | null;
proseMirror?.focus();
}
}, [plainTextMode]);
const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
if (autocompleteTimeoutRef.current) {
clearTimeout(autocompleteTimeoutRef.current);
@@ -368,12 +457,14 @@ export function EmailComposer({
? substitutePlaceholders(template.body, filledValues)
: template.body;
// Convert template plain text body to HTML for the rich text editor
const htmlBody = `<p>${filledBody.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</p>`;
// In plain text mode, use template body as-is; otherwise convert to HTML
const bodyContent = plainTextMode
? filledBody
: `<p>${filledBody.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</p>`;
if (mode === 'compose') {
setSubject(filledSubject);
setBody(htmlBody);
setBody(bodyContent);
if (template.defaultRecipients?.to?.length) {
setTo(template.defaultRecipients.to.join(', ') + ', ');
}
@@ -386,7 +477,7 @@ export function EmailComposer({
setShowBcc(true);
}
} else {
setBody((prev) => htmlBody + prev);
setBody((prev) => bodyContent + (plainTextMode ? '\n' : '') + prev);
}
if (template.identityId) {
@@ -394,7 +485,7 @@ export function EmailComposer({
}
setShowTemplatePicker(false);
}, [mode]);
}, [mode, plainTextMode]);
useEffect(() => {
const handleTemplateKey = (e: KeyboardEvent) => {
@@ -451,17 +542,18 @@ export function EmailComposer({
}
}, [client, t]);
const handleImageUpload = useCallback(async (file: File): Promise<string | null> => {
if (!client) return null;
try {
const { blobId } = await client.uploadBlob(file);
return await client.fetchBlobAsObjectUrl(blobId, file.name, file.type);
} catch (error) {
debug.error(`Failed to upload inline image ${file.name}:`, error);
toast.error(t('upload_failed', { filename: file.name }));
return null;
}
}, [client, t]);
const handleImageUpload = useCallback((file: File): Promise<string | null> => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
reader.onerror = () => {
debug.error(`Failed to read inline image ${file.name}`);
toast.error(t('upload_failed', { filename: file.name }));
resolve(null);
};
reader.readAsDataURL(file);
});
}, [t]);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files) return;
@@ -529,7 +621,7 @@ export function EmailComposer({
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
if (!toAddresses.length && !subject && !htmlToPlainText(body).trim()) {
if (!toAddresses.length && !subject && !(plainTextMode ? body.trim() : htmlToPlainText(body).trim())) {
return null;
}
@@ -565,7 +657,7 @@ export function EmailComposer({
const savedDraftId = await client.createDraft(
toAddresses,
subject || t('no_subject'),
htmlToPlainText(body),
plainTextMode ? body : htmlToPlainText(body),
ccAddresses,
bccAddresses,
currentIdentity?.id,
@@ -629,7 +721,7 @@ export function EmailComposer({
}, []);
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
const bodyPlainText = htmlToPlainText(body).trim();
const bodyPlainText = plainTextMode ? body.trim() : htmlToPlainText(body).trim();
const hasContent = bodyPlainText || attachments.some(att => att.blobId && !att.uploading);
const canSend = toAddresses.length > 0 && !!subject && hasContent;
@@ -641,7 +733,7 @@ export function EmailComposer({
return undefined;
};
const handleSend = async () => {
const handleSend = async (skipAttachmentCheck = false) => {
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
@@ -660,6 +752,21 @@ export function EmailComposer({
return;
}
// Attachment reminder check
if (!skipAttachmentCheck && attachmentReminderEnabled) {
const hasAttachments = attachments.some(att => att.blobId && !att.uploading && !att.error);
if (!hasAttachments) {
const bodyText = htmlToPlainText(body);
const searchText = `${subject} ${bodyText}`.toLowerCase();
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
if (matched) {
setAttachmentWarningKeyword(matched);
setShowAttachmentWarning(true);
return;
}
}
}
let finalDraftId = draftId;
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
@@ -679,8 +786,8 @@ export function EmailComposer({
: currentIdentity.email
: undefined;
// Body is already HTML from the rich text editor.
// Build HTML signature block
// Body is already HTML from the rich text editor (or plain text in plain text mode).
// Build HTML signature block (used only in rich text mode)
const buildSignatureHtml = (): string => {
if (currentIdentity?.htmlSignature) {
return `<br><br>-- <br>${sanitizeEmailHtml(currentIdentity.htmlSignature)}`;
@@ -691,13 +798,14 @@ export function EmailComposer({
return '';
};
const signatureHtml = buildSignatureHtml();
// In plain text mode, send text/plain only (no HTML body)
const finalBody = plainTextMode
? appendPlainTextSignature(body, currentIdentity)
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
// Build final HTML body: editor content + signature
const finalHtmlBody = `<div>${body}</div>${signatureHtml}`;
// Generate plain text version from the HTML body for multipart/alternative
const finalBody = appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
const finalHtmlBody = plainTextMode
? undefined
: `<div>${body}</div>${buildSignatureHtml()}`;
try {
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
@@ -919,7 +1027,7 @@ export function EmailComposer({
</div>
{/* Mobile: send button in header */}
<Button
onClick={handleSend}
onClick={() => handleSend()}
disabled={!canSend}
title={getSendTooltip()}
size="sm"
@@ -943,11 +1051,16 @@ export function EmailComposer({
onChange={(e) => setSelectedIdentityId(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
>
{identities.map((identity) => (
<option key={identity.id} value={identity.id}>
{identity.name ? `${identity.name} <${identity.email}>` : identity.email}
</option>
))}
{identities.map((identity) => {
const displayEmail = subAddressTag
? generateSubAddress(identity.email, subAddressTag)
: identity.email;
return (
<option key={identity.id} value={identity.id}>
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
</option>
);
})}
</select>
) : (
<span className="text-sm text-foreground flex-1 truncate">
@@ -1010,6 +1123,7 @@ export function EmailComposer({
onInsertAutocomplete={insertAutocomplete}
validationError={validationErrors.to}
validationMessage={t('validation.recipient_required')}
onTab={focusSubject}
/>
<div className="flex gap-0.5 shrink-0">
<Button
@@ -1079,6 +1193,7 @@ export function EmailComposer({
<div className="flex items-center gap-2 px-4 py-2.5">
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
<Input
ref={subjectInputRef}
type="text"
placeholder={t('subject_placeholder')}
value={subject}
@@ -1086,6 +1201,12 @@ export function EmailComposer({
setSubject(e.target.value);
if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false }));
}}
onKeyDown={(e) => {
if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
focusBody();
}
}}
className={cn(
"flex-1 border-0 focus-visible:ring-0 h-8 px-0 text-sm",
validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400"
@@ -1095,24 +1216,50 @@ export function EmailComposer({
</div>
</div>
{/* Body - Rich Text Editor */}
<RichTextEditor
content={body}
onChange={(html) => {
setBody(html);
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
}}
onImageUpload={handleImageUpload}
placeholder={t('body_placeholder')}
hasError={validationErrors.body}
/>
{/* Body */}
{plainTextMode ? (
<textarea
ref={bodyRef}
value={body}
onChange={(e) => {
setBody(e.target.value);
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
}}
placeholder={t('body_placeholder')}
className={cn(
"w-full min-h-[300px] px-4 py-3 text-sm text-foreground bg-transparent resize-y focus:outline-none font-mono",
validationErrors.body && "ring-2 ring-red-500 dark:ring-red-400 rounded"
)}
style={{ height: 'calc(100vh - 350px)' }}
aria-invalid={validationErrors.body || undefined}
/>
) : (
<div ref={editorContainerRef}>
<RichTextEditor
content={body}
onChange={(html) => {
setBody(html);
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
}}
onImageUpload={handleImageUpload}
placeholder={t('body_placeholder')}
hasError={validationErrors.body}
/>
</div>
)}
{composerSignatureHtml && (
{plainTextMode ? (
getPlainTextSignature(currentIdentity) ? (
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
{'-- \n'}{getPlainTextSignature(currentIdentity)}
</div>
) : null
) : composerSignatureHtml ? (
<div
className="px-4 pb-3 text-sm leading-6 text-foreground break-words [&_a]:text-primary [&_a]:underline-offset-2 [&_a:hover]:underline"
dangerouslySetInnerHTML={{ __html: `<div>-- </div>${composerSignatureHtml}` }}
/>
)}
) : null}
</div>
{/* Attachments */}
@@ -1232,6 +1379,7 @@ export function EmailComposer({
</Button>
</>
)}
<PluginSlot name="composer-toolbar" />
</div>
{/* Right side - Discard + Send (desktop) */}
@@ -1244,7 +1392,7 @@ export function EmailComposer({
{t('discard')}
</button>
<Button
onClick={handleSend}
onClick={() => handleSend()}
disabled={!canSend}
title={getSendTooltip()}
className="hidden md:inline-flex"
@@ -1344,6 +1492,36 @@ export function EmailComposer({
</div>
)}
{showAttachmentWarning && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
onClick={() => setShowAttachmentWarning(false)}
>
<div
ref={attachmentWarningRef}
role="alertdialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
>
<div className="p-6">
<h2 className="text-lg font-semibold text-foreground">{t('forgot_attachment.title')}</h2>
<p className="mt-2 text-sm text-muted-foreground">
{t('forgot_attachment.message', { keyword: attachmentWarningKeyword })}
</p>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
{t('forgot_attachment.back')}
</Button>
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true); }}>
{t('forgot_attachment.send_anyway')}
</Button>
</div>
</div>
</div>
)}
{showCloseDialog && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
@@ -1429,6 +1607,7 @@ function RecipientChipInput({
onInsertAutocomplete,
validationError,
validationMessage,
onTab,
}: {
value: string;
onChange: (value: string) => void;
@@ -1445,6 +1624,7 @@ function RecipientChipInput({
onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void;
validationError?: boolean;
validationMessage?: string;
onTab?: () => void;
}) {
const allParts = value.split(',').map(s => s.trim()).filter(Boolean);
const hasTrailingComma = value.trimEnd().endsWith(',');
@@ -1478,7 +1658,18 @@ function RecipientChipInput({
if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) {
if (e.key !== 'Tab') e.preventDefault();
commitCurrentInput();
setTimeout(() => inputRef.current?.focus(), 0);
if (e.key === 'Tab' && onTab) {
e.preventDefault();
setTimeout(() => onTab(), 0);
} else {
setTimeout(() => inputRef.current?.focus(), 0);
}
return;
}
if (e.key === 'Tab' && !e.shiftKey && onTab) {
e.preventDefault();
onTab();
return;
}
+34 -27
View File
@@ -9,6 +9,7 @@ import {
ContextMenuSubMenu,
ContextMenuHeader,
} from "@/components/ui/context-menu";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import {
Reply,
ReplyAll,
@@ -88,17 +89,18 @@ const getMailboxIcon = (role?: string) => {
}
};
// Get current label/color from email keywords (supports both $label: and legacy $color:)
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
if (!keywords) return null;
// Get all active label/color tag IDs from email keywords
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
return key.startsWith("$label:")
? key.slice("$label:".length)
: key.slice("$color:".length);
tags.push(
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
);
}
}
return null;
return tags;
};
export function EmailContextMenu({
@@ -131,12 +133,12 @@ export function EmailContextMenu({
onEditDraft,
}: EmailContextMenuProps) {
const t = useTranslations("context_menu");
const tColor = useTranslations("email_viewer.color_tag");
const _tColor = useTranslations("email_viewer.color_tag");
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isDraft = email.keywords?.['$draft'] === true;
const currentColor = getCurrentColor(email.keywords);
const currentColors = getCurrentColors(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
@@ -305,24 +307,27 @@ export function EmailContextMenu({
{/* Set tag submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
{colorOptions.map((option) => (
<button
key={option.value}
role="menuitem"
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
className={cn(
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
currentColor === option.value && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="flex-1">{option.name}</span>
{currentColor === option.value && (
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
)}
</button>
))}
{currentColor && (
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
role="menuitem"
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
className={cn(
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="flex-1">{option.name}</span>
{isActive && (
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
)}
</button>
);
})}
{currentColors.length > 0 && (
<>
<ContextMenuSeparator />
<ContextMenuItem
@@ -366,6 +371,8 @@ export function EmailContextMenu({
)
}
/>
<PluginSlot name="context-menu-email" />
</ContextMenu>
);
}
+71 -33
View File
@@ -9,6 +9,7 @@ import { useTranslations } from "next-intl";
interface EmailHoverActionsProps {
email: Email;
backgroundClassName?: string;
onToggleStar?: () => void;
onMarkAsRead?: (read: boolean) => void;
onDelete?: () => void;
@@ -54,8 +55,16 @@ const ACTION_CONFIG: Record<HoverAction, {
},
};
const CORNER_CLASSES = {
'top-right': 'top-1 right-1',
'top-left': 'top-1 left-1',
'bottom-right': 'bottom-1 right-1',
'bottom-left': 'bottom-1 left-1',
} as const;
export function EmailHoverActions({
email,
backgroundClassName = "bg-muted",
onToggleStar,
onMarkAsRead,
onDelete,
@@ -64,10 +73,13 @@ export function EmailHoverActions({
onMarkAsSpam,
}: EmailHoverActionsProps) {
const hoverActions = useSettingsStore((state) => state.hoverActions);
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
const hoverActionsCorner = useSettingsStore((state) => state.hoverActionsCorner);
const t = useTranslations("settings.email_behavior.hover_actions");
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const hoverBackgroundClassName = backgroundClassName;
if (hoverActions.length === 0) return null;
@@ -82,7 +94,7 @@ export function EmailHoverActions({
onToggleStar?.();
break;
case "markRead":
onMarkAsRead?.(!isUnread);
onMarkAsRead?.(isUnread);
break;
case "archive":
onArchive?.();
@@ -96,42 +108,68 @@ export function EmailHoverActions({
}
};
const actionButtons = hoverActions.map((actionId) => {
const config = ACTION_CONFIG[actionId];
if (!config) return null;
const Icon = config.icon;
const DisplayIcon = actionId === "markRead"
? (isUnread ? MailOpen : Mail)
: actionId === "star" && isStarred
? Star
: Icon;
return (
<button
key={actionId}
onClick={(e) => handleAction(e, actionId)}
title={t(config.titleKey)}
className={cn(
"p-1.5 rounded-md transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10",
config.className,
)}
>
<DisplayIcon
className={cn(
"w-4 h-4",
actionId === "star" && isStarred && "fill-amber-400 text-amber-400",
)}
/>
</button>
);
});
if (hoverActionsMode === 'floating') {
return (
<div
className={cn(
"absolute z-10 hidden group-hover:flex items-center",
CORNER_CLASSES[hoverActionsCorner],
)}
>
<div className={cn(
"flex items-center gap-0.5 rounded-lg px-1.5 py-0.5 shadow-md border border-border",
hoverBackgroundClassName,
)}>
{actionButtons}
</div>
</div>
);
}
return (
<div
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
>
<div className="w-8 h-full bg-gradient-to-r from-transparent to-muted" />
<div className="flex items-center gap-0.5 h-full bg-muted pr-3 pl-0.5">
{hoverActions.map((actionId) => {
const config = ACTION_CONFIG[actionId];
if (!config) return null;
const Icon = config.icon;
const DisplayIcon = actionId === "markRead"
? (isUnread ? MailOpen : Mail)
: actionId === "star" && isStarred
? Star
: Icon;
return (
<button
key={actionId}
onClick={(e) => handleAction(e, actionId)}
title={t(config.titleKey)}
className={cn(
"p-1.5 rounded-md transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10",
config.className,
)}
>
<DisplayIcon
className={cn(
"w-4 h-4",
actionId === "star" && isStarred && "fill-amber-400 text-amber-400",
)}
/>
</button>
);
})}
<div
className={cn("w-8 h-full", hoverBackgroundClassName)}
style={{
WebkitMaskImage: "linear-gradient(to right, transparent, black)",
maskImage: "linear-gradient(to right, transparent, black)",
}}
/>
<div className={cn("flex items-center gap-0.5 h-full pr-3 pl-0.5", hoverBackgroundClassName)}>
{actionButtons}
</div>
</div>
);
+150 -75
View File
@@ -6,7 +6,7 @@ import { formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, CheckSquare, Square, Tag } from "lucide-react";
import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
@@ -15,7 +15,7 @@ import { useLongPress } from "@/hooks/use-long-press";
import { useUIStore } from "@/stores/ui-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { EmailHoverActions } from "./email-hover-actions";
import { getEmailColorTag } from "@/lib/thread-utils";
import { getEmailColorTags } from "@/lib/thread-utils";
interface EmailListItemProps {
email: Email;
@@ -32,20 +32,30 @@ interface EmailListItemProps {
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
const t = useTranslations('email_viewer');
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id);
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isImportant = email.keywords?.["$important"];
const sender = email.from?.[0];
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tag using keyword definitions from settings
const colorTagId = getEmailColorTag(email.keywords);
const keywordDef = colorTagId ? emailKeywords.find(k => k.id === colorTagId) : null;
// Resolve color tags using keyword definitions from settings
const colorTagIds = getEmailColorTags(email.keywords);
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
// Use first tag for background coloring
const keywordDef = keywordDefs[0] ?? null;
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
// Drag and drop functionality
@@ -85,15 +95,15 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
// Apply color tag as background, with selected and unread states
colorTag ? colorTag : (
selected
? "bg-blue-200 dark:bg-blue-900/50"
? "bg-selection"
: "bg-background"
),
selected && !colorTag && "shadow-sm",
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !selected && !colorTag && "bg-amber-50 dark:bg-amber-900/20",
isUnread && !selected && !colorTag && "bg-warning/10",
// Add visual feedback for checked state
isChecked && "ring-2 ring-primary/20 bg-blue-100 dark:bg-blue-900/30",
isChecked && "ring-2 ring-primary/20 bg-selection/60",
// Drag state visual feedback
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
// Long press visual feedback
@@ -112,15 +122,19 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
}
}}
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
<div className="flex items-start px-4" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
<div
className={cn('px-4', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
{/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && (
<button
onClick={handleCheckboxClick}
className={cn(
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
!isFocusedMailLayout && 'mt-2',
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"animate-in fade-in zoom-in-95 duration-150",
@@ -138,12 +152,12 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
{/* Unread indicator */}
{isUnread && (
<div className="absolute left-1 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
<Circle className="w-2 h-2 fill-unread text-unread" />
</div>
)}
{/* Avatar */}
{density !== 'extra-compact' && (
{!isFocusedMailLayout && density !== 'extra-compact' && (
<Avatar
name={sender?.name}
email={sender?.email}
@@ -154,73 +168,133 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
{/* Content */}
<div className="flex-1 min-w-0">
{/* First Line: Sender and Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{isImportant && (
<span className="px-1.5 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded font-medium">
Important
{isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-40',
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
)}>
{sender?.name || sender?.email || 'Unknown'}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={cn(
'shrink-0 truncate',
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
)}>
{email.subject || t('no_subject')}
</span>
{inlinePreview && (
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
)}
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
{isAnswered && isForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
<EmailIdentityBadge email={email} identities={identities} compact={true} />
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
<span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
) : (
<>
{/* First Line: Sender and Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{isImportant && (
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
Important
</span>
)}
<EmailIdentityBadge email={email} identities={identities} compact={true} />
{isAnswered && !isForwarded && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isForwarded && !isAnswered && (
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isAnswered && isForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDefs.map((kd) => (
<span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{kd.label}
</span>
))}
<span className={cn(
"text-xs tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
{email.subject || t('no_subject')}
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || t('no_subject')}
</div>
{/* Third Line: Preview (controlled by showPreview setting) */}
{showPreview && density !== 'extra-compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{email.preview || "No preview available"}
</p>
{/* Third Line: Preview (controlled by showPreview setting) */}
{showPreview && density !== 'extra-compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{email.preview || "No preview available"}
</p>
)}
</>
)}
</div>
</div>
@@ -228,6 +302,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
{/* Hover Quick Actions */}
<EmailHoverActions
email={email}
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
+25 -7
View File
@@ -64,7 +64,7 @@ export function EmailList({
const { client } = useAuthStore();
const {
selectedEmailIds,
selectAllEmails,
selectAllEmails: _selectAllEmails,
clearSelection,
batchMarkAsRead,
batchDelete,
@@ -89,10 +89,12 @@ export function EmailList({
searchQuery,
} = useEmailStore();
const disableThreading = useSettingsStore((state) => state.disableThreading);
const threadGroups = useMemo(() => {
const groups = groupEmailsByThread(emails);
const groups = groupEmailsByThread(emails, disableThreading);
return sortThreadGroups(groups);
}, [emails]);
}, [emails, disableThreading]);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
@@ -101,11 +103,16 @@ export function EmailList({
const parentRef = useRef<HTMLDivElement>(null);
const density = useSettingsStore((state) => state.density);
const showPreview = useSettingsStore((state) => state.showPreview);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const isFocusedMailLayout = mailLayout === 'focus';
const estimateSize = useCallback(() => {
if (isFocusedMailLayout) {
return { 'extra-compact': 32, compact: 40, regular: 46, comfortable: 54 }[density];
}
const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
}, [density, showPreview]);
}, [density, isFocusedMailLayout, showPreview]);
const virtualizer = useVirtualizer({
count: threadGroups.length,
@@ -214,15 +221,26 @@ export function EmailList({
}
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
// Range-based load more: trigger when last visible item is near the end
// Range-based load more: trigger when last visible item is near the end.
// Debounce to prevent rapid cascade when thread grouping reduces item
// count below the viewport size (e.g. 2400 emails → fewer thread groups).
const virtualItems = virtualizer.getVirtualItems();
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (lastVirtualItemIndex === undefined) return;
if (lastVirtualItemIndex >= threadGroups.length - 5) {
handleLoadMore();
// Clear any pending timer so we don't stack calls
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
loadMoreTimerRef.current = setTimeout(() => {
handleLoadMore();
loadMoreTimerRef.current = null;
}, 150);
}
return () => {
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
};
}, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
// Scroll to the thread group containing the selected email
@@ -242,7 +260,7 @@ export function EmailList({
useEffect(() => {
virtualizer.measure();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [density, showPreview]);
}, [density, isFocusedMailLayout, showPreview]);
return (
<div className={cn("flex flex-col min-h-0", className)}>
+358 -144
View File
@@ -8,7 +8,7 @@ import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/ema
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime } from "@/lib/utils";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
import {
Reply,
@@ -57,6 +57,7 @@ import {
MapPin,
StickyNote,
PanelRightClose,
PanelRightOpen,
Send,
FolderInput,
Inbox,
@@ -67,6 +68,7 @@ import {
HelpCircle,
EditIcon,
PlayCircle,
PenSquare,
} from "lucide-react";
import { useTranslations } from "next-intl";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
@@ -95,6 +97,9 @@ import type { SmimeStatus } from "@/lib/smime/types";
import { parseTnef, isTnefAttachment } from "@/lib/tnef";
import { debug } from "@/lib/debug";
import type { TnefAttachment } from "@/lib/tnef";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { usePluginStore } from "@/stores/plugin-store";
import { ResizeHandle } from "@/components/layout/resize-handle";
interface EmailViewerProps {
email: Email | null;
@@ -117,6 +122,7 @@ interface EmailViewerProps {
onNavigatePrev?: () => void;
onShowShortcuts?: () => void;
onEditDraft?: () => void;
onCompose?: () => void;
currentUserEmail?: string;
currentUserName?: string;
currentMailboxRole?: string;
@@ -187,20 +193,21 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
return 'Attachment';
};
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
if (!keywords) return null;
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
return key.startsWith("$label:")
? key.slice("$label:".length)
: key.slice("$color:".length);
tags.push(
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
);
}
}
return null;
return tags;
};
// Helper function to format recipients with contextual display
const formatRecipients = (
const _formatRecipients = (
recipients: Array<{ name?: string; email: string }> | undefined,
currentUserEmail: string | undefined,
t: (key: string, params?: Record<string, string | number>) => string
@@ -861,6 +868,7 @@ export function EmailViewer({
onNavigatePrev,
onShowShortcuts,
onEditDraft,
onCompose,
currentUserEmail,
currentUserName,
currentMailboxRole,
@@ -880,11 +888,16 @@ export function EmailViewer({
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const isFocusedMailLayout = mailLayout === 'focus';
// Detect if current mailbox is Junk folder
const isInJunkFolder = currentMailboxRole === 'junk';
@@ -902,7 +915,7 @@ export function EmailViewer({
// Tablet list visibility
const { isTablet, isMobile } = useDeviceDetection();
const { tabletListVisible } = useUIStore();
const { identities, client, isDemoMode } = useAuthStore();
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const { startTour } = useTour();
const [showFullHeaders, setShowFullHeaders] = useState(false);
@@ -924,7 +937,8 @@ export function EmailViewer({
const moveMenuRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
const currentColor = getCurrentColor(email?.keywords);
const currentColors = getCurrentColors(email?.keywords);
const currentColor = currentColors[0] ?? null;
// S/MIME state
const [smimeStatus, setSmimeStatus] = useState<SmimeStatus | null>(null);
@@ -947,11 +961,18 @@ export function EmailViewer({
const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState<PostalMimeAttachment[]>([]);
const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false);
// Plugin detail sidebar state
const detailSlots = usePluginStore(s => s.slots['email-detail-sidebar']);
const hasDetailSidebar = detailSlots && detailSlots.length > 0;
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(false);
const [detailSidebarWidth, setDetailSidebarWidth] = useState(280);
const detailSidebarWidthRef = useRef(280);
// Ensure S/MIME key records are loaded from IndexedDB
useLayoutEffect(() => {
smimeStore.load();
smimeStore.load(activeAccountId ?? undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [activeAccountId]);
// Build mailbox tree for move-to dropdown
const moveTargetIds = useMemo(() => new Set(
@@ -1136,12 +1157,51 @@ export function EmailViewer({
}
);
const autoMarkedEmailRef = useRef<string | null>(null);
// Reset auto-mark tracking when email changes
useEffect(() => {
// Mark as read when email is viewed
if (email && !email.keywords?.$seen && onMarkAsRead) {
onMarkAsRead(email.id, true);
autoMarkedEmailRef.current = null;
}, [email?.id]);
useEffect(() => {
// Mark as read when email is viewed, respecting the delay setting
if (!email || !onMarkAsRead) {
return;
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- email?.id changes when email changes, which is the intended trigger
// Already read — record that so manual unread toggle won't re-trigger auto-mark
if (email.keywords?.$seen) {
autoMarkedEmailRef.current = email.id;
return;
}
// Don't re-trigger if we already auto-marked this email (user may have toggled it back to unread)
if (autoMarkedEmailRef.current === email.id) {
return;
}
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
// Never auto-mark
if (markAsReadDelay === -1) {
return;
}
// Instant mark
if (markAsReadDelay === 0) {
autoMarkedEmailRef.current = email.id;
onMarkAsRead(email.id, true);
return;
}
// Delayed mark
const timeout = setTimeout(() => {
autoMarkedEmailRef.current = email.id;
onMarkAsRead(email.id, true);
}, markAsReadDelay);
return () => clearTimeout(timeout);
}, [email?.id, email?.keywords?.$seen, onMarkAsRead]);
// Reset external content permission and quick reply when email changes
@@ -1509,8 +1569,25 @@ export function EmailViewer({
if (detection.type === 'enveloped-data') {
// Encrypted message
const { keyRecords, unlockedDecryptionKeys } = smimeStore;
smimeDebug('[S/MIME] decrypt attempt:', { keyRecordCount: keyRecords.length, unlockedKeyCount: unlockedDecryptionKeys.size, keyRecordIds: keyRecords.map(k => k.id) });
const { keyRecords, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys } = smimeStore;
smimeDebug('[S/MIME] decrypt attempt:', {
keyRecordCount: keyRecords.length,
unlockedKeyCount: unlockedDecryptionKeys.size,
legacyKeyCount: unlockedLegacyDecryptionKeys.size,
keyRecordIds: keyRecords.map(k => k.id),
});
// Short-circuit: no keys imported at all
if (keyRecords.length === 0) {
smimeDebug('[S/MIME] no key records available, skipping decrypt');
setSmimeStatus({
isSigned: false,
isEncrypted: true,
decryptionError: 'no-key',
});
return;
}
try {
let result: Awaited<ReturnType<typeof smimeDecrypt>> | null = null;
let lastError: unknown = null;
@@ -1521,6 +1598,7 @@ export function EmailViewer({
cmsBytes: candidate.normalized,
keyRecords,
unlockedKeys: unlockedDecryptionKeys,
legacyUnlockedKeys: unlockedLegacyDecryptionKeys,
});
smimeDebug('[S/MIME] decrypt success with candidate:', {
source: candidate.source,
@@ -1602,8 +1680,15 @@ export function EmailViewer({
if (!existing) {
try {
await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email');
} catch { /* ignore import errors */ }
smimeDebug('[S/MIME] auto-imported signer cert:', { email: verifyResult.status.signerCert.email, fingerprint: verifyResult.status.signerCert.fingerprint });
} catch (importErr) {
smimeError('[S/MIME] auto-import signer cert failed:', importErr);
}
} else {
smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint });
}
} else if (verifyResult.status.signatureValid && verifyResult.status.signerCert) {
smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: verifyResult.status.signerCert.email });
}
} catch (error) {
smimeError('[S/MIME] nested signature verify failed:', {
@@ -1641,10 +1726,12 @@ export function EmailViewer({
decryptionError: 'locked',
});
} else {
const errMsg = err instanceof Error ? err.message : 'Decryption failed';
const isNoKeyError = errMsg.includes('No imported S/MIME key matches');
setSmimeStatus({
isSigned: false,
isEncrypted: true,
decryptionError: err instanceof Error ? err.message : 'Decryption failed',
decryptionError: isNoKeyError ? 'no-key' : errMsg,
});
}
}
@@ -1706,8 +1793,15 @@ export function EmailViewer({
if (!existing) {
try {
await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email');
} catch { /* ignore import errors */ }
smimeDebug('[S/MIME] auto-imported signer cert:', { email: result.status.signerCert.email, fingerprint: result.status.signerCert.fingerprint });
} catch (importErr) {
smimeError('[S/MIME] auto-import signer cert failed:', importErr);
}
} else {
smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint });
}
} else if (result.status.signatureValid && result.status.signerCert) {
smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: result.status.signerCert.email });
}
} catch (err) {
if (cancelled) return;
@@ -1740,6 +1834,8 @@ export function EmailViewer({
smimeStore.autoImportSignerCerts,
smimeStore.keyRecords,
smimeStore.unlockedDecryptionKeys,
smimeStore.unlockedLegacyDecryptionKeys,
smimeStore,
]);
// TNEF (winmail.dat) detection and processing
@@ -1748,12 +1844,12 @@ export function EmailViewer({
const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type));
if (!tnefAtt?.blobId) {
debug.log('TNEF: No winmail.dat attachment found in email', email?.id);
debug.log('email', 'TNEF: No winmail.dat attachment found in email', email?.id);
return;
}
debug.group('TNEF Processing');
debug.log('Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
debug.group('TNEF Processing', 'email');
debug.log('email', 'Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
// Check if the email already has a usable HTML body with real content
// Outlook often forwards TNEF emails with an HTML body that's just Word
@@ -1763,46 +1859,46 @@ export function EmailViewer({
let hasRealHtmlBody = !!htmlValue;
if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) {
hasRealHtmlBody = false;
debug.log('TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body');
debug.log('email', 'TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body');
}
if (hasRealHtmlBody) {
debug.log('TNEF: Email has real HTML body, will extract attachments only');
debug.log('email', 'TNEF: Email has real HTML body, will extract attachments only');
} else {
debug.log('TNEF: Email has no usable HTML body, proceeding with full TNEF extraction');
debug.log('email', 'TNEF: Email has no usable HTML body, proceeding with full TNEF extraction');
}
let cancelled = false;
async function processTnef() {
try {
debug.time('TNEF fetch blob');
debug.time('TNEF fetch blob', 'email');
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
debug.timeEnd('TNEF fetch blob');
debug.log('TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
debug.timeEnd('TNEF fetch blob', 'email');
debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
if (cancelled) {
debug.log('TNEF: Processing cancelled after fetch');
debug.log('email', 'TNEF: Processing cancelled after fetch');
debug.groupEnd();
return;
}
if (blobBytes.byteLength === 0) {
debug.warn('TNEF: Fetched blob is empty (0 bytes)');
debug.warn('email', 'TNEF: Fetched blob is empty (0 bytes)');
debug.groupEnd();
return;
}
const tnefData = new Uint8Array(blobBytes);
debug.time('TNEF parse');
debug.time('TNEF parse', 'email');
const parsed = parseTnef(tnefData);
debug.timeEnd('TNEF parse');
debug.timeEnd('TNEF parse', 'email');
if (cancelled) {
debug.log('TNEF: Processing cancelled after parse');
debug.log('email', 'TNEF: Processing cancelled after parse');
debug.groupEnd();
return;
}
debug.log('TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
debug.log('email', 'TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
if (parsed.htmlBody && !hasRealHtmlBody) {
setTnefHtml(parsed.htmlBody);
@@ -1812,11 +1908,11 @@ export function EmailViewer({
}
if (parsed.attachments.length > 0) {
setTnefAttachments(parsed.attachments);
debug.log('TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', '));
debug.log('email', 'TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', '));
}
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
debug.warn('TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
}
debug.groupEnd();
@@ -1854,13 +1950,13 @@ export function EmailViewer({
const hasRealText = !!textValue;
if (hasRealHtml || hasRealText) {
debug.log('Embedded RFC822: Outer email has real body content, not unwrapping');
debug.log('email', 'Embedded RFC822: Outer email has real body content, not unwrapping');
return;
}
debug.group('Embedded RFC822 Unwrapping');
debug.log('Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
debug.log('Outer email body is empty, will unwrap embedded email');
debug.group('Embedded RFC822 Unwrapping', 'email');
debug.log('email', 'Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
debug.log('email', 'Outer email body is empty, will unwrap embedded email');
let cancelled = false;
@@ -1869,7 +1965,7 @@ export function EmailViewer({
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
if (cancelled) { debug.groupEnd(); return; }
if (blobBytes.byteLength === 0) {
debug.warn('Embedded RFC822: Fetched blob is empty');
debug.warn('email', 'Embedded RFC822: Fetched blob is empty');
debug.groupEnd();
return;
}
@@ -1879,7 +1975,7 @@ export function EmailViewer({
const parsed = await parser.parse(new Uint8Array(blobBytes));
if (cancelled) { debug.groupEnd(); return; }
debug.log('Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
debug.log('email', 'Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
', attachments:', parsed.attachments?.length ?? 0);
@@ -1891,7 +1987,7 @@ export function EmailViewer({
}
if (parsed.attachments && parsed.attachments.length > 0) {
setEmbeddedEmailAttachments(parsed.attachments as PostalMimeAttachment[]);
debug.log('Embedded RFC822 attachments:', parsed.attachments.map(
debug.log('email', 'Embedded RFC822 attachments:', parsed.attachments.map(
a => (a.filename || 'unnamed') + ' (' + a.mimeType + ')'
).join(', '));
}
@@ -1917,7 +2013,7 @@ export function EmailViewer({
if (decryptedCidAttachments.length > 0) {
const urls: Record<string, string> = {};
decryptedCidAttachments.forEach((att, index) => {
decryptedCidAttachments.forEach((att) => {
const bytes = getAttachmentContentBytes(att);
if (!bytes) return;
const cidValue = att.contentId!.replace(/^<|>$/g, '');
@@ -1974,7 +2070,7 @@ export function EmailViewer({
cancelled = true;
objectUrls.forEach(url => URL.revokeObjectURL(url));
};
}, [client, email?.id, smimeDecryptedAttachments]);
}, [client, email?.id, smimeDecryptedAttachments, email?.attachments]);
const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
if (smimeDecryptedAttachments.length > 0) {
@@ -2218,9 +2314,11 @@ export function EmailViewer({
// Use shared sanitization config as base (more secure)
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
// Check if sender is trusted
// Check if sender is trusted (localStorage list or address book)
const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false;
const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
// Block external content based on policy:
// 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted
@@ -2327,7 +2425,7 @@ export function EmailViewer({
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
isHtml: false
};
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, cidBlobUrls]);
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, isTrustedAddressBookSender, trustedSendersAddressBook, cidBlobUrls]);
// Override email content with S/MIME decrypted content when available
const effectiveEmailContent = useMemo(() => {
@@ -2494,14 +2592,21 @@ export function EmailViewer({
// If email has native dark mode, let it handle its own theming
// Otherwise, use CSS filter inversion for dark mode (preserves layout)
// Re-invert leaf media elements so they appear normal.
// Container selectors (bgcolor, background, etc.) use :not(:has(...)) to avoid
// double re-inverting images nested inside those containers.
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
html { background: #1a1a1a; }
body { filter: invert(1) hue-rotate(180deg); }
img, video, picture, svg, canvas, object, embed,
[style*="background-image"], [style*="background:"],
[background], [bgcolor],
td[background], table[background],
img[src], input[type="image"] {
img, video, svg, canvas, object, embed, input[type="image"] {
filter: invert(1) hue-rotate(180deg);
}
[style*="background-image"]:not(:has(img, video, svg, canvas, object, embed)),
[style*="background:"]:not(:has(img, video, svg, canvas, object, embed)),
[background]:not(:has(img, video, svg, canvas, object, embed)),
[bgcolor]:not(:has(img, video, svg, canvas, object, embed)),
td[background]:not(:has(img, video, svg, canvas, object, embed)),
table[background]:not(:has(img, video, svg, canvas, object, embed)) {
filter: invert(1) hue-rotate(180deg);
}
` : '';
@@ -2540,11 +2645,35 @@ export function EmailViewer({
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
// Dark mode: re-invert elements with stylesheet-defined background images
// (CSS attribute selectors only catch inline styles, not <style> block rules)
if (isDark && !emailHasNativeDarkMode) {
const win = doc.defaultView;
if (win) {
doc.body.querySelectorAll('*').forEach(el => {
const htmlEl = el as HTMLElement;
// Skip elements already handled by CSS attribute selectors
if (htmlEl.style.backgroundImage || htmlEl.style.background ||
htmlEl.hasAttribute('background') || htmlEl.hasAttribute('bgcolor')) return;
// Skip leaf media elements (already re-inverted by CSS)
const tag = htmlEl.tagName;
if (['IMG', 'VIDEO', 'SVG', 'CANVAS', 'OBJECT', 'EMBED'].includes(tag)) return;
const computed = win.getComputedStyle(htmlEl);
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
// Only re-invert if this container doesn't have media children
if (!htmlEl.querySelector('img, video, svg, canvas, object, embed')) {
htmlEl.style.filter = 'invert(1) hue-rotate(180deg)';
}
}
});
}
}
}
} catch {
// Cross-origin restrictions - iframe will still display content
}
}, []);
}, [isDark, emailHasNativeDarkMode]);
// Export email as .eml file
const handleExportEmail = async () => {
@@ -2743,6 +2872,12 @@ export function EmailViewer({
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
{onCompose && (
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
<PenSquare className="w-4 h-4 mr-2" />
{t('compose')}
</Button>
)}
</div>
</div>
);
@@ -2758,7 +2893,7 @@ export function EmailViewer({
<>
{/* Left: Reply actions */}
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
{showBackButton && isTablet && !tabletListVisible && onBack && (
{showBackButton && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
<Button
variant="ghost"
size="icon"
@@ -2773,7 +2908,7 @@ export function EmailViewer({
<Button
variant="default"
size="sm"
onClick={onEditDraft}
onClick={() => onEditDraft()}
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('tooltips.edit_draft')}
>
@@ -2819,6 +2954,7 @@ export function EmailViewer({
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('forward')}</span>}
</Button>
</>)}
<PluginSlot name="toolbar-actions" />
</div>
{/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print, view source */}
@@ -2926,43 +3062,51 @@ export function EmailViewer({
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
className={cn(
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
currentColor && "bg-muted/50"
currentColors.length > 0 && "bg-muted/50"
)}
title={t('set_color')}
>
{(() => {
const kw = currentColor ? emailKeywords.find(k => k.id === currentColor) : null;
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
return dotClass ? (
<>
<span className={cn("w-3 h-3 rounded-full", dotClass)} />
{showToolbarLabels && <span className="text-xs font-medium text-foreground">{kw!.label}</span>}
</>
) : (
<>
<Tag className="w-4 h-4 text-muted-foreground" />
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
</>
);
})()}
{currentColors.length > 0 ? (
<>
<span className="flex items-center gap-0.5">
{currentColors.slice(0, 3).map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId);
return kw ? <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot)} /> : null;
})}
</span>
{showToolbarLabels && currentColors.length === 1 && (
<span className="text-xs font-medium text-foreground">
{emailKeywords.find(k => k.id === currentColors[0])?.label}
</span>
)}
</>
) : (
<>
<Tag className="w-4 h-4 text-muted-foreground" />
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
</>
)}
</button>
{tagMenuOpen && (
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
{colorOptions.map((option) => (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
currentColor === option.value && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
</button>
))}
{currentColor && (
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<button
@@ -3170,21 +3314,24 @@ export function EmailViewer({
</button>
{moreMenuSub === 'tag' && (
<div className="absolute right-full top-0 mr-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
{colorOptions.map((option) => (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
currentColor === option.value && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
</button>
))}
{currentColor && (
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<button
@@ -3360,21 +3507,24 @@ export function EmailViewer({
<>
<div className="h-px bg-border my-1" />
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
{colorOptions.map((option) => (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
className={cn(
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
currentColor === option.value && "bg-accent font-medium"
)}
>
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{currentColor === option.value && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
</button>
))}
{currentColor && (
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
className={cn(
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
@@ -3491,7 +3641,7 @@ export function EmailViewer({
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start justify-between gap-2 lg:gap-4">
{/* Back button (for below-subject mode on tablet) */}
{toolbarPosition === 'below-subject' && isTablet && !tabletListVisible && onBack && (
{toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
<Button
variant="ghost"
size="icon"
@@ -3520,16 +3670,20 @@ export function EmailViewer({
)} />
</button>
)}
{/* Color tag dot */}
{currentColor && (() => {
const kw = emailKeywords.find(k => k.id === currentColor);
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
return dotClass ? (
<span className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
) : null;
})()}
{/* Color tag dots */}
{currentColors.length > 0 && (
<span className="flex items-center gap-0.5">
{currentColors.map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId);
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
return dotClass ? (
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
) : null;
})}
</span>
)}
{isImportant && (
<span className="px-1.5 lg:px-2 py-0.5 bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
{t('important')}
</span>
)}
@@ -4360,9 +4514,9 @@ export function EmailViewer({
{/* Draft Banner */}
{isDraft && (
<div className="border-b border-border bg-amber-50 dark:bg-amber-950/30">
<div className="border-b border-border bg-warning/10">
<div className="max-w-4xl mx-auto px-6 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2 text-amber-700 dark:text-amber-400">
<div className="flex items-center gap-2 text-warning">
<File className="w-4 h-4" />
<span className="text-sm font-medium">{t('draft_banner')}</span>
</div>
@@ -4370,7 +4524,7 @@ export function EmailViewer({
<Button
size="sm"
variant="outline"
onClick={onEditDraft}
onClick={() => onEditDraft()}
className="gap-1.5"
>
<EditIcon className="w-3.5 h-3.5" />
@@ -4397,7 +4551,7 @@ export function EmailViewer({
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
hasCalendarInvitation) && (
<div className="border-b border-border bg-muted/30 isolate">
<div className="max-w-4xl mx-auto px-6 py-1.5">
<div className="max-w-6xl mx-auto px-6 py-1.5">
<div className="flex flex-col gap-3 isolate">
{/* External Content Controls */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
@@ -4416,7 +4570,11 @@ export function EmailViewer({
onClick={() => {
const senderEmail = email.from?.[0]?.email;
if (senderEmail) {
addTrustedSender(senderEmail);
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
setAllowExternalContent(true);
}
}}
@@ -4443,6 +4601,8 @@ export function EmailViewer({
<div>
<PluginSlot name="email-banner" extraProps={{ email }} />
{/* Email Body */}
<div className="email-content-wrapper overflow-x-auto">
{effectiveEmailContent.isHtml ? (
@@ -4470,6 +4630,8 @@ export function EmailViewer({
)}
</div>
<PluginSlot name="email-footer" />
{/* Quick Reply Section - hidden for drafts */}
{!isDraft && (<div className={cn(
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
@@ -4619,13 +4781,13 @@ export function EmailViewer({
{/* Mobile bottom action bar */}
{isMobile && (
<nav className="fixed bottom-0 left-0 right-0 z-[50] bg-background border-t border-border sm:hidden">
<div className="flex items-center justify-around">
<nav className="fixed bottom-0 left-0 right-0 z-[50] bg-background border-t border-border sm:hidden overflow-hidden">
<div className="flex items-center overflow-x-auto mobile-scroll-hidden">
<button
onClick={onNavigatePrev}
disabled={!onNavigatePrev}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] transition-colors duration-150",
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150",
onNavigatePrev ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
)}
aria-label={t('tooltips.previous')}
@@ -4635,8 +4797,8 @@ export function EmailViewer({
</button>
{isDraft && onEditDraft ? (
<button
onClick={onEditDraft}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-primary active:text-primary/80 transition-colors duration-150"
onClick={() => onEditDraft()}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-primary active:text-primary/80 transition-colors duration-150"
aria-label={t('tooltips.edit_draft')}
>
<EditIcon className="w-5 h-5" />
@@ -4646,7 +4808,7 @@ export function EmailViewer({
<>
<button
onClick={() => onReply?.()}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-muted-foreground active:text-foreground transition-colors duration-150"
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.reply')}
>
<Reply className="w-5 h-5" />
@@ -4654,7 +4816,7 @@ export function EmailViewer({
</button>
<button
onClick={onReplyAll}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-muted-foreground active:text-foreground transition-colors duration-150"
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.reply_all')}
>
<ReplyAll className="w-5 h-5" />
@@ -4662,7 +4824,7 @@ export function EmailViewer({
</button>
<button
onClick={onForward}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-muted-foreground active:text-foreground transition-colors duration-150"
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.forward')}
>
<Forward className="w-5 h-5" />
@@ -4673,7 +4835,7 @@ export function EmailViewer({
onClick={onNavigateNext}
disabled={!onNavigateNext}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] transition-colors duration-150",
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150",
onNavigateNext ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
)}
aria-label={t('tooltips.next')}
@@ -4685,6 +4847,58 @@ export function EmailViewer({
</nav>
)}
{/* Plugin Detail Sidebar - resizable, collapsible */}
{hasDetailSidebar && !isMobile && (
<>
{/* Collapse toggle when sidebar is collapsed */}
{detailSidebarCollapsed && (
<div className="flex flex-col items-center border-l border-border bg-background">
<Button
variant="ghost"
size="icon"
onClick={() => setDetailSidebarCollapsed(false)}
className="h-8 w-8 m-1"
aria-label="Expand panel"
>
<PanelRightOpen className="w-4 h-4" />
</Button>
</div>
)}
{!detailSidebarCollapsed && (
<>
<ResizeHandle
onResizeStart={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
onResize={(delta) => {
const newWidth = Math.max(200, Math.min(500, detailSidebarWidthRef.current - delta));
setDetailSidebarWidth(newWidth);
}}
onResizeEnd={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
onDoubleClick={() => setDetailSidebarWidth(280)}
/>
<div
className="flex flex-col h-full border-l border-border bg-background overflow-hidden"
style={{ width: detailSidebarWidth, minWidth: detailSidebarWidth }}
>
<div className="flex items-center justify-end px-1 py-1 border-b border-border shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => setDetailSidebarCollapsed(true)}
className="h-7 w-7"
aria-label="Collapse panel"
>
<PanelRightClose className="w-4 h-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto">
<PluginSlot name="email-detail-sidebar" extraProps={{ email }} />
</div>
</div>
</>
)}
</>
)}
{/* Contact Detail Sidebar - desktop only */}
{contactSidebarEmail && !isMobileDevice && (
<ContactSidebarPanel
@@ -4708,7 +4922,7 @@ export function EmailViewer({
if (client && supportsSync) {
createContact(client, contactData).then(() => toast.success('Contact added'));
} else {
addLocalContact({ id: `local-${crypto.randomUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
toast.success('Contact added');
}
}}
+12
View File
@@ -93,6 +93,18 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
return () => document.removeEventListener("keydown", handler);
}, [isOpen]);
// Close on scroll so the popover does not float while content moves.
useEffect(() => {
if (!isOpen) return;
const handler = () => handleClose();
document.addEventListener("scroll", handler, true);
window.addEventListener("resize", handler);
return () => {
document.removeEventListener("scroll", handler, true);
window.removeEventListener("resize", handler);
};
}, [isOpen]);
const handleViewContact = () => {
if (onViewContact) {
onViewContact(contact ?? null, email);
+1
View File
@@ -118,6 +118,7 @@ export function RichTextEditor({
);
if (imageFiles.length === 0) return false;
event.preventDefault();
event.stopPropagation();
for (const file of imageFiles) {
upload(file).then((url) => {
if (url) {
+17 -5
View File
@@ -30,6 +30,12 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
text: t('unlock_key_desc'),
variant: 'warning',
});
} else if (status.decryptionError === 'no-key') {
items.push({
icon: <Lock className="w-4 h-4" />,
text: t('status_encrypted_no_key'),
variant: 'warning',
});
} else {
items.push({
icon: <ShieldX className="w-4 h-4" />,
@@ -49,7 +55,13 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
// Signature status
if (status.isSigned) {
if (status.signatureValid === true) {
if (status.signerEmailMatch === false) {
if (status.selfSigned) {
items.push({
icon: <AlertTriangle className="w-4 h-4" />,
text: t('status_signed_self_signed'),
variant: 'warning',
});
} else if (status.signerEmailMatch === false) {
items.push({
icon: <AlertTriangle className="w-4 h-4" />,
text: t('status_signed_mismatch'),
@@ -83,10 +95,10 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
if (items.length === 0) return null;
const variantStyles = {
success: 'bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-200 dark:border-green-800',
warning: 'bg-yellow-50 dark:bg-yellow-950/30 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800',
error: 'bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-400 border-red-200 dark:border-red-800',
info: 'bg-blue-50 dark:bg-blue-950/30 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800',
success: 'bg-success/10 text-success border-success/30',
warning: 'bg-warning/10 text-warning border-warning/30',
error: 'bg-destructive/10 text-destructive border-destructive/30',
info: 'bg-info/10 text-info border-info/30',
};
return (
+14 -3
View File
@@ -31,6 +31,7 @@ import {
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { isFilePreviewable } from "@/lib/file-preview";
@@ -84,6 +85,10 @@ export function ThreadConversationView({
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const { client } = useAuthStore();
// Track which emails are expanded (most recent by default)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
@@ -164,7 +169,9 @@ export function ThreadConversationView({
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
{emails.map((email, index) => {
const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false;
const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
return (
<EmailCard
key={email.id}
@@ -175,7 +182,11 @@ export function ThreadConversationView({
onToggleExpanded={() => toggleExpanded(email.id)}
onAllowExternal={() => toggleAllowExternal(email.id)}
onTrustSender={senderEmail ? () => {
addTrustedSender(senderEmail);
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
toggleAllowExternal(email.id);
} : undefined}
onReply={onReply ? () => onReply(email) : undefined}
@@ -305,7 +316,7 @@ function EmailCard({
cancelled = true;
objectUrls.forEach(url => URL.revokeObjectURL(url));
};
}, [client, email?.id]);
}, [client, email?.id, email?.attachments]);
// Sanitize and prepare email HTML content
const emailContent = useMemo(() => {
+18 -4
View File
@@ -5,7 +5,7 @@ import { formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react";
import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { useEmailStore } from "@/stores/email-store";
@@ -29,6 +29,8 @@ export function ThreadEmailItem({
}: ThreadEmailItemProps) {
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0];
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const density = useSettingsStore((state) => state.density);
@@ -83,9 +85,9 @@ export function ThreadEmailItem({
"pl-12 pr-4",
"border-l-2 border-l-transparent",
selected
? "bg-blue-200 dark:bg-blue-900/50 border-l-primary"
? "bg-selection border-l-primary"
: "hover:bg-muted/50",
isUnread && !selected && "bg-amber-50 dark:bg-amber-900/20",
isUnread && !selected && "bg-warning/10",
!isLast && "border-b border-border/30",
isChecked && "ring-2 ring-primary/20 bg-accent/40",
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
@@ -119,7 +121,7 @@ export function ThreadEmailItem({
{/* Unread indicator */}
{isUnread && (
<div className="absolute left-7 top-1/2 -translate-y-1/2">
<Circle className="w-1.5 h-1.5 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
<Circle className="w-1.5 h-1.5 fill-unread text-unread" />
</div>
)}
@@ -151,6 +153,18 @@ export function ThreadEmailItem({
{isStarred && (
<Star className="w-3 h-3 fill-amber-400 text-amber-400" />
)}
{isAnswered && !isForwarded && (
<Reply className="w-3 h-3 text-muted-foreground" />
)}
{isForwarded && !isAnswered && (
<Forward className="w-3 h-3 text-muted-foreground" />
)}
{isAnswered && isForwarded && (
<>
<Reply className="w-3 h-3 text-muted-foreground" />
<Forward className="w-3 h-3 text-muted-foreground" />
</>
)}
{email.hasAttachment && (
<Paperclip className="w-3 h-3 text-muted-foreground" />
)}

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