Compare commits

...
136 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
203 changed files with 21758 additions and 1816 deletions
+12 -1
View File
@@ -9,9 +9,16 @@
# App name displayed in the UI # App name displayed in the UI
APP_NAME=Bulwark Webmail APP_NAME=Bulwark Webmail
# URL of your JMAP-compatible mail server (required) # URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
JMAP_SERVER_URL=https://your-jmap-server.com JMAP_SERVER_URL=https://your-jmap-server.com
# Allow users to specify a custom JMAP server URL on the login form.
# When enabled, a "JMAP Server" field appears on the login page.
# Users can connect to any JMAP-compatible server.
# NOTE: External JMAP servers must include this domain in their CORS
# Access-Control-Allow-Origin header, or browser requests will be blocked.
# ALLOW_CUSTOM_JMAP_ENDPOINT=true
# ============================================================================= # =============================================================================
# Stalwart Mail Server Integration # Stalwart Mail Server Integration
# ============================================================================= # =============================================================================
@@ -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 (server-side only, never exposed to the browser)
# OAUTH_CLIENT_SECRET=your-client-secret # OAUTH_CLIENT_SECRET=your-client-secret
# Alternatively, you can specify the path to a file containing the OAuth client secret.
# OAUTH_CLIENT_SECRET_FILE=/oauth-client-secret
# OpenID Connect issuer URL for discovery # OpenID Connect issuer URL for discovery
# OAUTH_ISSUER_URL=https://your-idp.example.com # OAUTH_ISSUER_URL=https://your-idp.example.com
@@ -53,6 +62,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Required for both "Remember me" and settings sync features. # Required for both "Remember me" and settings sync features.
# Generate with: openssl rand -base64 32 # Generate with: openssl rand -base64 32
# SESSION_SECRET=your-secret-key-here # SESSION_SECRET=your-secret-key-here
# Alternatively, you can specify the path to a file containing the session secret.
# SESSION_SECRET_FILE=/session-secret
# ============================================================================= # =============================================================================
# Settings Sync # Settings Sync
+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" - ".github/workflows/docker-publish.yml"
workflow_dispatch: workflow_dispatch:
env:
IMAGE_NAME: ghcr.io/${{ github.repository }}
jobs: 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: build:
needs: prepare
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -57,7 +69,7 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.IMAGE_NAME }} images: ${{ needs.prepare.outputs.image_name }}
- name: Build and push by digest - name: Build and push by digest
id: build id: build
@@ -66,7 +78,7 @@ jobs:
context: . context: .
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }} 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-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
@@ -86,7 +98,7 @@ jobs:
merge: merge:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build needs: [prepare, build]
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -113,17 +125,17 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.IMAGE_NAME }} images: ${{ needs.prepare.outputs.image_name }}
tags: | tags: |
type=raw,value={{branch}} type=raw,value=latest
type=sha,prefix={{branch}}- type=sha
- name: Create manifest list and push - name: Create manifest list and push
working-directory: /tmp/digests working-directory: /tmp/digests
run: | run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ 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 - name: Inspect image
run: | 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
-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
+149
View File
@@ -1,5 +1,154 @@
# Changelog # 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) ## 1.4.9 (2026-03-27)
### Features ### Features
+15 -4
View File
@@ -1,9 +1,9 @@
<div align="center"> <div align="center">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20White%20and%20Color.svg" /> <source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" /> <source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
<img src="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" alt="Bulwark Webmail" width="220" /> <img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
</picture> </picture>
</div> </div>
@@ -12,6 +12,17 @@
Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors. 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 ## Getting Started
### Development Setup ### Development Setup
@@ -195,7 +206,7 @@ webmail/
- **Never commit sensitive data** (API keys, passwords, etc.) - **Never commit sensitive data** (API keys, passwords, etc.)
- **Sanitize user input** and email content - **Sanitize user input** and email content
- **Block external content** by default for privacy - **Block external content** by default for privacy
- Report security vulnerabilities privately - Report security vulnerabilities privately (e.g. bulwark@rbm.systems)
## Questions? ## Questions?
+4 -2
View File
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.4.9-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![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) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div> </div>
@@ -271,6 +271,7 @@ PORT=3000 # Default listen port
OAUTH_ENABLED=true OAUTH_ENABLED=true
OAUTH_CLIENT_ID=webmail OAUTH_CLIENT_ID=webmail
OAUTH_CLIENT_SECRET= # optional, for confidential clients OAUTH_CLIENT_SECRET= # optional, for confidential clients
OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik) OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
``` ```
@@ -282,7 +283,8 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
<summary>Remember Me</summary> <summary>Remember Me</summary>
```env ```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). Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry).
+1 -1
View File
@@ -1 +1 @@
1.4.9 1.4.12
+80 -20
View File
@@ -39,10 +39,13 @@ import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { ResizeHandle } from "@/components/layout/resize-handle"; import { ResizeHandle } from "@/components/layout/resize-handle";
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization"; import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
import { getEventStartDate } from "@/lib/calendar-utils";
import { useTaskStore } from "@/stores/task-store"; import { useTaskStore } from "@/stores/task-store";
import { useContactStore } from "@/stores/contact-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types"; import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
import { getUserParticipantId } from "@/lib/calendar-participants"; import { getUserParticipantId } from "@/lib/calendar-participants";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
type PendingScopeAction = type PendingScopeAction =
@@ -66,12 +69,13 @@ export default function CalendarPage() {
isLoading, isLoadingEvents, supportsCalendar, error, isLoading, isLoadingEvents, supportsCalendar, error,
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent, fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
refreshAllSubscriptions, refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore(); } = useCalendarStore();
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar } = useSettingsStore(); const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
const taskStore = useTaskStore(); const taskStore = useTaskStore();
const fetchTasksFn = useTaskStore(state => state.fetchTasks); const fetchTasksFn = useTaskStore(state => state.fetchTasks);
const { identities } = useIdentityStore(); const { identities } = useIdentityStore();
const contacts = useContactStore((s) => s.contacts);
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month"; const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
const currentUserEmails = useMemo(() => const currentUserEmails = useMemo(() =>
@@ -82,6 +86,7 @@ export default function CalendarPage() {
const [showEventModal, setShowEventModal] = useState(false); const [showEventModal, setShowEventModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null); const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>(); const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>(); const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
@@ -146,6 +151,13 @@ export default function CalendarPage() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [client, refreshAllSubscriptions]); }, [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 dateRange = useMemo(() => {
const d = selectedDate; const d = selectedDate;
switch (normalizedViewMode) { switch (normalizedViewMode) {
@@ -337,18 +349,28 @@ export default function CalendarPage() {
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => { const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
if (isMobile) return; if (isMobile) return;
if (calendarHoverPreview === 'off') return;
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
// Don't show hover popover if the sidebar is already open for this event // Don't show hover popover if the sidebar is already open for this event
if (showEventModal && editEvent?.id === event.id) return; if (showEventModal && editEvent?.id === event.id) return;
setDetailEvent(event); if (calendarHoverPreview === 'delay-500ms' || calendarHoverPreview === 'delay-1s' || calendarHoverPreview === 'delay-2s') {
setDetailAnchorRect(anchorRect); const ms = calendarHoverPreview === 'delay-500ms' ? 500 : calendarHoverPreview === 'delay-1s' ? 1000 : 2000;
}, [isMobile, showEventModal, editEvent]); hoverTimerRef.current = setTimeout(() => {
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}, ms);
} else {
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}
}, [isMobile, calendarHoverPreview, showEventModal, editEvent]);
const handleHoverLeave = useCallback(() => { const handleHoverLeave = useCallback(() => {
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
hoverTimerRef.current = setTimeout(() => { hoverTimerRef.current = setTimeout(() => {
setDetailEvent(null); setDetailEvent(null);
setDetailAnchorRect(null); setDetailAnchorRect(null);
}, 200); }, 300);
}, []); }, []);
const handleEditFromDetail = useCallback(() => { const handleEditFromDetail = useCallback(() => {
@@ -385,12 +407,16 @@ export default function CalendarPage() {
} }
}, [client, fetchEvents]); }, [client, fetchEvents]);
const focusCalendarOnEvent = useCallback((event: Pick<CalendarEvent, "start">) => { const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
if (!event.start) { if (!event.start) {
return; return;
} }
const eventDate = parseISO(event.start); const eventDate = getEventStartDate({
start: event.start,
utcStart: event.utcStart ?? null,
showWithoutTime: event.showWithoutTime ?? false,
});
if (Number.isNaN(eventDate.getTime())) { if (Number.isNaN(eventDate.getTime())) {
return; return;
} }
@@ -727,14 +753,31 @@ export default function CalendarPage() {
return () => window.removeEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey);
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent, enableCalendarTasks]); }, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent, enableCalendarTasks]);
const visibleEvents = useMemo(() => const birthdayEvents = useMemo(() => {
events.filter((e) => { 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; if (!e.start || !e.calendarIds) return false;
const calIds = Object.keys(e.calendarIds); const calIds = Object.keys(e.calendarIds);
return calIds.some((id) => selectedCalendarIds.includes(id)); 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(() => { useEffect(() => {
const hiddenEvents = events.filter((event) => { const hiddenEvents = events.filter((event) => {
@@ -749,7 +792,7 @@ export default function CalendarPage() {
return; return;
} }
debug.log('Calendar visibility summary', { debug.log('calendar', 'Calendar visibility summary', {
totalEvents: events.length, totalEvents: events.length,
visibleEvents: visibleEvents.length, visibleEvents: visibleEvents.length,
hiddenEvents: hiddenEvents.length, hiddenEvents: hiddenEvents.length,
@@ -783,7 +826,7 @@ export default function CalendarPage() {
<CalendarMonthView <CalendarMonthView
selectedDate={selectedDate} selectedDate={selectedDate}
events={visibleEvents} events={visibleEvents}
calendars={calendars} calendars={allCalendars}
onSelectDate={handleSelectDate} onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
@@ -799,7 +842,7 @@ export default function CalendarPage() {
<CalendarWeekView <CalendarWeekView
selectedDate={selectedDate} selectedDate={selectedDate}
events={visibleEvents} events={visibleEvents}
calendars={calendars} calendars={allCalendars}
onSelectDate={handleSelectDate} onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
@@ -818,7 +861,7 @@ export default function CalendarPage() {
<CalendarDayView <CalendarDayView
selectedDate={selectedDate} selectedDate={selectedDate}
events={visibleEvents} events={visibleEvents}
calendars={calendars} calendars={allCalendars}
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave} onHoverLeave={handleHoverLeave}
@@ -835,7 +878,7 @@ export default function CalendarPage() {
<CalendarAgendaView <CalendarAgendaView
selectedDate={selectedDate} selectedDate={selectedDate}
events={visibleEvents} events={visibleEvents}
calendars={calendars} calendars={allCalendars}
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave} onHoverLeave={handleHoverLeave}
@@ -873,7 +916,7 @@ export default function CalendarPage() {
})(); })();
return ( 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} {viewContent}
{isLoadingEvents && calendars.length > 0 && events.length === 0 && ( {isLoadingEvents && calendars.length > 0 && events.length === 0 && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
@@ -926,13 +969,18 @@ export default function CalendarPage() {
showWeekNumbers={showWeekNumbers} showWeekNumbers={showWeekNumbers}
/> />
<CalendarSidebarPanel <CalendarSidebarPanel
calendars={calendars} calendars={allCalendars}
selectedCalendarIds={selectedCalendarIds} selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility} onToggleVisibility={toggleCalendarVisibility}
onColorChange={client ? (calendarId, color) => { onColorChange={client ? (calendarId, color) => {
if (calendarId === BIRTHDAY_CALENDAR_ID) {
updateSetting('birthdayCalendarColor', color);
return;
}
updateCalendar(client, calendarId, { color }); updateCalendar(client, calendarId, { color });
} : undefined} } : undefined}
onSubscribe={() => setShowSubscriptionModal(true)} onSubscribe={() => setShowSubscriptionModal(true)}
onEditSubscription={(subId) => setEditingSubscription(subId)}
client={client} client={client}
/> />
</div> </div>
@@ -1099,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} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<RecurrenceScopeDialog <RecurrenceScopeDialog
isOpen={!!pendingScopeAction} isOpen={!!pendingScopeAction}
+49 -2
View File
@@ -13,12 +13,13 @@ import { ContactGroupForm } from "@/components/contacts/contact-group-form";
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail"; import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar"; import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog"; import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { RenameDialog } from "@/components/files/rename-dialog";
import { exportContacts } from "@/components/contacts/contact-export"; import { exportContacts } from "@/components/contacts/contact-export";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store"; import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils"; import { cn, generateUUID } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail"; import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view"; import { InlineAppView } from "@/components/layout/inline-app-view";
@@ -72,12 +73,16 @@ export default function ContactsPage() {
bulkDeleteContacts, bulkDeleteContacts,
bulkAddToGroup, bulkAddToGroup,
moveContactToAddressBook, moveContactToAddressBook,
renameAddressBook,
renameKeyword,
importContacts, importContacts,
} = useContactStore(); } = useContactStore();
const [view, setView] = useState<View>("list"); const [view, setView] = useState<View>("list");
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all"); const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
const [showImportDialog, setShowImportDialog] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false);
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null); const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const hasFetched = useRef(false); const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
@@ -274,7 +279,7 @@ export default function ContactsPage() {
toast.success(t("toast.created")); toast.success(t("toast.created"));
} else { } else {
const localContact: ContactCard = { const localContact: ContactCard = {
id: `local-${crypto.randomUUID()}`, id: `local-${generateUUID()}`,
addressBookIds: {}, addressBookIds: {},
...data, ...data,
}; };
@@ -631,6 +636,8 @@ export default function ContactsPage() {
onDeleteGroup={handleDeleteGroupFromSidebar} onDeleteGroup={handleDeleteGroupFromSidebar}
onDropContacts={handleDropContacts} onDropContacts={handleDropContacts}
onDropContactsToCategory={handleDropContactsToCategory} onDropContactsToCategory={handleDropContactsToCategory}
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
/> />
</div> </div>
<ResizeHandle <ResizeHandle
@@ -725,6 +732,46 @@ export default function ContactsPage() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
{renamingKeyword !== null && (
<RenameDialog
currentName={renamingKeyword}
title={t("rename_category")}
label={t("category_name_label")}
onCancel={() => setRenamingKeyword(null)}
onConfirm={async (newName) => {
try {
await renameKeyword(supportsSync && client ? client : null, renamingKeyword, newName);
toast.success(t("category_renamed"));
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === renamingKeyword) {
setActiveCategory({ keyword: newName.trim() });
}
setRenamingKeyword(null);
} catch (err) {
console.error("Failed to rename category:", err);
toast.error(t("category_rename_failed"));
}
}}
/>
)}
{renamingAddressBook && (
<RenameDialog
currentName={renamingAddressBook.name}
title={t("address_books.rename")}
label={t("address_books.name_label")}
onCancel={() => setRenamingAddressBook(null)}
onConfirm={async (newName) => {
if (!client) return;
try {
await renameAddressBook(client, renamingAddressBook, newName);
toast.success(t("address_books.renamed"));
setRenamingAddressBook(null);
} catch (err) {
console.error("Failed to rename address book:", err);
toast.error(t("address_books.rename_failed"));
}
}}
/>
)}
{showImportDialog && ( {showImportDialog && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden"> <div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
+19 -2
View File
@@ -17,15 +17,18 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view"; import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsMobile } from "@/hooks/use-media-query"; import { useIsMobile } from "@/hooks/use-media-query";
import { usePolicyStore } from "@/stores/policy-store";
import { FileBrowser } from "@/components/files/file-browser"; import { FileBrowser } from "@/components/files/file-browser";
import { ImagePreviewModal } from "@/components/files/image-preview-modal"; import { ImagePreviewModal } from "@/components/files/image-preview-modal";
import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { loadFilesSettings } from "@/components/files/files-settings-dialog"; import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog"; import type { FolderLayout } from "@/components/files/files-settings-dialog";
import { AlertTriangle } from "lucide-react";
export default function FilesPage() { export default function FilesPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations("files"); const t = useTranslations("files");
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore(); const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
@@ -392,12 +395,25 @@ export default function FilesPage() {
</div> </div>
)} )}
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0 flex flex-col">
{supportsFiles === false ? ( {!filesEnabled ? (
<div className="flex items-center justify-center h-full">
<div className="max-w-lg text-center space-y-3 px-4">
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
<p className="text-sm font-medium">{t("disabled_title")}</p>
<p className="text-xs text-muted-foreground">{t("disabled_description")}</p>
</div>
</div>
) : supportsFiles === false ? (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">{t("not_available")}</p> <p className="text-sm text-muted-foreground">{t("not_available")}</p>
</div> </div>
) : ( ) : (
<div className="flex flex-col flex-1 min-h-0">
<div className="mx-4 mt-3 mb-1 flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 px-3 py-2">
<AlertTriangle className="w-4 h-4 text-yellow-500 shrink-0 mt-0.5" />
<p className="text-xs text-yellow-700 dark:text-yellow-400">{t("stability_warning")}</p>
</div>
<FileBrowser <FileBrowser
currentPath={currentPath} currentPath={currentPath}
resources={resources} resources={resources}
@@ -442,6 +458,7 @@ export default function FilesPage() {
onToggleDetails={handleToggleDetails} onToggleDetails={handleToggleDetails}
detailResource={detailResource} detailResource={detailResource}
/> />
</div>
)} )}
</div> </div>
</div> </div>
+88 -14
View File
@@ -10,13 +10,15 @@ import { useAuthStore } from "@/stores/auth-store";
import { useThemeStore } from "@/stores/theme-store"; import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
import { getPathPrefix } from "@/lib/browser-navigation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { 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 { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
const APP_VERSION = "1.4.9"; 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 = [ const THEME_OPTIONS = [
{ value: "light" as const, icon: Sun, label: "Light" }, { value: "light" as const, icon: Sun, label: "Light" },
@@ -24,6 +26,41 @@ const THEME_OPTIONS = [
{ value: "system" as const, icon: Monitor, label: "System" }, { 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() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations("login"); const t = useTranslations("login");
@@ -32,13 +69,14 @@ export default function LoginPage() {
const isAddAccountMode = searchParams.get("mode") === "add-account"; const isAddAccountMode = searchParams.get("mode") === "add-account";
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore(); 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 { 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 resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
username: "", username: "",
password: "", password: "",
}); });
const [jmapEndpoint, setJmapEndpoint] = useState("");
const [totpCode, setTotpCode] = useState(""); const [totpCode, setTotpCode] = useState("");
const [showTotpField, setShowTotpField] = useState(false); const [showTotpField, setShowTotpField] = useState(false);
const [rememberMe, setRememberMe] = useState(false); const [rememberMe, setRememberMe] = useState(false);
@@ -73,6 +111,12 @@ export default function LoginPage() {
} }
}, [appName, serverUrl]); }, [appName, serverUrl]);
useEffect(() => {
if (serverUrl && !jmapEndpoint) {
setJmapEndpoint(serverUrl);
}
}, [serverUrl, jmapEndpoint]);
useEffect(() => { useEffect(() => {
try { try {
if (sessionStorage.getItem('session_expired') === 'true') { if (sessionStorage.getItem('session_expired') === 'true') {
@@ -91,6 +135,14 @@ export default function LoginPage() {
prevError.current = error; prevError.current = error;
}, [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(() => { useEffect(() => {
if (!serverUrl) return; if (!serverUrl) return;
const saved = localStorage.getItem("webmail_usernames"); const saved = localStorage.getItem("webmail_usernames");
@@ -180,7 +232,8 @@ export default function LoginPage() {
const startServerSideSso = useCallback(async () => { const startServerSideSso = useCallback(async () => {
setOauthLoading(true); setOauthLoading(true);
try { 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', { const res = await fetch('/api/auth/sso/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -264,7 +317,7 @@ export default function LoginPage() {
); );
} }
if (!serverUrl && !demoMode) { if (!serverUrl && !demoMode && !allowCustomJmapEndpoint) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<div className="w-full max-w-md mx-auto px-4 text-center"> <div className="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_code_verifier", verifier);
sessionStorage.setItem("oauth_state", state); sessionStorage.setItem("oauth_state", state);
sessionStorage.setItem("oauth_server_url", serverUrl!); sessionStorage.setItem("oauth_server_url", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!);
if (isAddAccountMode) { if (isAddAccountMode) {
sessionStorage.setItem("oauth_add_account_mode", "true"); sessionStorage.setItem("oauth_add_account_mode", "true");
} }
@@ -382,8 +435,9 @@ export default function LoginPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl;
const success = await login( const success = await login(
serverUrl, effectiveServerUrl,
formData.username, formData.username,
formData.password, formData.password,
totpCode || undefined, totpCode || undefined,
@@ -563,9 +617,7 @@ export default function LoginPage() {
)} )}
</div> </div>
)} )}
<p className="text-center text-xs text-muted-foreground/40"> <VersionBadge />
v{APP_VERSION}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -749,6 +801,27 @@ export default function LoginPage() {
/* Login Form */ /* Login Form */
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<fieldset disabled={isLoading} className="space-y-4"> <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 */} {/* Username field */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label htmlFor="username" className="block text-sm font-medium text-foreground"> <label htmlFor="username" className="block text-sm font-medium text-foreground">
@@ -861,7 +934,10 @@ export default function LoginPage() {
maxLength={6} maxLength={6}
value={totpCode} value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))} 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")} placeholder={t("totp_placeholder")}
autoComplete="one-time-code" autoComplete="one-time-code"
aria-label={t("totp_label")} aria-label={t("totp_label")}
@@ -1035,9 +1111,7 @@ export default function LoginPage() {
)} )}
</div> </div>
)} )}
<p className="text-center text-xs text-muted-foreground/40"> <VersionBadge />
v{APP_VERSION}
</p>
</div> </div>
</div> </div>
</div> </div>
+204 -50
View File
@@ -14,11 +14,13 @@ import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useIdentityStore } from "@/stores/identity-store"; import { useIdentityStore } from "@/stores/identity-store";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { useDeviceDetection } from "@/hooks/use-media-query"; import { useDeviceDetection } from "@/hooks/use-media-query";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound"; import { playNotificationSound } from "@/lib/notification-sound";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -30,6 +32,7 @@ import {
ComposerErrorFallback, ComposerErrorFallback,
} from "@/components/error"; } from "@/components/error";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
import { DragDropProvider } from "@/contexts/drag-drop-context"; import { DragDropProvider } from "@/contexts/drag-drop-context";
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils"; import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
import { WelcomeBanner } from "@/components/ui/welcome-banner"; import { WelcomeBanner } from "@/components/ui/welcome-banner";
@@ -37,6 +40,7 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view"; import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIdentitySync } from "@/hooks/use-identity-sync";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview"; import { isFilePreviewable } from "@/lib/file-preview";
@@ -53,10 +57,12 @@ export default function Home() {
const t = useTranslations(); const t = useTranslations();
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const { appName } = useConfig(); const { appName } = useConfig();
const mailLayout = useSettingsStore((state) => state.mailLayout);
const [showComposer, setShowComposer] = useState(false); const [showComposer, setShowComposer] = useState(false);
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose'); const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
const [composerDraftText, setComposerDraftText] = useState(""); const [composerDraftText, setComposerDraftText] = useState("");
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null); const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
const [composerSessionId, setComposerSessionId] = useState(0);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
@@ -74,6 +80,16 @@ export default function Home() {
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null); const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
const { identities } = useIdentityStore(); 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(() => { useEffect(() => {
if (!isRateLimited || !rateLimitUntil) { if (!isRateLimited || !rateLimitUntil) {
@@ -141,6 +157,102 @@ export default function Home() {
fetchEmailContent, fetchEmailContent,
} = useEmailStore(); } = 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 // Keyboard shortcuts handlers
const keyboardHandlers = useMemo(() => ({ const keyboardHandlers = useMemo(() => ({
onNextEmail: () => { onNextEmail: () => {
@@ -350,13 +462,13 @@ export default function Home() {
if (pushEnabled) { if (pushEnabled) {
setPushConnected(true); setPushConnected(true);
debug.log('[Push] Push notifications successfully enabled'); debug.log('push', '[Push] Push notifications successfully enabled');
} else { } else {
debug.log('[Push] Push notifications not available on this server'); debug.log('push', '[Push] Push notifications not available on this server');
} }
} catch (error) { } catch (error) {
// Push notifications are optional - don't break the app if they fail // 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) { } catch (error) {
console.error('Error loading email data:', error); console.error('Error loading email data:', error);
@@ -390,7 +502,7 @@ export default function Home() {
useEffect(() => { useEffect(() => {
// Clear any existing timeout when email changes // Clear any existing timeout when email changes
if (markAsReadTimeoutRef.current) { if (markAsReadTimeoutRef.current) {
debug.log('[Mark as Read] Clearing previous timeout'); debug.log('email', '[Mark as Read] Clearing previous timeout');
clearTimeout(markAsReadTimeoutRef.current); clearTimeout(markAsReadTimeoutRef.current);
markAsReadTimeoutRef.current = null; markAsReadTimeoutRef.current = null;
} }
@@ -402,20 +514,20 @@ export default function Home() {
// Get current setting value // Get current setting value
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay; 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) { if (markAsReadDelay === -1) {
// Never mark as read automatically // 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) { } else if (markAsReadDelay === 0) {
// Mark as read instantly // 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); markAsRead(client, selectedEmail.id, true);
} else { } else {
// Mark as read after delay // 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(() => { 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); markAsRead(client, selectedEmail.id, true);
markAsReadTimeoutRef.current = null; markAsReadTimeoutRef.current = null;
}, markAsReadDelay); }, markAsReadDelay);
@@ -424,7 +536,7 @@ export default function Home() {
// Cleanup on unmount or when dependencies change // Cleanup on unmount or when dependencies change
return () => { return () => {
if (markAsReadTimeoutRef.current) { if (markAsReadTimeoutRef.current) {
debug.log('[Mark as Read] Cleanup - clearing timeout'); debug.log('email', '[Mark as Read] Cleanup - clearing timeout');
clearTimeout(markAsReadTimeoutRef.current); clearTimeout(markAsReadTimeoutRef.current);
markAsReadTimeoutRef.current = null; markAsReadTimeoutRef.current = null;
} }
@@ -439,7 +551,7 @@ export default function Home() {
if (emailNotificationsEnabled && emailNotificationSound) { if (emailNotificationsEnabled && emailNotificationSound) {
playNotificationSound(notificationSoundChoice); playNotificationSound(notificationSoundChoice);
} }
debug.log('New email received:', newEmailNotification.subject); debug.log('email', 'New email received:', newEmailNotification.subject);
clearNewEmailNotification(); clearNewEmailNotification();
} }
}, [newEmailNotification, clearNewEmailNotification]); }, [newEmailNotification, clearNewEmailNotification]);
@@ -519,7 +631,10 @@ export default function Home() {
const handleEditDraft = async (email?: Email) => { const handleEditDraft = async (email?: Email) => {
if (!client) return; if (!client) return;
let draft = email || selectedEmail; const draftCandidate = email && typeof email === 'object' && typeof email.id === 'string'
? email
: selectedEmail;
let draft = draftCandidate;
if (!draft) return; if (!draft) return;
// The email list only fetches limited properties (no bodyValues/htmlBody/bcc). // The email list only fetches limited properties (no bodyValues/htmlBody/bcc).
@@ -538,6 +653,16 @@ export default function Home() {
const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId] const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId]
? draft.bodyValues[draft.htmlBody[0].partId].value ? draft.bodyValues[draft.htmlBody[0].partId].value
: undefined; : 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({ setPendingDraft({
to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '', to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '',
cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '', cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '',
@@ -546,7 +671,7 @@ export default function Home() {
body: htmlBody || bodyText, body: htmlBody || bodyText,
showCc: (draft.cc?.length || 0) > 0, showCc: (draft.cc?.length || 0) > 0,
showBcc: (draft.bcc?.length || 0) > 0, showBcc: (draft.bcc?.length || 0) > 0,
selectedIdentityId: null, selectedIdentityId: matchedIdentity?.id ?? null,
subAddressTag: '', subAddressTag: '',
mode: 'compose', mode: 'compose',
draftId: draft.id, draftId: draft.id,
@@ -726,16 +851,22 @@ export default function Home() {
const keywords = { ...email.keywords }; const keywords = { ...email.keywords };
// Remove old label and legacy color tags - set to false for JMAP to remove them if (color === null) {
Object.keys(keywords).forEach(key => { // Remove all label/color tags
if (key.startsWith("$label:") || key.startsWith("$color:")) { Object.keys(keywords).forEach(key => {
keywords[key] = false; 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 // Update email keywords via JMAP
@@ -967,6 +1098,10 @@ export default function Home() {
// Get current mailbox name for mobile header // Get current mailbox name for mobile header
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox"; 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 // Handle email selection with mobile view switching
const handleEmailSelect = async (email: { id: string }) => { const handleEmailSelect = async (email: { id: string }) => {
@@ -1009,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 = () => { const handleMobileBack = () => {
// If in conversation view, clear it if (typeof window !== 'undefined') {
window.history.back();
return;
}
if (conversationThread) { if (conversationThread) {
setConversationThread(null); setConversationThread(null);
setConversationEmails([]); setConversationEmails([]);
} }
selectEmail(null); selectEmail(null);
if (isTablet) {
setTabletListVisible(true);
}
setActiveView("list"); setActiveView("list");
}; };
@@ -1197,12 +1342,12 @@ export default function Home() {
"max-md:flex-1 max-md:border-r-0", "max-md:flex-1 max-md:border-r-0",
isMobile && activeView !== "list" && "max-md:hidden", isMobile && activeView !== "list" && "max-md:hidden",
// Tablet/Desktop: fixed width with collapse animation // 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", !isResizing && "transition-all duration-200 ease-out",
// Tablet: collapse when email selected shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
isTablet && !tabletListVisible && "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 */} {/* Mobile Header for List View */}
<MobileHeader <MobileHeader
@@ -1487,24 +1632,27 @@ export default function Home() {
</ErrorBoundary> </ErrorBoundary>
</div> </div>
{/* Floating Compose Button (mobile) */} {/* Floating Compose Button */}
{isMobile && ( <Button
<Button onClick={() => {
onClick={() => { setComposerMode('compose');
setComposerMode('compose'); setShowComposer(true);
setShowComposer(true); if (isMobile) setActiveView('viewer');
setActiveView('viewer'); }}
}} className={cn(
className="absolute bottom-4 right-4 z-40 h-14 w-14 rounded-full shadow-lg" "absolute z-40 rounded-full shadow-lg",
aria-label={t('sidebar.compose')} isMobile ? "bottom-4 right-4 h-14 w-14" : "bottom-4 right-4 h-12 w-12"
> )}
<PenSquare className="h-6 w-6" /> aria-label={t('sidebar.compose')}
</Button> title={t('sidebar.compose_hint')}
)} data-tour="compose-button"
>
<PenSquare className={isMobile ? "h-6 w-6" : "h-5 w-5"} />
</Button>
</div> </div>
{/* Email list resize handle (desktop only) */} {/* Email list resize handle (desktop only) */}
{!isMobile && !isTablet && ( {!isMobile && !isTablet && !isFocusedMailLayout && (
<ResizeHandle <ResizeHandle
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }} onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)} onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
@@ -1521,7 +1669,8 @@ export default function Home() {
"max-md:fixed max-md:inset-0 max-md:z-30", "max-md:fixed max-md:inset-0 max-md:z-30",
isMobile && activeView !== "viewer" && "max-md:hidden", isMobile && activeView !== "viewer" && "max-md:hidden",
// Tablet/Desktop: relative // Tablet/Desktop: relative
"md:relative" "md:relative",
shouldHideViewerPane && "md:hidden"
)} )}
> >
{/* Inline Composer - shown in viewer pane */} {/* Inline Composer - shown in viewer pane */}
@@ -1534,11 +1683,14 @@ export default function Home() {
}} }}
> >
<EmailComposer <EmailComposer
key={composerSessionId}
mode={pendingDraft?.mode ?? composerMode} mode={pendingDraft?.mode ?? composerMode}
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? { replyTo={pendingDraft !== null ? pendingDraft.replyTo : (selectedEmail ? {
from: selectedEmail.from, from: selectedEmail.from,
replyToAddresses: selectedEmail.replyTo,
to: selectedEmail.to, to: selectedEmail.to,
cc: selectedEmail.cc, cc: selectedEmail.cc,
bcc: selectedEmail.bcc,
subject: selectedEmail.subject, subject: selectedEmail.subject,
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '', body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined, htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
@@ -1636,7 +1788,7 @@ export default function Home() {
onReplyAll={handleReplyAll} onReplyAll={handleReplyAll}
onForward={handleForward} onForward={handleForward}
onDelete={handleDelete} onDelete={handleDelete}
onArchive={handleArchive} onArchive={() => handleArchive()}
onToggleStar={handleToggleStar} onToggleStar={handleToggleStar}
onSetColorTag={handleSetColorTag} onSetColorTag={handleSetColorTag}
onMarkAsSpam={handleMarkAsSpam} onMarkAsSpam={handleMarkAsSpam}
@@ -1648,14 +1800,15 @@ export default function Home() {
}} }}
onDownloadAttachment={handleDownloadAttachment} onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply} onQuickReply={handleQuickReply}
onBack={() => { onBack={handleMobileBack}
setTabletListVisible(true);
selectEmail(null);
}}
onNavigateNext={handleNavigateNext} onNavigateNext={handleNavigateNext}
onNavigatePrev={handleNavigatePrev} onNavigatePrev={handleNavigatePrev}
onShowShortcuts={() => setShowShortcutsModal(true)} onShowShortcuts={() => setShowShortcutsModal(true)}
onEditDraft={handleEditDraft} onEditDraft={handleEditDraft}
onCompose={() => {
setComposerMode('compose');
setShowComposer(true);
}}
currentUserEmail={client?.getUsername()} currentUserEmail={client?.getUsername()}
currentUserName={client?.getUsername()?.split("@")[0]} currentUserName={client?.getUsername()?.split("@")[0]}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role} currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
@@ -1709,6 +1862,7 @@ export default function Home() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
<TotpReauthDialog />
</div> </div>
</DragDropProvider> </DragDropProvider>
); );
+2 -1
View File
@@ -36,6 +36,7 @@ import { IdentitySettings } from '@/components/settings/identity-settings';
import { VacationSettings } from '@/components/settings/vacation-settings'; import { VacationSettings } from '@/components/settings/vacation-settings';
import { CalendarSettings } from '@/components/settings/calendar-settings'; import { CalendarSettings } from '@/components/settings/calendar-settings';
import { CalendarManagementSettings } from '@/components/settings/calendar-management-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 { FilterSettings } from '@/components/settings/filter-settings';
import { TemplateSettings } from '@/components/settings/template-settings'; import { TemplateSettings } from '@/components/settings/template-settings';
import { AdvancedSettings } from '@/components/settings/advanced-settings'; import { AdvancedSettings } from '@/components/settings/advanced-settings';
@@ -211,7 +212,7 @@ export default function SettingsPage() {
{activeTab === 'encryption' && <SmimeSettings />} {activeTab === 'encryption' && <SmimeSettings />}
{activeTab === 'vacation' && <VacationSettings />} {activeTab === 'vacation' && <VacationSettings />}
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>} {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 === 'filters' && <FilterSettings />}
{activeTab === 'templates' && <TemplateSettings />} {activeTab === 'templates' && <TemplateSettings />}
{activeTab === 'folders' && <FolderSettings />} {activeTab === 'folders' && <FolderSettings />}
+2 -7
View File
@@ -24,6 +24,7 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store'; import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
@@ -78,13 +79,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
}, [pathname]); }, [pathname]);
function getJmapHeaders(): Record<string, string> { function getJmapHeaders(): Record<string, string> {
const client = useAuthStore.getState().client; return getActiveAccountSlotHeaders();
if (!client) return {};
return {
'Authorization': client.getAuthHeader(),
'X-JMAP-Server-URL': client.getServerUrl(),
'X-JMAP-Username': client.getUsername(),
};
} }
async function checkAuth() { async function checkAuth() {
+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>
);
}
+30 -1
View File
@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen } from 'lucide-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 type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY } from '@/lib/admin/types'; import { DEFAULT_POLICY } from '@/lib/admin/types';
@@ -59,6 +60,15 @@ export default function AdminPluginsPage() {
setMessage(null); setMessage(null);
} }
function toggleRequirePluginApproval() {
setPolicy(prev => ({
...prev,
features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval },
}));
setPolicyDirty(true);
setMessage(null);
}
async function handleSavePolicy() { async function handleSavePolicy() {
setSavingPolicy(true); setSavingPolicy(true);
setMessage(null); setMessage(null);
@@ -247,6 +257,7 @@ export default function AdminPluginsPage() {
const pluginsEnabled = policy.features.pluginsEnabled ?? true; const pluginsEnabled = policy.features.pluginsEnabled ?? true;
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true; const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
const requirePluginApproval = policy.features.requirePluginApproval ?? true;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -319,6 +330,17 @@ export default function AdminPluginsPage() {
</button> </button>
</div> </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 */} {/* Force enable / disable all */}
{plugins.length > 0 && ( {plugins.length > 0 && (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex items-center justify-between gap-4">
@@ -396,6 +418,13 @@ export default function AdminPluginsPage() {
</div> </div>
<div className="flex items-center gap-2"> <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 <button
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)} onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'} title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
+3
View File
@@ -19,6 +19,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' }, debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' }, folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' }, hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
}; };
const RESTRICTABLE_SETTINGS = [ const RESTRICTABLE_SETTINGS = [
@@ -28,10 +29,12 @@ const RESTRICTABLE_SETTINGS = [
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' }, { key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] }, { key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' }, { 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: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] }, { key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' }, { key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] }, { 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: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' }, { key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' }, { key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
+8
View File
@@ -112,6 +112,14 @@ export default function AdminSettingsPage() {
<SettingsSection title="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="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" /> <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} /> <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" /> <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} /> <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} />
+17 -2
View File
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials'; 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 * GET /api/account/stalwart/auth
* Proxy to Stalwart GET /api/account/auth * Proxy to Stalwart GET /api/account/auth
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
if (!response.ok) { if (!response.ok) {
const text = await response.text(); 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( return NextResponse.json(
{ error: 'Failed to fetch auth info', details: text }, { error: detail || 'Failed to fetch auth info' },
{ status: response.status } { status: response.status }
); );
} }
+17 -2
View File
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials'; 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 * GET /api/account/stalwart/crypto
* Proxy to Stalwart GET /api/account/crypto * Proxy to Stalwart GET /api/account/crypto
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
if (!response.ok) { if (!response.ok) {
const text = await response.text(); 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( return NextResponse.json(
{ error: 'Failed to fetch crypto info', details: text }, { error: detail || 'Failed to fetch crypto info' },
{ status: response.status } { status: response.status }
); );
} }
+13 -3
View File
@@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { encryptSession } from '@/lib/auth/crypto'; 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 { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
const COOKIE_OPTIONS = { const COOKIE_OPTIONS = {
httpOnly: true, httpOnly: true,
@@ -69,10 +70,19 @@ export async function POST(request: NextRequest) {
} }
// If session cookie exists, update it with the new password // If session cookie exists, update it with the new password
const cookieStore = await cookies();
if (creds.hasSessionCookie) { if (creds.hasSessionCookie) {
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword); const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
const cookieStore = await cookies(); cookieStore.set(sessionCookieName(creds.slot), newToken, COOKIE_OPTIONS);
cookieStore.set(SESSION_COOKIE, 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 }); 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 { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials'; 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 * GET /api/account/stalwart/principal
* Proxy to Stalwart GET /api/principal/{username} * Proxy to Stalwart GET /api/principal/{username}
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
if (!response.ok) { if (!response.ok) {
const text = await response.text(); 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( return NextResponse.json(
{ error: 'Failed to fetch principal', details: text }, { error: detail || 'Failed to fetch principal' },
{ status: response.status } { status: response.status }
); );
} }
+20
View File
@@ -196,6 +196,26 @@ export async function POST(request: NextRequest) {
const code = await jsFile.async('string'); 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 // Validate permissions
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : []; const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]); const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
+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 });
}
}
+12 -3
View File
@@ -139,12 +139,18 @@ export async function POST(request: NextRequest) {
} }
const code = await entryFile.async('string'); const code = await entryFile.async('string');
// Security warnings (logged but not blocking for admin) // Security: block plugins containing dangerous JS patterns
const warnings: string[] = []; const warnings: string[] = [];
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) { for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
if (pattern.test(code)) warnings.push(`Contains ${label}`); if (pattern.test(code)) warnings.push(`Contains ${label}`);
pattern.lastIndex = 0; 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 now = new Date().toISOString();
const plugin: ServerPlugin = { const plugin: ServerPlugin = {
@@ -157,14 +163,17 @@ export async function POST(request: NextRequest) {
permissions: (manifest.permissions as string[]) || [], permissions: (manifest.permissions as string[]) || [],
entrypoint: manifest.entrypoint as string, entrypoint: manifest.entrypoint as string,
enabled: true, enabled: true,
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
: {}),
installedAt: now, installedAt: now,
updatedAt: now, updatedAt: now,
}; };
await savePlugin(plugin, code); await savePlugin(plugin, code);
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, warnings }, ip); await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip);
return NextResponse.json({ plugin, warnings }); return NextResponse.json({ plugin });
} catch (error) { } catch (error) {
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' }); logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+76 -1
View File
@@ -4,6 +4,11 @@ import { logger } from '@/lib/logger';
import { encryptSession, decryptSession } from '@/lib/auth/crypto'; import { encryptSession, decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie'; import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; 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 = { const COOKIE_OPTIONS = {
...getCookieOptions(), ...getCookieOptions(),
@@ -31,12 +36,23 @@ export async function POST(request: NextRequest) {
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request); const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
const cookieName = sessionCookieName(slot); 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(); const cookieStore = await cookies();
cookieStore.set(cookieName, token, COOKIE_OPTIONS); cookieStore.set(cookieName, token, COOKIE_OPTIONS);
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: normalizedServerUrl,
username,
authHeader,
});
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) { } 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' }); logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
} }
@@ -56,9 +72,66 @@ export async function GET(request: NextRequest) {
const credentials = decryptSession(token); const credentials = decryptSession(token);
if (!credentials) { if (!credentials) {
cookieStore.delete(cookieName); cookieStore.delete(cookieName);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 }); 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, { return NextResponse.json(credentials, {
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' }, 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) // Delete all session cookies (slots 0-4)
for (let i = 0; i <= 4; i++) { for (let i = 0; i <= 4; i++) {
cookieStore.delete(sessionCookieName(i)); cookieStore.delete(sessionCookieName(i));
clearStalwartAuthContextInStore(cookieStore, i);
} }
} else { } else {
const slot = getSlot(request); const slot = getSlot(request);
cookieStore.delete(sessionCookieName(slot)); cookieStore.delete(sessionCookieName(slot));
clearStalwartAuthContextInStore(cookieStore, slot);
} }
return NextResponse.json({ ok: true }); 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 { discoverOAuth } from '@/lib/oauth/discovery';
import { OAUTH_SCOPES } from '@/lib/oauth/tokens'; import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
const SSO_PENDING_COOKIE = 'sso_pending'; const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE = 300; // 5 minutes const SSO_PENDING_MAX_AGE = 300; // 5 minutes
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { 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 }); 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,
});
}
+4 -2
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env';
/** /**
* Runtime configuration endpoint * Runtime configuration endpoint
@@ -33,8 +34,8 @@ export async function GET() {
oauthOnly, oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''), oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''), oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
rememberMeEnabled: !!process.env.SESSION_SECRET, rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && !!process.env.SESSION_SECRET, settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
stalwartFeaturesEnabled, stalwartFeaturesEnabled,
devMode: configManager.get<boolean>('devMode', false), devMode: configManager.get<boolean>('devMode', false),
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'), faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
@@ -47,6 +48,7 @@ export async function GET() {
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''), loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''), loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
demoMode: configManager.get<boolean>('demoMode', false), demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false), autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'", embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get<string>('parentOrigin', ''), parentOrigin: configManager.get<string>('parentOrigin', ''),
+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'; import { NextRequest, NextResponse } from 'next/server';
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const FETCH_TIMEOUT_MS = 15000; 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; let url: URL;
try { try {
url = new URL(urlString); url = new URL(urlString);
@@ -15,21 +45,16 @@ function isValidExternalUrl(urlString: string): boolean {
return false; return false;
} }
const hostname = url.hostname.toLowerCase(); const hostname = normalizeHostname(url.hostname);
// Block private/internal hostnames // Block private/internal hostnames
if ( if (
hostname === 'localhost' || hostname === 'localhost' ||
hostname === '127.0.0.1' || hostname.endsWith('.localhost') ||
hostname === '::1' ||
hostname === '0.0.0.0' ||
hostname.endsWith('.local') || hostname.endsWith('.local') ||
hostname.endsWith('.internal') || hostname.endsWith('.internal') ||
hostname.endsWith('.arpa') || hostname.endsWith('.arpa') ||
hostname.startsWith('10.') || hostname.endsWith('.localdomain')
hostname.startsWith('192.168.') ||
hostname.startsWith('169.254.') ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
) { ) {
return false; return false;
} }
@@ -39,7 +64,24 @@ function isValidExternalUrl(urlString: string): boolean {
return false; 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) { 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 }); 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 }); 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 controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const response = await fetch(url, { const MAX_REDIRECTS = 5;
signal: controller.signal, let currentUrl = url;
headers: { let response: Response | undefined;
'Accept': 'text/calendar, application/ics, text/plain, */*',
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher', for (let i = 0; i <= MAX_REDIRECTS; i++) {
}, if (!(await isValidExternalUrl(currentUrl))) {
redirect: 'follow', 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); clearTimeout(timeout);
if (!response.ok) { if (!response || !response.ok) {
return NextResponse.json( return NextResponse.json(
{ error: `Remote server returned ${response.status}` }, { error: `Remote server returned ${response?.status ?? 'unknown'}` },
{ status: 502 } { status: 502 }
); );
} }
+24 -12
View File
@@ -3,8 +3,10 @@ import { cookies } from 'next/headers';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { decryptSession } from '@/lib/auth/crypto'; import { decryptSession } from '@/lib/auth/crypto';
import { sessionCookieName } from '@/lib/auth/session-cookie'; import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync'; import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env';
function classifyError(error: unknown): { message: string; status: number } { function classifyError(error: unknown): { message: string; status: number } {
const code = (error as NodeJS.ErrnoException).code; const code = (error as NodeJS.ErrnoException).code;
@@ -47,33 +49,43 @@ function classifyError(error: unknown): { message: string; status: number } {
} }
function isEnabled(): boolean { 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. * Verify identity against session cookies across all account slots.
* With multi-account, the requesting account may be on any slot (0-4). * 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> { async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
const cookieStore = await cookies(); const cookieStore = await cookies();
let hasAnyCookie = false; const normalizedServerUrl = normalizeUrl(serverUrl);
for (let slot = 0; slot <= 4; slot++) { for (let slot = 0; slot <= 4; slot++) {
// Check basic-auth session cookie
const token = cookieStore.get(sessionCookieName(slot))?.value; const token = cookieStore.get(sessionCookieName(slot))?.value;
if (!token) continue; if (token) {
hasAnyCookie = true; const session = decryptSession(token);
if (session && session.username === username && normalizeUrl(session.serverUrl) === normalizedServerUrl) {
return true;
}
}
const session = decryptSession(token); // Check stalwart auth context cookie (set for all auth modes)
if (session && session.username === username && session.serverUrl === serverUrl) { const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
return true; // Found a matching slot if (ctx && ctx.username === username && normalizeUrl(ctx.serverUrl) === normalizedServerUrl) {
return true;
} }
} }
// No cookies at all → can't verify, allow (same-origin protection applies) // No matching session found (or no cookies at all)reject
if (!hasAnyCookie) return true;
// Cookies exist but none matched → identity mismatch
return false; return false;
} }
+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']); 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 * POST /api/webdav
* Proxies WebDAV requests to the Stalwart server. * 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 davPath = request.headers.get('X-WebDAV-Path') || '/';
const cleanPath = davPath.replace(/^\/+/, '');
const baseUrl = creds.apiUrl.replace(/\/$/, ''); const baseUrl = creds.apiUrl.replace(/\/$/, '');
const targetUrl = cleanPath const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanPath}`
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
// Build headers for the upstream request // Build headers for the upstream request
const upstreamHeaders: Record<string, string> = { 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 // For MOVE/COPY, construct the full Destination URL from the relative path
const destination = request.headers.get('X-WebDAV-Destination'); const destination = request.headers.get('X-WebDAV-Destination');
if (destination) { if (destination) {
const cleanDest = destination.replace(/^\/+/, ''); upstreamHeaders['Destination'] = buildDavTargetUrl(baseUrl, creds.username, destination);
upstreamHeaders['Destination'] = cleanDest
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanDest}`
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
} }
const overwrite = request.headers.get('Overwrite'); const overwrite = request.headers.get('Overwrite');
if (overwrite) upstreamHeaders['Overwrite'] = overwrite; if (overwrite) upstreamHeaders['Overwrite'] = overwrite;
// Forward request body for methods that need it // Forward request body for methods that need it.
let body: ArrayBuffer | null = null; // PUT streams directly to upstream to avoid buffering large uploads in memory.
if (method === 'PROPFIND' || method === 'PUT') { // PROPFIND bodies are small XML and are read fully.
let body: ArrayBuffer | ReadableStream<Uint8Array> | null = null;
if (method === 'PROPFIND') {
body = await request.arrayBuffer(); body = await request.arrayBuffer();
} else if (method === 'PUT') {
body = request.body;
} }
const response = await fetch(targetUrl, { const response = await fetch(targetUrl, {
@@ -70,7 +94,9 @@ export async function POST(request: NextRequest) {
headers: upstreamHeaders, headers: upstreamHeaders,
body, body,
redirect: 'follow', 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 // For file downloads (GET), stream the response back
if (method === 'GET') { if (method === 'GET') {
@@ -104,6 +130,10 @@ export async function POST(request: NextRequest) {
status: response.status, status: response.status,
}); });
} catch (error) { } 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' }); logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
} }
+5
View File
@@ -208,6 +208,11 @@ body {
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
} }
.email-content-text a {
color: var(--color-primary);
text-decoration: underline;
}
.email-content { .email-content {
font-family: font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
+19 -2
View File
@@ -1,3 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?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">
<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> <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 { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { getLocale } from "next-intl/server"; import { getLocale } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -20,6 +22,15 @@ export async function generateMetadata(): Promise<Metadata> {
return { return {
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail", title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
description: "Minimalist webmail client using JMAP protocol", 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 } } : {}), ...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
}; };
} }
@@ -36,6 +47,14 @@ export default async function RootLayout({
return ( return (
<html lang={locale} suppressHydrationWarning> <html lang={locale} suppressHydrationWarning>
<head> <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 && ( {parentOrigin && (
<meta name="parent-origin" content={parentOrigin} /> <meta name="parent-origin" content={parentOrigin} />
)} )}
@@ -63,7 +82,9 @@ export default async function RootLayout({
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
<ServiceWorkerRegistration />
{children} {children}
<PWAInstallPrompt />
</body> </body>
</html> </html>
); );
+5 -5
View File
@@ -2,11 +2,11 @@
import { useMemo, useRef, useEffect, useCallback } from "react"; import { useMemo, useRef, useEffect, useCallback } from "react";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { format, parseISO, isToday, isTomorrow, startOfDay } from "date-fns"; import { format, isToday, isTomorrow, startOfDay } from "date-fns";
import { MapPin, Users } from "lucide-react"; import { MapPin, Users } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { parseDuration, getEventColor } from "./event-card"; import { 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 { getParticipantCount } from "@/lib/calendar-participants";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
@@ -49,7 +49,7 @@ export function CalendarAgendaView({
const grouped = useMemo(() => { const grouped = useMemo(() => {
const sorted = [...events].sort((a, b) => const sorted = [...events].sort((a, b) =>
parseISO(a.start).getTime() - parseISO(b.start).getTime() getEventStartDate(a).getTime() - getEventStartDate(b).getTime()
); );
const groups: DayGroup[] = []; const groups: DayGroup[] = [];
@@ -144,9 +144,9 @@ export function CalendarAgendaView({
const calId = getPrimaryCalendarId(ev); const calId = getPrimaryCalendarId(ev);
const calendar = calId ? calendarMap.get(calId) : undefined; const calendar = calId ? calendarMap.get(calId) : undefined;
const color = getEventColor(ev, calendar); const color = getEventColor(ev, calendar);
const start = parseISO(ev.start); const start = getEventStartDate(ev);
const durMin = parseDuration(ev.duration); const durMin = parseDuration(ev.duration);
const end = new Date(start.getTime() + durMin * 60000); const end = getEventEndDate(ev);
const locationName = ev.locations const locationName = ev.locations
? Object.values(ev.locations)[0]?.name ? Object.values(ev.locations)[0]?.name
: null; : null;
+9 -11
View File
@@ -5,9 +5,9 @@ import { useTranslations, useFormatter } from "next-intl";
import { format, isSameDay, isToday, parseISO } from "date-fns"; import { format, isSameDay, isToday, parseISO } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { EventCard, parseDuration } from "./event-card"; import { EventCard } from "./event-card";
import { QuickEventInput } from "./quick-event-input"; 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 type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal"; import type { PendingEventPreview } from "./event-modal";
@@ -66,7 +66,7 @@ export function CalendarDayView({
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime(); const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
if (!spansThisDay) return; if (!spansThisDay) return;
if (ev.showWithoutTime) allDay.push(ev); if (ev.showWithoutTime || isTimedEventFullDayOnDate(ev, selectedDate)) allDay.push(ev);
else timed.push(ev); else timed.push(ev);
} catch { /* skip invalid dates */ } } catch { /* skip invalid dates */ }
}); });
@@ -127,10 +127,10 @@ export function CalendarDayView({
return format(new Date(2000, 0, 1, h), "HH:mm"); 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 ( 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")}> <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")}> <h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
{isMobile {isMobile
@@ -200,7 +200,7 @@ export function CalendarDayView({
</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="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}> <div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
{HOURS.map((h) => ( {HOURS.map((h) => (
@@ -241,11 +241,9 @@ export function CalendarDayView({
/> />
))} ))}
{layouted.map(({ event: ev, column, totalColumns }) => { {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const start = parseISO(ev.start); const durMin = Math.max(15, endMinutes - startMinutes);
const startMin = start.getHours() * 60 + start.getMinutes(); const top = (startMinutes / 60) * HOUR_HEIGHT;
const durMin = Math.max(15, parseDuration(ev.duration));
const top = (startMin / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT); const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight; const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev); const calId = getPrimaryCalendarId(ev);
+17 -1
View File
@@ -2,13 +2,14 @@
import { useState, useRef, useEffect, useMemo } from "react"; import { useState, useRef, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Globe, ListTodo, RefreshCw, Share2, Trash2 } from "lucide-react"; import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils"; import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types"; import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useTaskStore } from "@/stores/task-store"; import { useTaskStore } from "@/stores/task-store";
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { IJMAPClient } from '@/lib/jmap/client-interface';
@@ -18,6 +19,7 @@ interface CalendarSidebarPanelProps {
onToggleVisibility: (id: string) => void; onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void; onColorChange?: (calendarId: string, color: string) => void;
onSubscribe?: () => void; onSubscribe?: () => void;
onEditSubscription?: (subscriptionId: string) => void;
client?: IJMAPClient | null; client?: IJMAPClient | null;
} }
@@ -27,6 +29,7 @@ export function CalendarSidebarPanel({
onToggleVisibility, onToggleVisibility,
onColorChange, onColorChange,
onSubscribe, onSubscribe,
onEditSubscription,
client, client,
}: CalendarSidebarPanelProps) { }: CalendarSidebarPanelProps) {
const t = useTranslations("calendar"); 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> </button>
{/* Subscription context menu on right-click */} {/* Subscription context menu on right-click */}
@@ -173,6 +179,16 @@ export function CalendarSidebarPanel({
ref={contextMenuRef} 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" 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 <button
onClick={() => handleRefreshSubscription(sub.id)} onClick={() => handleRefreshSubscription(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors" className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
+22 -16
View File
@@ -7,9 +7,9 @@ import {
} from "date-fns"; } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { EventCard, parseDuration } from "./event-card"; import { EventCard } from "./event-card";
import { QuickEventInput } from "./quick-event-input"; 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 type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal"; import type { PendingEventPreview } from "./event-modal";
@@ -77,7 +77,7 @@ export function CalendarWeekView({
const cursor = new Date(startDay); const cursor = new Date(startDay);
while (cursor <= endDay) { while (cursor <= endDay) {
const key = format(cursor, "yyyy-MM-dd"); const key = format(cursor, "yyyy-MM-dd");
if (!ev.showWithoutTime) { if (!ev.showWithoutTime && !isTimedEventFullDayOnDate(ev, cursor)) {
const arr = timed.get(key) || []; const arr = timed.get(key) || [];
arr.push(ev); arr.push(ev);
timed.set(key, arr); timed.set(key, arr);
@@ -89,10 +89,18 @@ export function CalendarWeekView({
return timed; return timed;
}, [events]); }, [events]);
const allDaySegments = useMemo(() => buildWeekSegments( const allDaySegments = useMemo(() => {
events.filter((event) => event.showWithoutTime), const explicitAllDay = buildWeekSegmentsRaw(
weekDays, events.filter((event) => event.showWithoutTime),
), [events, weekDays]); weekDays,
);
const timedFullDay = buildTimedFullDayWeekSegments(
events.filter((event) => !event.showWithoutTime),
weekDays,
);
return packWeekSegments([...explicitAllDay, ...timedFullDay]);
}, [events, weekDays]);
const allDayRowCount = useMemo(() => { const allDayRowCount = useMemo(() => {
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0); return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
@@ -189,13 +197,13 @@ export function CalendarWeekView({
<div <div
ref={rootRef} ref={rootRef}
className={cn( className={cn(
"flex flex-col flex-1", "flex min-h-0 min-w-0 flex-col flex-1",
isMobile ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden" isMobile ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden"
)} )}
role="grid" role="grid"
aria-label={t("views.week")} aria-label={t("views.week")}
> >
<div className={cn("flex flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && ( <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="flex border-b border-border">
<div <div
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")} className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
@@ -321,7 +329,7 @@ export function CalendarWeekView({
</div> </div>
</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="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}> <div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}>
{HOURS.map((h) => ( {HOURS.map((h) => (
@@ -344,7 +352,7 @@ export function CalendarWeekView({
const key = format(day, "yyyy-MM-dd"); const key = format(day, "yyyy-MM-dd");
const dayEvents = timedEvents.get(key) || []; const dayEvents = timedEvents.get(key) || [];
const todayCol = isToday(day); const todayCol = isToday(day);
const layouted = layoutOverlappingEvents(dayEvents); const layouted = layoutOverlappingEvents(dayEvents, day);
return ( return (
<div <div
@@ -371,11 +379,9 @@ export function CalendarWeekView({
/> />
))} ))}
{layouted.map(({ event: ev, column, totalColumns }) => { {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const start = parseISO(ev.start); const durMin = Math.max(15, endMinutes - startMinutes);
const startMin = start.getHours() * 60 + start.getMinutes(); const top = (startMinutes / 60) * HOUR_HEIGHT;
const durMin = Math.max(15, parseDuration(ev.duration));
const top = (startMin / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT); const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight; const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev); const calId = getPrimaryCalendarId(ev);
+4 -3
View File
@@ -4,9 +4,10 @@ import { useCallback, useState, type CSSProperties, type DragEvent } from "react
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; 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 { Users } from "lucide-react";
import { getParticipantCount } from "@/lib/calendar-participants"; import { getParticipantCount } from "@/lib/calendar-participants";
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
interface EventCardProps { interface EventCardProps {
@@ -68,14 +69,14 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const [isBeingDragged, setIsBeingDragged] = useState(false); const [isBeingDragged, setIsBeingDragged] = useState(false);
const color = getEventColor(event, calendar); const color = getEventColor(event, calendar);
const startDate = parseISO(event.start); const startDate = getEventStartDate(event);
const timeFormat = useSettingsStore((state) => state.timeFormat); const timeFormat = useSettingsStore((state) => state.timeFormat);
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView); const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const calendarName = calendar?.name || ""; const calendarName = calendar?.name || "";
const durationMinutes = parseDuration(event.duration); 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 timeString = `${format(startDate, timeFmt)} ${format(endTime, timeFmt)}`;
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`; const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
+4 -3
View File
@@ -12,6 +12,7 @@ import { format, parseISO } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card"; import { parseDuration, getEventColor } from "./event-card";
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { import {
isOrganizer, isOrganizer,
getUserParticipantId, getUserParticipantId,
@@ -87,7 +88,7 @@ function formatDurationDisplay(minutes: number): string {
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null { function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
if (!event.alerts) return null; if (!event.alerts) return null;
const first = Object.values(event.alerts)[0]; 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; const offset = first.trigger.offset;
if (offset === "PT0S") return t("alerts.at_time"); if (offset === "PT0S") return t("alerts.at_time");
const minMatch = offset.match(/-?PT?(\d+)M$/); const minMatch = offset.match(/-?PT?(\d+)M$/);
@@ -138,9 +139,9 @@ export function EventDetailPopover({
const [isSavingNote, setIsSavingNote] = useState(false); const [isSavingNote, setIsSavingNote] = useState(false);
const color = getEventColor(event, calendar); const color = getEventColor(event, calendar);
const startDate = parseISO(event.start); const startDate = getEventStartDate(event);
const durationMinutes = parseDuration(event.duration); const durationMinutes = parseDuration(event.duration);
const endDate = new Date(startDate.getTime() + durationMinutes * 60000); const endDate = getEventEndDate(event);
const locationName = useMemo(() => { const locationName = useMemo(() => {
if (!event.locations) return null; if (!event.locations) return null;
+63 -14
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 { format, parseISO, addHours, addDays } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card"; import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { ParticipantInput } from "./participant-input"; import { ParticipantInput } from "./participant-input";
import { import {
isOrganizer, isOrganizer,
@@ -18,7 +18,9 @@ import {
getStatusCounts, getStatusCounts,
buildParticipantMap, buildParticipantMap,
} from "@/lib/calendar-participants"; } from "@/lib/calendar-participants";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils";
export interface PendingEventPreview { export interface PendingEventPreview {
start: Date; start: Date;
@@ -159,7 +161,7 @@ export function EventModal({
}, [event, existingParticipants]); }, [event, existingParticipants]);
const getInitialStart = (): Date => { const getInitialStart = (): Date => {
if (event?.start) return parseISO(event.start); if (event?.start) return getEventStartDate(event);
if (defaultDate) { if (defaultDate) {
const d = new Date(defaultDate); const d = new Date(defaultDate);
if (defaultEndDate) return d; if (defaultEndDate) return d;
@@ -177,9 +179,7 @@ export function EventModal({
if (event.showWithoutTime) { if (event.showWithoutTime) {
return getEventDisplayEndDate(event); return getEventDisplayEndDate(event);
} }
const s = parseISO(event.start); return getEventEndDate(event);
const dur = parseDuration(event.duration);
return new Date(s.getTime() + dur * 60000);
} }
if (defaultEndDate) return new Date(defaultEndDate); if (defaultEndDate) return new Date(defaultEndDate);
return addHours(getInitialStart(), 1); return addHours(getInitialStart(), 1);
@@ -190,6 +190,9 @@ export function EventModal({
const [location, setLocation] = useState( const [location, setLocation] = useState(
event?.locations ? Object.values(event.locations)[0]?.name || "" : "" 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 [startDate, setStartDate] = useState(formatDateInput(getInitialStart()));
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart())); const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd())); const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
@@ -315,6 +318,20 @@ export function EventModal({
data.locations = null; 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") { if (recurrence !== "none") {
data.recurrenceRules = [{ data.recurrenceRules = [{
"@type": "RecurrenceRule", "@type": "RecurrenceRule",
@@ -374,7 +391,7 @@ export function EventModal({
} finally { } finally {
setIsSaving(false); 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']) => { const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return; if (!event || !userParticipantId || !onRsvp) return;
@@ -384,11 +401,9 @@ export function EventModal({
const handleDuplicate = useCallback(() => { const handleDuplicate = useCallback(() => {
if (!event || !onDuplicate) return; if (!event || !onDuplicate) return;
const start = parseISO(event.start); const start = getEventStartDate(event);
const newStart = addDays(start, 1); const newStart = addDays(start, 1);
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID const newUid = generateUUID();
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const data: Partial<CalendarEvent> = { const data: Partial<CalendarEvent> = {
uid: newUid, uid: newUid,
title: event.title, title: event.title,
@@ -403,6 +418,7 @@ export function EventModal({
privacy: event.privacy, privacy: event.privacy,
}; };
if (event.locations) data.locations = structuredClone(event.locations); 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.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
if (event.alerts) data.alerts = structuredClone(event.alerts); if (event.alerts) data.alerts = structuredClone(event.alerts);
if (event.participants) data.participants = structuredClone(event.participants); if (event.participants) data.participants = structuredClone(event.participants);
@@ -456,9 +472,9 @@ export function EventModal({
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0); const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
if (isAttendeeMode && event) { if (isAttendeeMode && event) {
const startD = parseISO(event.start); const startD = getEventStartDate(event);
const durMin = parseDuration(event.duration); 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 locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
const participants = getParticipantList(event); const participants = getParticipantList(event);
@@ -567,9 +583,9 @@ export function EventModal({
// View mode: read-only display of event details with Edit button // View mode: read-only display of event details with Edit button
if (mode === "view" && event) { if (mode === "view" && event) {
const startD = parseISO(event.start); const startD = getEventStartDate(event);
const durMin = parseDuration(event.duration); 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 locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null; const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
const viewParticipants = getParticipantList(event); const viewParticipants = getParticipantList(event);
@@ -780,6 +796,39 @@ export function EventModal({
/> />
</div> </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> <div>
<label className="text-sm font-medium mb-1 block"> <label className="text-sm font-medium mb-1 block">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
+3 -2
View File
@@ -7,6 +7,7 @@ import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
import { format, parseISO } from "date-fns"; import { format, parseISO } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { getEventStartDate } from "@/lib/calendar-utils";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
@@ -17,7 +18,7 @@ interface ICalImportModalProps {
onClose: () => void; onClose: () => void;
} }
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ACCEPTED_EXTENSIONS = [".ics", ".ical"]; const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
type ImportStep = "select" | "preview" | "importing"; type ImportStep = "select" | "preview" | "importing";
@@ -195,7 +196,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
const formatEventDate = (event: Partial<CalendarEvent>): string => { const formatEventDate = (event: Partial<CalendarEvent>): string => {
if (!event.start) return ""; if (!event.start) return "";
try { try {
const date = parseISO(event.start); const date = getEventStartDate(event as CalendarEvent);
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
return event.showWithoutTime return event.showWithoutTime
? format(date, "MMM d, yyyy") ? format(date, "MMM d, yyyy")
+29 -14
View File
@@ -5,24 +5,28 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { X, Loader2, Globe } from "lucide-react"; import { X, Loader2, Globe } from "lucide-react";
import type { IJMAPClient } from '@/lib/jmap/client-interface'; 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 { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
interface ICalSubscriptionModalProps { interface ICalSubscriptionModalProps {
client: IJMAPClient; client: IJMAPClient;
onClose: () => void; onClose: () => void;
editSubscription?: ICalSubscription;
} }
export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) { export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) {
const t = useTranslations("calendar.subscription"); const t = useTranslations("calendar.subscription");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription); const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
const updateICalSubscription = useCalendarStore((s) => s.updateICalSubscription);
const [url, setUrl] = useState(""); const isEdit = !!editSubscription;
const [name, setName] = useState("");
const [color, setColor] = useState("#3b82f6"); const [url, setUrl] = useState(editSubscription?.url || "");
const [refreshInterval, setRefreshInterval] = useState(60); 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 [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
@@ -49,15 +53,26 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval); if (isEdit && editSubscription) {
if (subscription) { const updates: { url?: string; name?: string; color?: string; refreshInterval?: number } = {};
toast.success(t("success", { name: name.trim() })); 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(); onClose();
} else { } 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 { } catch {
setError(t("error")); setError(isEdit ? t("update_error") : t("error"));
} finally { } finally {
setIsSubmitting(false); 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 justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-primary" /> <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> </div>
<button <button
onClick={onClose} onClick={onClose}
@@ -192,10 +207,10 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
{isSubmitting ? ( {isSubmitting ? (
<> <>
<Loader2 className="w-4 h-4 animate-spin mr-2" /> <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> </Button>
</div> </div>
+10 -2
View File
@@ -7,9 +7,10 @@ import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
addMonths, subMonths, addYears, subYears, setMonth, setYear, addMonths, subMonths, addYears, subYears, setMonth, setYear,
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek, eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
isSameDay, isSameMonth, isToday, format, parseISO, isSameDay, isSameMonth, isToday, format,
} from "date-fns"; } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getEventDayBounds } from "@/lib/calendar-utils";
import type { CalendarEvent } from "@/lib/jmap/types"; import type { CalendarEvent } from "@/lib/jmap/types";
type PickerView = "days" | "months" | "years"; type PickerView = "days" | "months" | "years";
@@ -54,7 +55,14 @@ export function MiniCalendar({
const eventDates = useMemo(() => { const eventDates = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
events.forEach(e => { events.forEach(e => {
try { set.add(format(parseISO(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; return set;
}, [events]); }, [events]);
+5 -19
View File
@@ -304,11 +304,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
if (contact?.addressBookIds) { if (contact?.addressBookIds) {
const ids = Object.keys(contact.addressBookIds).filter(k => contact.addressBookIds[k]); const ids = Object.keys(contact.addressBookIds).filter(k => contact.addressBookIds[k]);
if (ids.length > 0) { if (ids.length > 0) {
// For shared contacts, the addressBookIds uses the original (non-namespaced) id // addressBookIds are already namespaced for shared contacts (e.g. "accountId:bookId")
// but we need the namespaced id to match addressBooks entries // so we can use them directly to match addressBook entries
if (contact.isShared && contact.accountId) {
return `${contact.accountId}:${ids[0]}`;
}
return ids[0]; return ids[0];
} }
} }
@@ -914,7 +911,6 @@ function CategoryComboBox({
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Parse current keywords from comma-separated string // Parse current keywords from comma-separated string
@@ -949,17 +945,6 @@ function CategoryComboBox({
onChange(next); onChange(next);
}, [currentKeywords, onChange]); }, [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) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") { if (e.key === "Enter") {
@@ -973,7 +958,7 @@ function CategoryComboBox({
}; };
return ( return (
<div ref={wrapperRef} className="relative"> <div className="relative">
{/* Keyword badges */} {/* Keyword badges */}
{currentKeywords.length > 0 && ( {currentKeywords.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2"> <div className="flex flex-wrap gap-1.5 mb-2">
@@ -1001,6 +986,7 @@ function CategoryComboBox({
value={inputValue} value={inputValue}
onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }} onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }}
onFocus={() => setIsOpen(true)} onFocus={() => setIsOpen(true)}
onBlur={() => setIsOpen(false)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={currentKeywords.length === 0 ? placeholder : ""} placeholder={currentKeywords.length === 0 ? placeholder : ""}
/> />
@@ -1008,7 +994,7 @@ function CategoryComboBox({
{/* Dropdown */} {/* Dropdown */}
{isOpen && (suggestions.length > 0 || canAddNew) && ( {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 => ( {suggestions.map(kw => (
<button <button
key={kw} key={kw}
+111 -28
View File
@@ -2,7 +2,8 @@
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react"; import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { BookUser, 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 { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu";
@@ -25,6 +26,8 @@ interface ContactsSidebarProps {
onDeleteGroup?: (groupId: string) => void; onDeleteGroup?: (groupId: string) => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void; onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void; onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
onRenameAddressBook?: (addressBook: AddressBook) => void;
onRenameKeyword?: (keyword: string) => void;
className?: string; className?: string;
} }
@@ -58,10 +61,15 @@ export function ContactsSidebar({
onDeleteGroup, onDeleteGroup,
onDropContacts, onDropContacts,
onDropContactsToCategory, onDropContactsToCategory,
onRenameAddressBook,
onRenameKeyword,
className, className,
}: ContactsSidebarProps) { }: ContactsSidebarProps) {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const router = useRouter();
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>(); 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 [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
@@ -246,19 +254,32 @@ export function ContactsSidebar({
{/* My Address Books */} {/* My Address Books */}
{personalBooks.length > 0 && ( {personalBooks.length > 0 && (
<div className="mt-2"> <div className="mt-2">
<button <div className="flex items-center px-3 py-1 group">
onClick={() => toggleSection("addressBooks")} <button
className="flex items-center gap-1 px-3 py-1 w-full text-left group" onClick={() => toggleSection("addressBooks")}
> className="flex items-center gap-1 flex-1 text-left"
{collapsed.addressBooks ? ( >
<ChevronRight className="w-3 h-3 text-muted-foreground" /> {collapsed.addressBooks ? (
) : ( <ChevronRight className="w-3 h-3 text-muted-foreground" />
<ChevronDown 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 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
</span> {t("address_books.title")}
</button> </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) => ( {!collapsed.addressBooks && personalBooks.map((book) => (
<AddressBookItem <AddressBookItem
key={book.id} key={book.id}
@@ -267,6 +288,7 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0} contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })} onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts} onDropContacts={onDropContacts}
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
/> />
))} ))}
</div> </div>
@@ -362,6 +384,7 @@ export function ContactsSidebar({
isActive={isActive} isActive={isActive}
onSelect={() => onSelectCategory({ keyword })} onSelect={() => onSelectCategory({ keyword })}
onDropContacts={onDropContactsToCategory} onDropContacts={onDropContactsToCategory}
onContextMenu={onRenameKeyword ? (e) => openKeywordContextMenu(e, keyword) : undefined}
/> />
); );
})} })}
@@ -372,20 +395,33 @@ export function ContactsSidebar({
{/* Shared accounts with address books */} {/* Shared accounts with address books */}
{sharedBookGroups.map((group) => ( {sharedBookGroups.map((group) => (
<div key={group.accountId} className="mt-2"> <div key={group.accountId} className="mt-2">
<button <div className="flex items-center px-3 py-1 group">
onClick={() => toggleSection(`shared-${group.accountId}`)} <button
className="flex items-center gap-1 px-3 py-1 w-full text-left group" 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" /> {collapsed[`shared-${group.accountId}`] ? (
) : ( <ChevronRight className="w-3 h-3 text-muted-foreground" />
<ChevronDown 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"> <Share2 className="w-3 h-3 text-muted-foreground" />
{t("address_books.shared_prefix", { name: group.accountName })} <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
</span> {t("address_books.shared_prefix", { name: group.accountName })}
</button> </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) => ( {!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
<AddressBookItem <AddressBookItem
key={book.id} key={book.id}
@@ -394,12 +430,53 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0} contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })} onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts} onDropContacts={onDropContacts}
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
/> />
))} ))}
</div> </div>
))} ))}
</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 */} {/* Group context menu */}
{groupContextMenu.data && ( {groupContextMenu.data && (
<ContextMenu <ContextMenu
@@ -438,12 +515,14 @@ function CategoryItem({
isActive, isActive,
onSelect, onSelect,
onDropContacts, onDropContacts,
onContextMenu,
}: { }: {
keyword: string; keyword: string;
count: number; count: number;
isActive: boolean; isActive: boolean;
onSelect: () => void; onSelect: () => void;
onDropContacts?: (contactIds: string[], keyword: string) => void; onDropContacts?: (contactIds: string[], keyword: string) => void;
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) { }) {
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
@@ -476,6 +555,7 @@ function CategoryItem({
return ( return (
<button <button
onClick={onSelect} onClick={onSelect}
onContextMenu={onContextMenu}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
@@ -503,12 +583,14 @@ function AddressBookItem({
contactCount, contactCount,
onSelect, onSelect,
onDropContacts, onDropContacts,
onContextMenu,
}: { }: {
book: AddressBook; book: AddressBook;
isActive: boolean; isActive: boolean;
contactCount: number; contactCount: number;
onSelect: () => void; onSelect: () => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void; onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) { }) {
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
@@ -541,6 +623,7 @@ function AddressBookItem({
return ( return (
<button <button
onClick={onSelect} onClick={onSelect}
onContextMenu={onContextMenu}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
@@ -38,6 +38,7 @@ describe('EmailListItem tag badge', () => {
useSettingsStore.setState({ useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS], emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false, showPreview: false,
mailLayout: 'split',
}); });
useEmailStore.setState({ useEmailStore.setState({
selectedEmailIds: new Set<string>(), selectedEmailIds: new Set<string>(),
@@ -104,4 +105,17 @@ describe('EmailListItem tag badge', () => {
render(<EmailListItem email={email} />); render(<EmailListItem email={email} />);
expect(screen.getByText('Hello World')).toBeInTheDocument(); 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();
});
}); });
+57 -56
View File
@@ -754,49 +754,67 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
{/* Content */} {/* Content */}
<div className="px-4 py-3 space-y-2.5"> <div className="px-4 py-3 space-y-2.5">
{/* Event title */} <div className="lg:flex lg:gap-6">
{summary?.title && ( {/* Left: Event info */}
<div className="flex items-start justify-between gap-2"> <div className="lg:flex-1 space-y-2.5 min-w-0">
<h3 className={cn( {/* Event title */}
"text-base font-semibold leading-snug", {summary?.title && (
isCancellation ? "line-through text-muted-foreground" : "text-foreground" <div className="flex items-start justify-between gap-2">
)}> <h3 className={cn(
{summary.title} "text-base font-semibold leading-snug",
</h3> isCancellation ? "line-through text-muted-foreground" : "text-foreground"
{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"> {summary.title}
{t('event_updated', { sequence: parsedEvent.sequence })} </h3>
</span> {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 */} {/* Event details */}
<div className="space-y-1"> <div className="lg:flex lg:items-center lg:gap-4 lg:flex-wrap space-y-1 lg:space-y-0">
{summary?.start && ( {summary?.start && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground"> <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="w-3.5 h-3.5 flex-shrink-0" /> <Clock className="w-3.5 h-3.5 flex-shrink-0" />
<span> <span>
{formatDateTime(summary.start)} {formatDateTime(summary.start)}
{summary.end && ` ${formatDateTime(summary.end)}`} {summary.end && ` ${formatDateTime(summary.end)}`}
</span> </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> </div>
)} </div>
{summary?.location && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground"> {/* Right: Info & actor messages on large screens */}
<MapPin className="w-3.5 h-3.5 flex-shrink-0" /> <div className="lg:flex-shrink-0 lg:text-right lg:max-w-xs mt-2.5 lg:mt-0 space-y-1">
<span>{summary.location}</span> {bannerInfo && (
</div> <p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
)}
<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>
)} )}
{summary && summary.attendeeCount > 0 && ( {actorMessage && (
<span>{t('attendees', { count: summary.attendeeCount })}</span> <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>
</div> </div>
@@ -840,23 +858,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
</div> </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 */} {/* Proposed changes */}
{proposedChanges.length > 0 && ( {proposedChanges.length > 0 && (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs"> <div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs">
+169 -32
View File
@@ -29,13 +29,13 @@ import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form"; import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types"; import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils"; import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
import { findReplyIdentityId } from "@/lib/reply-identity";
import { RichTextEditor } from "@/components/email/rich-text-editor"; import { RichTextEditor } from "@/components/email/rich-text-editor";
/** Strip HTML tags and decode entities to get a plain-text version */ /** Strip HTML tags and decode entities to get a plain-text version */
function htmlToPlainText(html: string): string { function htmlToPlainText(html: string): string {
const tmp = document.createElement('div'); const doc = new DOMParser().parseFromString(html, 'text/html');
tmp.innerHTML = html; return doc.body.textContent || '';
return tmp.textContent || tmp.innerText || '';
} }
export interface ComposerDraftData { export interface ComposerDraftData {
@@ -76,8 +76,10 @@ interface EmailComposerProps {
mode?: 'compose' | 'reply' | 'replyAll' | 'forward'; mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: { replyTo?: {
from?: { email?: string; name?: string }[]; from?: { email?: string; name?: string }[];
replyToAddresses?: { email?: string; name?: string }[];
to?: { email?: string; name?: string }[]; to?: { email?: string; name?: string }[];
cc?: { email?: string; name?: string }[]; cc?: { email?: string; name?: string }[];
bcc?: { email?: string; name?: string }[];
subject?: string; subject?: string;
body?: string; body?: string;
htmlBody?: string; htmlBody?: string;
@@ -100,17 +102,22 @@ export function EmailComposer({
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const timeFormat = useSettingsStore((state) => state.timeFormat); const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextMode = useSettingsStore((state) => state.plainTextMode); 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 // Initialize with reply/forward data if provided
const getInitialTo = () => { const getInitialTo = () => {
if (!replyTo) return ""; 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') { if (mode === 'reply') {
const email = replyTo.from?.[0]?.email || ""; return replyTarget ? replyTarget + ', ' : "";
return email ? email + ', ' : "";
} else if (mode === 'replyAll') { } else if (mode === 'replyAll') {
const from = replyTo.from?.[0]?.email || "";
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || ""; 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 combined ? combined + ', ' : "";
} }
return ""; return "";
@@ -207,6 +214,8 @@ export function EmailComposer({
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null); const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
const [smimePassphraseInput, setSmimePassphraseInput] = useState(''); const [smimePassphraseInput, setSmimePassphraseInput] = useState('');
const [smimePassphraseError, setSmimePassphraseError] = useState(''); const [smimePassphraseError, setSmimePassphraseError] = useState('');
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
const saveTemplateModalRef = useFocusTrap({ const saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate, isActive: showSaveAsTemplate,
@@ -220,12 +229,43 @@ export function EmailComposer({
restoreFocus: true, restoreFocus: true,
}); });
const attachmentWarningRef = useFocusTrap({
isActive: showAttachmentWarning,
onEscape: () => setShowAttachmentWarning(false),
restoreFocus: true,
});
const { client } = useAuthStore(); const { client } = useAuthStore();
const identities = useIdentityStore((s) => s.identities); const identities = useIdentityStore((s) => s.identities);
const primaryIdentity = identities[0] ?? null; const primaryIdentity = identities[0] ?? null;
const currentIdentity = selectedIdentityId const currentIdentity = selectedIdentityId
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity ? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
: 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 const composerSignatureHtml = currentIdentity?.htmlSignature
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>` ? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
: currentIdentity?.textSignature : currentIdentity?.textSignature
@@ -301,6 +341,17 @@ export function EmailComposer({
return () => window.removeEventListener('beforeunload', handleBeforeUnload); 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 [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null); const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1); const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
@@ -308,10 +359,26 @@ export function EmailComposer({
const toInputRef = useRef<HTMLInputElement>(null); const toInputRef = useRef<HTMLInputElement>(null);
const ccInputRef = useRef<HTMLInputElement>(null); const ccInputRef = useRef<HTMLInputElement>(null);
const bccInputRef = 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 toDropdownRef = useRef<HTMLDivElement>(null);
const ccDropdownRef = useRef<HTMLDivElement>(null); const ccDropdownRef = useRef<HTMLDivElement>(null);
const bccDropdownRef = 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') => { const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
if (autocompleteTimeoutRef.current) { if (autocompleteTimeoutRef.current) {
clearTimeout(autocompleteTimeoutRef.current); clearTimeout(autocompleteTimeoutRef.current);
@@ -475,17 +542,18 @@ export function EmailComposer({
} }
}, [client, t]); }, [client, t]);
const handleImageUpload = useCallback(async (file: File): Promise<string | null> => { const handleImageUpload = useCallback((file: File): Promise<string | null> => {
if (!client) return null; return new Promise((resolve) => {
try { const reader = new FileReader();
const { blobId } = await client.uploadBlob(file); reader.onload = (e) => resolve((e.target?.result as string) ?? null);
return await client.fetchBlobAsObjectUrl(blobId, file.name, file.type); reader.onerror = () => {
} catch (error) { debug.error(`Failed to read inline image ${file.name}`);
debug.error(`Failed to upload inline image ${file.name}:`, error); toast.error(t('upload_failed', { filename: file.name }));
toast.error(t('upload_failed', { filename: file.name })); resolve(null);
return null; };
} reader.readAsDataURL(file);
}, [client, t]); });
}, [t]);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files) return; if (!event.target.files) return;
@@ -665,7 +733,7 @@ export function EmailComposer({
return undefined; return undefined;
}; };
const handleSend = async () => { const handleSend = async (skipAttachmentCheck = false) => {
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean); const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean); const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
@@ -684,6 +752,21 @@ export function EmailComposer({
return; 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; let finalDraftId = draftId;
if (saveTimeoutRef.current) { if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current); clearTimeout(saveTimeoutRef.current);
@@ -944,7 +1027,7 @@ export function EmailComposer({
</div> </div>
{/* Mobile: send button in header */} {/* Mobile: send button in header */}
<Button <Button
onClick={handleSend} onClick={() => handleSend()}
disabled={!canSend} disabled={!canSend}
title={getSendTooltip()} title={getSendTooltip()}
size="sm" size="sm"
@@ -1040,6 +1123,7 @@ export function EmailComposer({
onInsertAutocomplete={insertAutocomplete} onInsertAutocomplete={insertAutocomplete}
validationError={validationErrors.to} validationError={validationErrors.to}
validationMessage={t('validation.recipient_required')} validationMessage={t('validation.recipient_required')}
onTab={focusSubject}
/> />
<div className="flex gap-0.5 shrink-0"> <div className="flex gap-0.5 shrink-0">
<Button <Button
@@ -1109,6 +1193,7 @@ export function EmailComposer({
<div className="flex items-center gap-2 px-4 py-2.5"> <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> <span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
<Input <Input
ref={subjectInputRef}
type="text" type="text"
placeholder={t('subject_placeholder')} placeholder={t('subject_placeholder')}
value={subject} value={subject}
@@ -1116,6 +1201,12 @@ export function EmailComposer({
setSubject(e.target.value); setSubject(e.target.value);
if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false })); if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false }));
}} }}
onKeyDown={(e) => {
if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
focusBody();
}
}}
className={cn( className={cn(
"flex-1 border-0 focus-visible:ring-0 h-8 px-0 text-sm", "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" validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400"
@@ -1128,6 +1219,7 @@ export function EmailComposer({
{/* Body */} {/* Body */}
{plainTextMode ? ( {plainTextMode ? (
<textarea <textarea
ref={bodyRef}
value={body} value={body}
onChange={(e) => { onChange={(e) => {
setBody(e.target.value); setBody(e.target.value);
@@ -1142,16 +1234,18 @@ export function EmailComposer({
aria-invalid={validationErrors.body || undefined} aria-invalid={validationErrors.body || undefined}
/> />
) : ( ) : (
<RichTextEditor <div ref={editorContainerRef}>
content={body} <RichTextEditor
onChange={(html) => { content={body}
setBody(html); onChange={(html) => {
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false })); setBody(html);
}} if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
onImageUpload={handleImageUpload} }}
placeholder={t('body_placeholder')} onImageUpload={handleImageUpload}
hasError={validationErrors.body} placeholder={t('body_placeholder')}
/> hasError={validationErrors.body}
/>
</div>
)} )}
{plainTextMode ? ( {plainTextMode ? (
@@ -1298,7 +1392,7 @@ export function EmailComposer({
{t('discard')} {t('discard')}
</button> </button>
<Button <Button
onClick={handleSend} onClick={() => handleSend()}
disabled={!canSend} disabled={!canSend}
title={getSendTooltip()} title={getSendTooltip()}
className="hidden md:inline-flex" className="hidden md:inline-flex"
@@ -1398,6 +1492,36 @@ export function EmailComposer({
</div> </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 && ( {showCloseDialog && (
<div <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" 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"
@@ -1483,6 +1607,7 @@ function RecipientChipInput({
onInsertAutocomplete, onInsertAutocomplete,
validationError, validationError,
validationMessage, validationMessage,
onTab,
}: { }: {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
@@ -1499,6 +1624,7 @@ function RecipientChipInput({
onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void; onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void;
validationError?: boolean; validationError?: boolean;
validationMessage?: string; validationMessage?: string;
onTab?: () => void;
}) { }) {
const allParts = value.split(',').map(s => s.trim()).filter(Boolean); const allParts = value.split(',').map(s => s.trim()).filter(Boolean);
const hasTrailingComma = value.trimEnd().endsWith(','); const hasTrailingComma = value.trimEnd().endsWith(',');
@@ -1532,7 +1658,18 @@ function RecipientChipInput({
if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) { if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) {
if (e.key !== 'Tab') e.preventDefault(); if (e.key !== 'Tab') e.preventDefault();
commitCurrentInput(); 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; return;
} }
+30 -26
View File
@@ -89,17 +89,18 @@ const getMailboxIcon = (role?: string) => {
} }
}; };
// Get current label/color from email keywords (supports both $label: and legacy $color:) // Get all active label/color tag IDs from email keywords
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => { const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return null; if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) { for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
return key.startsWith("$label:") tags.push(
? key.slice("$label:".length) key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
: key.slice("$color:".length); );
} }
} }
return null; return tags;
}; };
export function EmailContextMenu({ export function EmailContextMenu({
@@ -137,7 +138,7 @@ export function EmailContextMenu({
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const isDraft = email.keywords?.['$draft'] === true; const isDraft = email.keywords?.['$draft'] === true;
const currentColor = getCurrentColor(email.keywords); const currentColors = getCurrentColors(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1; const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk'; const isInJunkFolder = currentMailboxRole === 'junk';
@@ -306,24 +307,27 @@ export function EmailContextMenu({
{/* Set tag submenu - only for single email */} {/* Set tag submenu - only for single email */}
{!showBatchActions && ( {!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}> <ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
{colorOptions.map((option) => ( {colorOptions.map((option) => {
<button const isActive = currentColors.includes(option.value);
key={option.value} return (
role="menuitem" <button
onClick={() => handleAction(() => onSetColorTag?.(option.value))} key={option.value}
className={cn( role="menuitem"
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer", onClick={() => handleAction(() => onSetColorTag?.(option.value))}
currentColor === option.value && "bg-accent font-medium" 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> >
{currentColor === option.value && ( <span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" /> <span className="flex-1">{option.name}</span>
)} {isActive && (
</button> <Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
))} )}
{currentColor && ( </button>
);
})}
{currentColors.length > 0 && (
<> <>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
+16 -4
View File
@@ -9,6 +9,7 @@ import { useTranslations } from "next-intl";
interface EmailHoverActionsProps { interface EmailHoverActionsProps {
email: Email; email: Email;
backgroundClassName?: string;
onToggleStar?: () => void; onToggleStar?: () => void;
onMarkAsRead?: (read: boolean) => void; onMarkAsRead?: (read: boolean) => void;
onDelete?: () => void; onDelete?: () => void;
@@ -63,6 +64,7 @@ const CORNER_CLASSES = {
export function EmailHoverActions({ export function EmailHoverActions({
email, email,
backgroundClassName = "bg-muted",
onToggleStar, onToggleStar,
onMarkAsRead, onMarkAsRead,
onDelete, onDelete,
@@ -77,6 +79,7 @@ export function EmailHoverActions({
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const hoverBackgroundClassName = backgroundClassName;
if (hoverActions.length === 0) return null; if (hoverActions.length === 0) return null;
@@ -91,7 +94,7 @@ export function EmailHoverActions({
onToggleStar?.(); onToggleStar?.();
break; break;
case "markRead": case "markRead":
onMarkAsRead?.(!isUnread); onMarkAsRead?.(isUnread);
break; break;
case "archive": case "archive":
onArchive?.(); onArchive?.();
@@ -144,7 +147,10 @@ export function EmailHoverActions({
CORNER_CLASSES[hoverActionsCorner], CORNER_CLASSES[hoverActionsCorner],
)} )}
> >
<div className="flex items-center gap-0.5 bg-muted rounded-lg px-1.5 py-0.5 shadow-md border border-border"> <div className={cn(
"flex items-center gap-0.5 rounded-lg px-1.5 py-0.5 shadow-md border border-border",
hoverBackgroundClassName,
)}>
{actionButtons} {actionButtons}
</div> </div>
</div> </div>
@@ -155,8 +161,14 @@ export function EmailHoverActions({
<div <div
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center" 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
<div className="flex items-center gap-0.5 h-full bg-muted pr-3 pl-0.5"> 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} {actionButtons}
</div> </div>
</div> </div>
+138 -77
View File
@@ -15,7 +15,7 @@ import { useLongPress } from "@/hooks/use-long-press";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { EmailIdentityBadge } from "./email-identity-badge"; import { EmailIdentityBadge } from "./email-identity-badge";
import { EmailHoverActions } from "./email-hover-actions"; import { EmailHoverActions } from "./email-hover-actions";
import { getEmailColorTag } from "@/lib/thread-utils"; import { getEmailColorTags } from "@/lib/thread-utils";
interface EmailListItemProps { interface EmailListItemProps {
email: Email; email: Email;
@@ -32,9 +32,10 @@ interface EmailListItemProps {
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) { export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
const t = useTranslations('email_viewer'); 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 showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { identities } = useAuthStore(); const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id); const isChecked = selectedEmailIds.has(email.id);
@@ -43,11 +44,18 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const isImportant = email.keywords?.["$important"]; const isImportant = email.keywords?.["$important"];
const isAnswered = email.keywords?.$answered; const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded; const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0]; // 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 // Resolve color tags using keyword definitions from settings
const colorTagId = getEmailColorTag(email.keywords); const colorTagIds = getEmailColorTags(email.keywords);
const keywordDef = colorTagId ? emailKeywords.find(k => k.id === colorTagId) : null; 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; const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
// Drag and drop functionality // Drag and drop functionality
@@ -114,15 +122,19 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
} }
}} }}
onContextMenu={handleContextMenu} 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 */} {/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && ( {selectedEmailIds.size > 0 && (
<button <button
onClick={handleCheckboxClick} onClick={handleCheckboxClick}
className={cn( 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", "hover:bg-muted/50 hover:scale-110",
"active:scale-95", "active:scale-95",
"animate-in fade-in zoom-in-95 duration-150", "animate-in fade-in zoom-in-95 duration-150",
@@ -145,7 +157,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
)} )}
{/* Avatar */} {/* Avatar */}
{density !== 'extra-compact' && ( {!isFocusedMailLayout && density !== 'extra-compact' && (
<Avatar <Avatar
name={sender?.name} name={sender?.name}
email={sender?.email} email={sender?.email}
@@ -156,85 +168,133 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* First Line: Sender and Date */} {isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-2 mb-1"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex min-w-0 flex-1 items-center gap-3">
<span className={cn( <span className={cn(
"truncate text-sm", 'w-32 shrink-0 truncate text-sm lg:w-40',
isUnread isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
? "font-bold text-foreground" )}>
: "font-medium text-muted-foreground" {sender?.name || sender?.email || 'Unknown'}
)}> </span>
{sender?.name || sender?.email || "Unknown"} <div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
</span> <span className={cn(
<div className="flex items-center gap-1.5"> 'shrink-0 truncate',
{isStarred && ( isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" /> )}>
)} {email.subject || t('no_subject')}
{isImportant && (
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
Important
</span> </span>
)} {inlinePreview && (
<EmailIdentityBadge email={email} identities={identities} compact={true} /> <span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
{isAnswered && !isForwarded && ( )}
<Reply className="w-3.5 h-3.5 text-muted-foreground" /> </div>
)} </div>
{isForwarded && !isAnswered && ( <div className="flex items-center gap-2.5 shrink-0">
<Forward className="w-3.5 h-3.5 text-muted-foreground" /> {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 && ( {isAnswered && isForwarded && (
<> <>
<Reply className="w-3.5 h-3.5 text-muted-foreground" /> <Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" /> <Forward className="w-3.5 h-3.5 text-muted-foreground" />
</> </>
)} )}
{email.hasAttachment && ( {email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
<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> </div>
<div className="flex items-center gap-1.5 flex-shrink-0"> ) : (
{keywordDef && ( <>
<span className={cn( {/* First Line: Sender and Date */}
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full", <div className="flex items-center justify-between gap-2 mb-1">
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted" <div className="flex items-center gap-2 min-w-0 flex-1">
)}> <span className={cn(
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} /> "truncate text-sm",
{keywordDef.label} isUnread
</span> ? "font-bold text-foreground"
)} : "font-medium text-muted-foreground"
<span className={cn( )}>
"text-xs tabular-nums", {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 isUnread
? "text-foreground font-semibold" ? "font-semibold text-foreground"
: "text-muted-foreground" : "font-normal text-foreground/90"
)}> )}>
{formatDate(email.receivedAt)} {email.subject || t('no_subject')}
</span> </div>
</div>
</div>
{/* Second Line: Subject */} {/* Third Line: Preview (controlled by showPreview setting) */}
<div className={cn( {showPreview && density !== 'extra-compact' && (
"mb-1 line-clamp-1 text-sm", <p className={cn(
isUnread "text-sm leading-relaxed line-clamp-2",
? "font-semibold text-foreground" isUnread
: "font-normal text-foreground/90" ? "text-muted-foreground"
)}> : "text-muted-foreground/80"
{email.subject || t('no_subject')} )}>
</div> {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>
</div> </div>
@@ -242,6 +302,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
{/* Hover Quick Actions */} {/* Hover Quick Actions */}
<EmailHoverActions <EmailHoverActions
email={email} email={email}
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")}
onToggleStar={onToggleStar} onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead} onMarkAsRead={onMarkAsRead}
onDelete={onDelete} onDelete={onDelete}
+20 -4
View File
@@ -103,11 +103,16 @@ export function EmailList({
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const isFocusedMailLayout = mailLayout === 'focus';
const estimateSize = useCallback(() => { 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]; const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
return (showPreview && density !== 'extra-compact') ? base + 36 : base; return (showPreview && density !== 'extra-compact') ? base + 36 : base;
}, [density, showPreview]); }, [density, isFocusedMailLayout, showPreview]);
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: threadGroups.length, count: threadGroups.length,
@@ -216,15 +221,26 @@ export function EmailList({
} }
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]); }, [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 virtualItems = virtualizer.getVirtualItems();
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index; const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
if (lastVirtualItemIndex === undefined) return; if (lastVirtualItemIndex === undefined) return;
if (lastVirtualItemIndex >= threadGroups.length - 5) { 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]); }, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
// Scroll to the thread group containing the selected email // Scroll to the thread group containing the selected email
@@ -244,7 +260,7 @@ export function EmailList({
useEffect(() => { useEffect(() => {
virtualizer.measure(); virtualizer.measure();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [density, showPreview]); }, [density, isFocusedMailLayout, showPreview]);
return ( return (
<div className={cn("flex flex-col min-h-0", className)}> <div className={cn("flex flex-col min-h-0", className)}>
+253 -128
View File
@@ -8,7 +8,7 @@ import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/ema
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar"; 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 { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
import { import {
Reply, Reply,
@@ -68,6 +68,7 @@ import {
HelpCircle, HelpCircle,
EditIcon, EditIcon,
PlayCircle, PlayCircle,
PenSquare,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type { Attachment as PostalMimeAttachment } from 'postal-mime'; import type { Attachment as PostalMimeAttachment } from 'postal-mime';
@@ -121,6 +122,7 @@ interface EmailViewerProps {
onNavigatePrev?: () => void; onNavigatePrev?: () => void;
onShowShortcuts?: () => void; onShowShortcuts?: () => void;
onEditDraft?: () => void; onEditDraft?: () => void;
onCompose?: () => void;
currentUserEmail?: string; currentUserEmail?: string;
currentUserName?: string; currentUserName?: string;
currentMailboxRole?: string; currentMailboxRole?: string;
@@ -191,16 +193,17 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
return 'Attachment'; return 'Attachment';
}; };
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => { const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return null; if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) { for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
return key.startsWith("$label:") tags.push(
? key.slice("$label:".length) key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
: key.slice("$color:".length); );
} }
} }
return null; return tags;
}; };
// Helper function to format recipients with contextual display // Helper function to format recipients with contextual display
@@ -865,6 +868,7 @@ export function EmailViewer({
onNavigatePrev, onNavigatePrev,
onShowShortcuts, onShowShortcuts,
onEditDraft, onEditDraft,
onCompose,
currentUserEmail, currentUserEmail,
currentUserName, currentUserName,
currentMailboxRole, currentMailboxRole,
@@ -884,11 +888,16 @@ export function EmailViewer({
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition); const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); 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 emailKeywords = useSettingsStore((state) => state.emailKeywords);
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled); const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
const timeFormat = useSettingsStore((state) => state.timeFormat); const timeFormat = useSettingsStore((state) => state.timeFormat);
const isFocusedMailLayout = mailLayout === 'focus';
// Detect if current mailbox is Junk folder // Detect if current mailbox is Junk folder
const isInJunkFolder = currentMailboxRole === 'junk'; const isInJunkFolder = currentMailboxRole === 'junk';
@@ -906,7 +915,7 @@ export function EmailViewer({
// Tablet list visibility // Tablet list visibility
const { isTablet, isMobile } = useDeviceDetection(); const { isTablet, isMobile } = useDeviceDetection();
const { tabletListVisible } = useUIStore(); const { tabletListVisible } = useUIStore();
const { identities, client, isDemoMode } = useAuthStore(); const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const { startTour } = useTour(); const { startTour } = useTour();
const [showFullHeaders, setShowFullHeaders] = useState(false); const [showFullHeaders, setShowFullHeaders] = useState(false);
@@ -928,7 +937,8 @@ export function EmailViewer({
const moveMenuRef = useRef<HTMLDivElement>(null); const moveMenuRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null); const toolbarRef = useRef<HTMLDivElement>(null);
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set()); 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 // S/MIME state
const [smimeStatus, setSmimeStatus] = useState<SmimeStatus | null>(null); const [smimeStatus, setSmimeStatus] = useState<SmimeStatus | null>(null);
@@ -960,9 +970,9 @@ export function EmailViewer({
// Ensure S/MIME key records are loaded from IndexedDB // Ensure S/MIME key records are loaded from IndexedDB
useLayoutEffect(() => { useLayoutEffect(() => {
smimeStore.load(); smimeStore.load(activeAccountId ?? undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [activeAccountId]);
// Build mailbox tree for move-to dropdown // Build mailbox tree for move-to dropdown
const moveTargetIds = useMemo(() => new Set( const moveTargetIds = useMemo(() => new Set(
@@ -1147,9 +1157,27 @@ export function EmailViewer({
} }
); );
const autoMarkedEmailRef = useRef<string | null>(null);
// Reset auto-mark tracking when email changes
useEffect(() => {
autoMarkedEmailRef.current = null;
}, [email?.id]);
useEffect(() => { useEffect(() => {
// Mark as read when email is viewed, respecting the delay setting // Mark as read when email is viewed, respecting the delay setting
if (!email || email.keywords?.$seen || !onMarkAsRead) { if (!email || !onMarkAsRead) {
return;
}
// 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; return;
} }
@@ -1162,17 +1190,18 @@ export function EmailViewer({
// Instant mark // Instant mark
if (markAsReadDelay === 0) { if (markAsReadDelay === 0) {
autoMarkedEmailRef.current = email.id;
onMarkAsRead(email.id, true); onMarkAsRead(email.id, true);
return; return;
} }
// Delayed mark // Delayed mark
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
autoMarkedEmailRef.current = email.id;
onMarkAsRead(email.id, true); onMarkAsRead(email.id, true);
}, markAsReadDelay); }, markAsReadDelay);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
// eslint-disable-next-line react-hooks/exhaustive-deps -- email?.id changes when email changes, which is the intended trigger
}, [email?.id, email?.keywords?.$seen, onMarkAsRead]); }, [email?.id, email?.keywords?.$seen, onMarkAsRead]);
// Reset external content permission and quick reply when email changes // Reset external content permission and quick reply when email changes
@@ -1540,8 +1569,25 @@ export function EmailViewer({
if (detection.type === 'enveloped-data') { if (detection.type === 'enveloped-data') {
// Encrypted message // Encrypted message
const { keyRecords, unlockedDecryptionKeys } = smimeStore; const { keyRecords, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys } = smimeStore;
smimeDebug('[S/MIME] decrypt attempt:', { keyRecordCount: keyRecords.length, unlockedKeyCount: unlockedDecryptionKeys.size, keyRecordIds: keyRecords.map(k => k.id) }); 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 { try {
let result: Awaited<ReturnType<typeof smimeDecrypt>> | null = null; let result: Awaited<ReturnType<typeof smimeDecrypt>> | null = null;
let lastError: unknown = null; let lastError: unknown = null;
@@ -1552,6 +1598,7 @@ export function EmailViewer({
cmsBytes: candidate.normalized, cmsBytes: candidate.normalized,
keyRecords, keyRecords,
unlockedKeys: unlockedDecryptionKeys, unlockedKeys: unlockedDecryptionKeys,
legacyUnlockedKeys: unlockedLegacyDecryptionKeys,
}); });
smimeDebug('[S/MIME] decrypt success with candidate:', { smimeDebug('[S/MIME] decrypt success with candidate:', {
source: candidate.source, source: candidate.source,
@@ -1633,8 +1680,15 @@ export function EmailViewer({
if (!existing) { if (!existing) {
try { try {
await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email'); 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) { } catch (error) {
smimeError('[S/MIME] nested signature verify failed:', { smimeError('[S/MIME] nested signature verify failed:', {
@@ -1672,10 +1726,12 @@ export function EmailViewer({
decryptionError: 'locked', decryptionError: 'locked',
}); });
} else { } else {
const errMsg = err instanceof Error ? err.message : 'Decryption failed';
const isNoKeyError = errMsg.includes('No imported S/MIME key matches');
setSmimeStatus({ setSmimeStatus({
isSigned: false, isSigned: false,
isEncrypted: true, isEncrypted: true,
decryptionError: err instanceof Error ? err.message : 'Decryption failed', decryptionError: isNoKeyError ? 'no-key' : errMsg,
}); });
} }
} }
@@ -1737,8 +1793,15 @@ export function EmailViewer({
if (!existing) { if (!existing) {
try { try {
await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email'); 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) { } catch (err) {
if (cancelled) return; if (cancelled) return;
@@ -1771,6 +1834,7 @@ export function EmailViewer({
smimeStore.autoImportSignerCerts, smimeStore.autoImportSignerCerts,
smimeStore.keyRecords, smimeStore.keyRecords,
smimeStore.unlockedDecryptionKeys, smimeStore.unlockedDecryptionKeys,
smimeStore.unlockedLegacyDecryptionKeys,
smimeStore, smimeStore,
]); ]);
@@ -1780,12 +1844,12 @@ export function EmailViewer({
const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type)); const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type));
if (!tnefAtt?.blobId) { 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; return;
} }
debug.group('TNEF Processing'); debug.group('TNEF Processing', 'email');
debug.log('Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size); 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 // 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 // Outlook often forwards TNEF emails with an HTML body that's just Word
@@ -1795,46 +1859,46 @@ export function EmailViewer({
let hasRealHtmlBody = !!htmlValue; let hasRealHtmlBody = !!htmlValue;
if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) { if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) {
hasRealHtmlBody = false; 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) { 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 { } 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; let cancelled = false;
async function processTnef() { async function processTnef() {
try { try {
debug.time('TNEF fetch blob'); debug.time('TNEF fetch blob', 'email');
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!); const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
debug.timeEnd('TNEF fetch blob'); debug.timeEnd('TNEF fetch blob', 'email');
debug.log('TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes'); debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
if (cancelled) { if (cancelled) {
debug.log('TNEF: Processing cancelled after fetch'); debug.log('email', 'TNEF: Processing cancelled after fetch');
debug.groupEnd(); debug.groupEnd();
return; return;
} }
if (blobBytes.byteLength === 0) { 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(); debug.groupEnd();
return; return;
} }
const tnefData = new Uint8Array(blobBytes); const tnefData = new Uint8Array(blobBytes);
debug.time('TNEF parse'); debug.time('TNEF parse', 'email');
const parsed = parseTnef(tnefData); const parsed = parseTnef(tnefData);
debug.timeEnd('TNEF parse'); debug.timeEnd('TNEF parse', 'email');
if (cancelled) { if (cancelled) {
debug.log('TNEF: Processing cancelled after parse'); debug.log('email', 'TNEF: Processing cancelled after parse');
debug.groupEnd(); debug.groupEnd();
return; 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) { if (parsed.htmlBody && !hasRealHtmlBody) {
setTnefHtml(parsed.htmlBody); setTnefHtml(parsed.htmlBody);
@@ -1844,11 +1908,11 @@ export function EmailViewer({
} }
if (parsed.attachments.length > 0) { if (parsed.attachments.length > 0) {
setTnefAttachments(parsed.attachments); 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) { 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(); debug.groupEnd();
@@ -1886,13 +1950,13 @@ export function EmailViewer({
const hasRealText = !!textValue; const hasRealText = !!textValue;
if (hasRealHtml || hasRealText) { 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; return;
} }
debug.group('Embedded RFC822 Unwrapping'); debug.group('Embedded RFC822 Unwrapping', 'email');
debug.log('Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size); debug.log('email', 'Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
debug.log('Outer email body is empty, will unwrap embedded email'); debug.log('email', 'Outer email body is empty, will unwrap embedded email');
let cancelled = false; let cancelled = false;
@@ -1901,7 +1965,7 @@ export function EmailViewer({
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!); const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
if (cancelled) { debug.groupEnd(); return; } if (cancelled) { debug.groupEnd(); return; }
if (blobBytes.byteLength === 0) { if (blobBytes.byteLength === 0) {
debug.warn('Embedded RFC822: Fetched blob is empty'); debug.warn('email', 'Embedded RFC822: Fetched blob is empty');
debug.groupEnd(); debug.groupEnd();
return; return;
} }
@@ -1911,7 +1975,7 @@ export function EmailViewer({
const parsed = await parser.parse(new Uint8Array(blobBytes)); const parsed = await parser.parse(new Uint8Array(blobBytes));
if (cancelled) { debug.groupEnd(); return; } 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)', ', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
', attachments:', parsed.attachments?.length ?? 0); ', attachments:', parsed.attachments?.length ?? 0);
@@ -1923,7 +1987,7 @@ export function EmailViewer({
} }
if (parsed.attachments && parsed.attachments.length > 0) { if (parsed.attachments && parsed.attachments.length > 0) {
setEmbeddedEmailAttachments(parsed.attachments as PostalMimeAttachment[]); 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 + ')' a => (a.filename || 'unnamed') + ' (' + a.mimeType + ')'
).join(', ')); ).join(', '));
} }
@@ -2250,9 +2314,11 @@ export function EmailViewer({
// Use shared sanitization config as base (more secure) // Use shared sanitization config as base (more secure)
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG }; 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 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: // Block external content based on policy:
// 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted // 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted
@@ -2359,7 +2425,7 @@ export function EmailViewer({
html: '<p style="color: var(--color-muted-foreground);">No content available</p>', html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
isHtml: false 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 // Override email content with S/MIME decrypted content when available
const effectiveEmailContent = useMemo(() => { const effectiveEmailContent = useMemo(() => {
@@ -2526,14 +2592,21 @@ export function EmailViewer({
// If email has native dark mode, let it handle its own theming // If email has native dark mode, let it handle its own theming
// Otherwise, use CSS filter inversion for dark mode (preserves layout) // 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 ? ` const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
html { background: #1a1a1a; } html { background: #1a1a1a; }
body { filter: invert(1) hue-rotate(180deg); } body { filter: invert(1) hue-rotate(180deg); }
img, video, picture, svg, canvas, object, embed, img, video, svg, canvas, object, embed, input[type="image"] {
[style*="background-image"], [style*="background:"], filter: invert(1) hue-rotate(180deg);
[background], [bgcolor], }
td[background], table[background], [style*="background-image"]:not(:has(img, video, svg, canvas, object, embed)),
img[src], input[type="image"] { [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); filter: invert(1) hue-rotate(180deg);
} }
` : ''; ` : '';
@@ -2572,11 +2645,35 @@ export function EmailViewer({
a.setAttribute('target', '_blank'); a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer'); 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 { } catch {
// Cross-origin restrictions - iframe will still display content // Cross-origin restrictions - iframe will still display content
} }
}, []); }, [isDark, emailHasNativeDarkMode]);
// Export email as .eml file // Export email as .eml file
const handleExportEmail = async () => { const handleExportEmail = async () => {
@@ -2775,6 +2872,12 @@ export function EmailViewer({
</div> </div>
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3> <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> <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>
</div> </div>
); );
@@ -2790,7 +2893,7 @@ export function EmailViewer({
<> <>
{/* Left: Reply actions */} {/* Left: Reply actions */}
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}> <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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -2805,7 +2908,7 @@ export function EmailViewer({
<Button <Button
variant="default" variant="default"
size="sm" size="sm"
onClick={onEditDraft} onClick={() => onEditDraft()}
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0" className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('tooltips.edit_draft')} title={t('tooltips.edit_draft')}
> >
@@ -2959,43 +3062,51 @@ export function EmailViewer({
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }} onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
className={cn( className={cn(
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2", "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')} title={t('set_color')}
> >
{(() => { {currentColors.length > 0 ? (
const kw = currentColor ? emailKeywords.find(k => k.id === currentColor) : null; <>
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; <span className="flex items-center gap-0.5">
return dotClass ? ( {currentColors.slice(0, 3).map((tagId) => {
<> const kw = emailKeywords.find(k => k.id === tagId);
<span className={cn("w-3 h-3 rounded-full", dotClass)} /> return kw ? <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot)} /> : null;
{showToolbarLabels && <span className="text-xs font-medium text-foreground">{kw!.label}</span>} })}
</> </span>
) : ( {showToolbarLabels && currentColors.length === 1 && (
<> <span className="text-xs font-medium text-foreground">
<Tag className="w-4 h-4 text-muted-foreground" /> {emailKeywords.find(k => k.id === currentColors[0])?.label}
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>} </span>
</> )}
); </>
})()} ) : (
<>
<Tag className="w-4 h-4 text-muted-foreground" />
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
</>
)}
</button> </button>
{tagMenuOpen && ( {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"> <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) => ( {colorOptions.map((option) => {
<button const isActive = currentColors.includes(option.value);
key={option.value} return (
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }} <button
className={cn( key={option.value}
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2", onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
currentColor === option.value && "bg-accent font-medium" 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> >
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />} <span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
</button> <span className="truncate">{option.name}</span>
))} {isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
{currentColor && ( </button>
);
})}
{currentColors.length > 0 && (
<> <>
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
<button <button
@@ -3203,21 +3314,24 @@ export function EmailViewer({
</button> </button>
{moreMenuSub === 'tag' && ( {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"> <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) => ( {colorOptions.map((option) => {
<button const isActive = currentColors.includes(option.value);
key={option.value} return (
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }} <button
className={cn( key={option.value}
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2", onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
currentColor === option.value && "bg-accent font-medium" 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> >
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />} <span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
</button> <span className="truncate">{option.name}</span>
))} {isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
{currentColor && ( </button>
);
})}
{currentColors.length > 0 && (
<> <>
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
<button <button
@@ -3393,21 +3507,24 @@ export function EmailViewer({
<> <>
<div className="h-px bg-border my-1" /> <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> <div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
{colorOptions.map((option) => ( {colorOptions.map((option) => {
<button const isActive = currentColors.includes(option.value);
key={option.value} return (
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }} <button
className={cn( key={option.value}
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3", onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
currentColor === option.value && "bg-accent font-medium" 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> >
{currentColor === option.value && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />} <span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
</button> <span className="truncate">{option.name}</span>
))} {isActive && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
{currentColor && ( </button>
);
})}
{currentColors.length > 0 && (
<button <button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }} 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" 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"
@@ -3524,7 +3641,7 @@ export function EmailViewer({
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}> <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"> <div className="flex items-start justify-between gap-2 lg:gap-4">
{/* Back button (for below-subject mode on tablet) */} {/* Back button (for below-subject mode on tablet) */}
{toolbarPosition === 'below-subject' && isTablet && !tabletListVisible && onBack && ( {toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -3553,14 +3670,18 @@ export function EmailViewer({
)} /> )} />
</button> </button>
)} )}
{/* Color tag dot */} {/* Color tag dots */}
{currentColor && (() => { {currentColors.length > 0 && (
const kw = emailKeywords.find(k => k.id === currentColor); <span className="flex items-center gap-0.5">
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; {currentColors.map((tagId) => {
return dotClass ? ( const kw = emailKeywords.find(k => k.id === tagId);
<span className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} /> const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
) : 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 && ( {isImportant && (
<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"> <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')} {t('important')}
@@ -4403,7 +4524,7 @@ export function EmailViewer({
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={onEditDraft} onClick={() => onEditDraft()}
className="gap-1.5" className="gap-1.5"
> >
<EditIcon className="w-3.5 h-3.5" /> <EditIcon className="w-3.5 h-3.5" />
@@ -4430,7 +4551,7 @@ export function EmailViewer({
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
hasCalendarInvitation) && ( hasCalendarInvitation) && (
<div className="border-b border-border bg-muted/30 isolate"> <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"> <div className="flex flex-col gap-3 isolate">
{/* External Content Controls */} {/* External Content Controls */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && ( {hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
@@ -4449,7 +4570,11 @@ export function EmailViewer({
onClick={() => { onClick={() => {
const senderEmail = email.from?.[0]?.email; const senderEmail = email.from?.[0]?.email;
if (senderEmail) { if (senderEmail) {
addTrustedSender(senderEmail); if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
setAllowExternalContent(true); setAllowExternalContent(true);
} }
}} }}
@@ -4672,7 +4797,7 @@ export function EmailViewer({
</button> </button>
{isDraft && onEditDraft ? ( {isDraft && onEditDraft ? (
<button <button
onClick={onEditDraft} 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" 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')} aria-label={t('tooltips.edit_draft')}
> >
@@ -4797,7 +4922,7 @@ export function EmailViewer({
if (client && supportsSync) { if (client && supportsSync) {
createContact(client, contactData).then(() => toast.success('Contact added')); createContact(client, contactData).then(() => toast.success('Contact added'));
} else { } else {
addLocalContact({ id: `local-${crypto.randomUUID()}`, addressBookIds: {}, ...contactData } as ContactCard); addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
toast.success('Contact added'); toast.success('Contact added');
} }
}} }}
+12
View File
@@ -93,6 +93,18 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
return () => document.removeEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler);
}, [isOpen]); }, [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 = () => { const handleViewContact = () => {
if (onViewContact) { if (onViewContact) {
onViewContact(contact ?? null, email); onViewContact(contact ?? null, email);
+1
View File
@@ -118,6 +118,7 @@ export function RichTextEditor({
); );
if (imageFiles.length === 0) return false; if (imageFiles.length === 0) return false;
event.preventDefault(); event.preventDefault();
event.stopPropagation();
for (const file of imageFiles) { for (const file of imageFiles) {
upload(file).then((url) => { upload(file).then((url) => {
if (url) { if (url) {
+13 -1
View File
@@ -30,6 +30,12 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
text: t('unlock_key_desc'), text: t('unlock_key_desc'),
variant: 'warning', 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 { } else {
items.push({ items.push({
icon: <ShieldX className="w-4 h-4" />, icon: <ShieldX className="w-4 h-4" />,
@@ -49,7 +55,13 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
// Signature status // Signature status
if (status.isSigned) { if (status.isSigned) {
if (status.signatureValid === true) { 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({ items.push({
icon: <AlertTriangle className="w-4 h-4" />, icon: <AlertTriangle className="w-4 h-4" />,
text: t('status_signed_mismatch'), text: t('status_signed_mismatch'),
+13 -2
View File
@@ -31,6 +31,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { isFilePreviewable } from "@/lib/file-preview"; import { isFilePreviewable } from "@/lib/file-preview";
@@ -84,6 +85,10 @@ export function ThreadConversationView({
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); 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) // Track which emails are expanded (most recent by default)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()); 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)' }}> <div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
{emails.map((email, index) => { {emails.map((email, index) => {
const senderEmail = email.from?.[0]?.email?.toLowerCase(); const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false; const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
return ( return (
<EmailCard <EmailCard
key={email.id} key={email.id}
@@ -175,7 +182,11 @@ export function ThreadConversationView({
onToggleExpanded={() => toggleExpanded(email.id)} onToggleExpanded={() => toggleExpanded(email.id)}
onAllowExternal={() => toggleAllowExternal(email.id)} onAllowExternal={() => toggleAllowExternal(email.id)}
onTrustSender={senderEmail ? () => { onTrustSender={senderEmail ? () => {
addTrustedSender(senderEmail); if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
toggleAllowExternal(email.id); toggleAllowExternal(email.id);
} : undefined} } : undefined}
onReply={onReply ? () => onReply(email) : undefined} onReply={onReply ? () => onReply(email) : undefined}
+281 -148
View File
@@ -9,7 +9,7 @@ import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSqu
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils"; import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
import { useEmailDrag } from "@/hooks/use-email-drag"; import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press"; import { useLongPress } from "@/hooks/use-long-press";
import { ThreadEmailItem } from "./thread-email-item"; import { ThreadEmailItem } from "./thread-email-item";
@@ -55,15 +55,22 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered; const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded; const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0]; const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); // In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const isChecked = selectedEmailIds.has(email.id); const isChecked = selectedEmailIds.has(email.id);
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color and keyword definition from keyword definitions if not passed directly // Resolve color tags using keyword definitions
const tagId = getEmailColorTag(email.keywords); const tagIds = getEmailColorTags(email.keywords);
const resolvedKeywordDef = tagId ? emailKeywords.find(k => k.id === tagId) : null; const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
const resolvedColorTag = (() => { const resolvedColorTag = (() => {
if (colorTag) return colorTag; if (colorTag) return colorTag;
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null; return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
@@ -131,15 +138,19 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)} )}
onClick={handleClick} onClick={handleClick}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }} style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
> >
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}> <div
className={cn('px-3', 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 */} {/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && ( {selectedEmailIds.size > 0 && (
<button <button
onClick={handleCheckboxClick} onClick={handleCheckboxClick}
className={cn( 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", "hover:bg-muted/50 hover:scale-110",
"active:scale-95", "active:scale-95",
"animate-in fade-in zoom-in-95 duration-150", "animate-in fade-in zoom-in-95 duration-150",
@@ -160,7 +171,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div> </div>
)} )}
{density !== 'extra-compact' && ( {!isFocusedMailLayout && density !== 'extra-compact' && (
<Avatar <Avatar
name={sender?.name} name={sender?.name}
email={sender?.email} email={sender?.email}
@@ -170,76 +181,123 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1"> {isFocusedMailLayout ? (
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center justify-between gap-3">
<span className={cn( <div className="flex min-w-0 flex-1 items-center gap-3">
"truncate text-sm", <span className={cn(
isUnread 'w-32 shrink-0 truncate text-sm lg:w-40',
? "font-bold text-foreground" isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
: "font-medium text-muted-foreground" )}>
)}> {sender?.name || sender?.email || 'Unknown'}
{sender?.name || sender?.email || "Unknown"} </span>
</span> <div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<div className="flex items-center gap-1.5"> <span className={cn(
{isStarred && ( 'shrink-0 truncate',
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" /> isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
)} )}>
{isAnswered && !isForwarded && ( {email.subject || '(no subject)'}
<Reply className="w-3.5 h-3.5 text-muted-foreground" /> </span>
)} {inlinePreview && (
{isForwarded && !isAnswered && ( <span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
<Forward className="w-3.5 h-3.5 text-muted-foreground" /> )}
)} </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" />}
{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 && ( {isAnswered && isForwarded && (
<> <>
<Reply className="w-3.5 h-3.5 text-muted-foreground" /> <Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" /> <Forward className="w-3.5 h-3.5 text-muted-foreground" />
</> </>
)} )}
{email.hasAttachment && ( {email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" /> {resolvedKeywordDefs.map((kd) => (
)} <span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
<span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(email.receivedAt)}
</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 flex-shrink-0"> ) : (
{resolvedKeywordDef && ( <>
<span className={cn( <div className="flex items-center justify-between gap-2 mb-1">
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full", <div className="flex items-center gap-2 min-w-0 flex-1">
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted" <span className={cn(
)}> "truncate text-sm",
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} /> isUnread
{resolvedKeywordDef.label} ? "font-bold text-foreground"
</span> : "font-medium text-muted-foreground"
)} )}>
<span className={cn( {sender?.name || sender?.email || "Unknown"}
"text-xs tabular-nums", </span>
<div className="flex items-center gap-1.5">
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{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">
{resolvedKeywordDefs.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>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread isUnread
? "text-foreground font-semibold" ? "font-semibold text-foreground"
: "text-muted-foreground" : "font-normal text-foreground/90"
)}> )}>
{formatDate(email.receivedAt)} {email.subject || "(no subject)"}
</span> </div>
</div>
</div>
<div className={cn( {showPreview && density !== 'extra-compact' && (
"mb-1 line-clamp-1 text-sm", <p className={cn(
isUnread "text-sm leading-relaxed line-clamp-2",
? "font-semibold text-foreground" isUnread
: "font-normal text-foreground/90" ? "text-muted-foreground"
)}> : "text-muted-foreground/80"
{email.subject || "(no subject)"} )}>
</div> {email.preview || "No preview available"}
</p>
{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>
</div> </div>
@@ -247,6 +305,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{/* Hover Quick Actions */} {/* Hover Quick Actions */}
<EmailHoverActions <EmailHoverActions
email={email} email={email}
backgroundClassName={resolvedColorTag ? resolvedColorTag : (selected ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar} onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead} onMarkAsRead={onMarkAsRead}
onDelete={onDelete} onDelete={onDelete}
@@ -280,10 +339,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const t = useTranslations('threads'); const t = useTranslations('threads');
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const isMobile = useUIStore((state) => state.isMobile); const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const displayNames = showRecipient
? Array.from(new Set(
thread.emails.flatMap(e => (e.to ?? []).map(r => r.name || r.email.split('@')[0]))
)).slice(0, 4)
: participantNames;
const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0];
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({ const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
email: latestEmail, email: latestEmail,
@@ -405,15 +476,19 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)} )}
onClick={handleHeaderClick} onClick={handleHeaderClick}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }} style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
> >
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}> <div
className={cn('px-3', 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 for thread selection - only visible when in selection mode */} {/* Checkbox for thread selection - only visible when in selection mode */}
{selectedEmailIds.size > 0 && ( {selectedEmailIds.size > 0 && (
<button <button
onClick={handleThreadCheckboxClick} onClick={handleThreadCheckboxClick}
className={cn( 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", "hover:bg-muted/50 hover:scale-110",
"active:scale-95", "active:scale-95",
"animate-in fade-in zoom-in-95 duration-150", "animate-in fade-in zoom-in-95 duration-150",
@@ -428,7 +503,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</button> </button>
)} )}
{!isMobile && ( {!isMobile && !isFocusedMailLayout && (
<button <button
data-expand-toggle data-expand-toggle
onClick={(e) => { onClick={(e) => {
@@ -460,98 +535,155 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div> </div>
)} )}
{density !== 'extra-compact' && ( {!isFocusedMailLayout && density !== 'extra-compact' && (
<Avatar <Avatar
name={latestEmail.from?.[0]?.name} name={avatarPerson?.name}
email={latestEmail.from?.[0]?.email} email={avatarPerson?.email}
size="md" size="md"
className="flex-shrink-0 shadow-sm" className="flex-shrink-0 shadow-sm"
/> />
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1"> {isFocusedMailLayout ? (
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center justify-between gap-3">
<span className={cn( <div className="flex min-w-0 flex-1 items-center gap-3">
"truncate text-sm", <span className={cn(
hasUnread 'w-32 shrink-0 truncate text-sm lg:w-44',
? "font-bold text-foreground" hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
: "font-medium text-muted-foreground" )}>
)}> {displayNames.join(', ')}
{participantNames.join(", ")} </span>
</span> <span
<span className={cn(
className={cn( 'inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium',
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium", hasUnread ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
hasUnread )}
? "bg-primary text-primary-foreground" title={t('messages_tooltip', { count: emailCount })}
: "bg-muted text-muted-foreground" >
)} <MessageSquare className="w-3 h-3" />
title={t('messages_tooltip', { count: emailCount })} {emailCount}
> </span>
<MessageSquare className="w-3 h-3" /> <div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
{emailCount} <span className={cn(
</span> 'shrink-0 truncate',
<div className="flex items-center gap-1.5"> hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
{hasStarred && ( )}>
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" /> {latestEmail.subject || '(no subject)'}
)} </span>
{hasAnswered && !hasForwarded && ( {inlinePreview && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" /> <span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
)} )}
{hasForwarded && !hasAnswered && ( </div>
<Forward className="w-3.5 h-3.5 text-muted-foreground" /> </div>
)} <div className="flex items-center gap-2.5 shrink-0">
{hasStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{hasAnswered && !hasForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
{hasForwarded && !hasAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
{hasAnswered && hasForwarded && ( {hasAnswered && hasForwarded && (
<> <>
<Reply className="w-3.5 h-3.5 text-muted-foreground" /> <Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" /> <Forward className="w-3.5 h-3.5 text-muted-foreground" />
</> </>
)} )}
{hasAttachment && ( {hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" /> {keywordDef && (
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
)} )}
<span className={cn(
'text-xs tabular-nums',
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(latestEmail.receivedAt)}
</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 flex-shrink-0"> ) : (
{keywordDef && ( <>
<span className={cn( <div className="flex items-center justify-between gap-2 mb-1">
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full", <div className="flex items-center gap-2 min-w-0 flex-1">
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted" <span className={cn(
)}> "truncate text-sm",
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} /> hasUnread
{keywordDef.label} ? "font-bold text-foreground"
</span> : "font-medium text-muted-foreground"
)} )}>
<span className={cn( {displayNames.join(", ")}
"text-xs tabular-nums", </span>
<span
className={cn(
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
hasUnread
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}
title={t('messages_tooltip', { count: emailCount })}
>
<MessageSquare className="w-3 h-3" />
{emailCount}
</span>
<div className="flex items-center gap-1.5">
{hasStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{hasAnswered && !hasForwarded && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
)}
{hasForwarded && !hasAnswered && (
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
)}
{hasAnswered && hasForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{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">
{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",
hasUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(latestEmail.receivedAt)}
</span>
</div>
</div>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
hasUnread hasUnread
? "text-foreground font-semibold" ? "font-semibold text-foreground"
: "text-muted-foreground" : "font-normal text-foreground/90"
)}> )}>
{formatDate(latestEmail.receivedAt)} {latestEmail.subject || "(no subject)"}
</span> </div>
</div>
</div>
<div className={cn( {showPreview && density !== 'extra-compact' && (
"mb-1 line-clamp-1 text-sm", <p className={cn(
hasUnread "text-sm leading-relaxed line-clamp-2",
? "font-semibold text-foreground" hasUnread
: "font-normal text-foreground/90" ? "text-muted-foreground"
)}> : "text-muted-foreground/80"
{latestEmail.subject || "(no subject)"} )}>
</div> {latestEmail.preview || "No preview available"}
</p>
{showPreview && density !== 'extra-compact' && ( )}
<p className={cn( </>
"text-sm leading-relaxed line-clamp-2",
hasUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{latestEmail.preview || "No preview available"}
</p>
)} )}
</div> </div>
</div> </div>
@@ -559,6 +691,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{/* Hover Quick Actions for thread header */} {/* Hover Quick Actions for thread header */}
<EmailHoverActions <EmailHoverActions
email={latestEmail} email={latestEmail}
backgroundClassName={colorTag ? colorTag : (isSelected ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined} onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined} onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
@@ -568,7 +701,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
/> />
</div> </div>
{isExpanded && !isMobile && ( {isExpanded && !isMobile && !isFocusedMailLayout && (
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200"> <div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
{isLoading ? ( {isLoading ? (
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground"> <div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
+1 -1
View File
@@ -837,7 +837,7 @@ export function FileBrowser({
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="flex flex-col h-full" className="flex flex-col flex-1 min-h-0"
onClick={handleContainerClick} onClick={handleContainerClick}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
+1
View File
@@ -223,6 +223,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
{!loading && !error && fileType === "pdf" && objectUrl && ( {!loading && !error && fileType === "pdf" && objectUrl && (
<iframe <iframe
src={objectUrl} src={objectUrl}
sandbox="allow-same-origin"
className="w-full max-w-5xl h-full rounded-lg bg-white" className="w-full max-w-5xl h-full rounded-lg bg-white"
title={name} title={name}
/> />
+2 -2
View File
@@ -16,7 +16,7 @@ import type {
FilterActionType, FilterActionType,
} from "@/lib/jmap/sieve-types"; } from "@/lib/jmap/sieve-types";
import type { Mailbox } from "@/lib/jmap/types"; import type { Mailbox } from "@/lib/jmap/types";
import { buildMailboxTree, flattenMailboxTree, type MailboxNode } from "@/lib/utils"; import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
interface FilterRuleModalProps { interface FilterRuleModalProps {
rule?: FilterRule; rule?: FilterRule;
@@ -109,7 +109,7 @@ export function FilterRuleModal({
} }
onSave({ onSave({
id: rule?.id || crypto.randomUUID(), id: rule?.id || generateUUID(),
name: trimmedName, name: trimmedName,
enabled: rule?.enabled ?? true, enabled: rule?.enabled ?? true,
matchType, matchType,
@@ -14,6 +14,10 @@ function useSyncIdentities() {
const syncIdentities = useAuthStore((state) => state.syncIdentities); const syncIdentities = useAuthStore((state) => state.syncIdentities);
return syncIdentities; return syncIdentities;
} }
function useRefreshIdentities() {
return useAuthStore((state) => state.refreshIdentities);
}
import type { Identity, EmailAddress } from '@/lib/jmap/types'; import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { toast } from '@/stores/toast-store'; import { toast } from '@/stores/toast-store';
import { useFocusTrap } from '@/hooks/use-focus-trap'; import { useFocusTrap } from '@/hooks/use-focus-trap';
@@ -49,6 +53,15 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary); const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary);
const syncIdentities = useSyncIdentities(); const syncIdentities = useSyncIdentities();
const refreshIdentitiesFromServer = useRefreshIdentities();
// Refresh identities from server whenever the modal is opened
useEffect(() => {
if (isOpen) {
refreshIdentitiesFromServer();
}
}, [isOpen, refreshIdentitiesFromServer]);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
+146 -22
View File
@@ -2,7 +2,7 @@
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield } from "lucide-react"; import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield, LogOut, Check } from "lucide-react";
import { AccountSwitcher } from "./account-switcher"; import { AccountSwitcher } from "./account-switcher";
import { icons as lucideIcons, type LucideIcon } from "lucide-react"; import { icons as lucideIcons, type LucideIcon } from "lucide-react";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
@@ -16,8 +16,12 @@ import { useWebDAVStore } from "@/stores/webdav-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store"; import { usePolicyStore } from "@/stores/policy-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getInitials } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
interface NavItem { interface NavItem {
id: string; id: string;
@@ -164,20 +168,60 @@ export function NavigationRail({
const { mailboxes } = useEmailStore(); const { mailboxes } = useEmailStore();
const { supportsWebDAV } = useWebDAVStore(); const { supportsWebDAV } = useWebDAVStore();
const sidebarApps = useSettingsStore((s) => s.sidebarApps); const sidebarApps = useSettingsStore((s) => s.sidebarApps);
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled')); const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : []; const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0; const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
// Account list for rail
const accounts = useAccountStore((s) => s.accounts);
const activeAccountId = useAccountStore((s) => s.activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount);
const logout = useAuthStore((s) => s.logout);
const logoutAll = useAuthStore((s) => s.logoutAll);
const [logoutMenuOpen, setLogoutMenuOpen] = useState(false);
const logoutBtnRef = useRef<HTMLButtonElement>(null);
const logoutPopoverRef = useRef<HTMLDivElement>(null);
const [logoutPopoverStyle, setLogoutPopoverStyle] = useState<React.CSSProperties>({});
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
const updateLogoutPosition = useCallback(() => {
if (!logoutBtnRef.current) return;
const rect = logoutBtnRef.current.getBoundingClientRect();
setLogoutPopoverStyle({
position: "fixed",
left: rect.right + 8,
bottom: Math.max(8, window.innerHeight - rect.bottom),
});
}, []);
useEffect(() => {
if (!logoutMenuOpen) return;
updateLogoutPosition();
const handleClickOutside = (e: MouseEvent) => {
if (
logoutBtnRef.current?.contains(e.target as Node) ||
logoutPopoverRef.current?.contains(e.target as Node)
) return;
setLogoutMenuOpen(false);
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") setLogoutMenuOpen(false);
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [logoutMenuOpen, updateLogoutPosition]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const { client } = useAuthStore.getState(); const headers = getActiveAccountSlotHeaders();
if (!client) return; if (!headers['X-JMAP-Cookie-Slot']) return;
const headers: Record<string, string> = {
'Authorization': client.getAuthHeader(),
'X-JMAP-Server-URL': client.getServerUrl(),
'X-JMAP-Username': client.getUsername(),
};
fetch('/api/admin/stalwart-check', { headers }) fetch('/api/admin/stalwart-check', { headers })
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
@@ -199,7 +243,7 @@ export function NavigationRail({
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread }, { id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar }, { id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" }, { id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false }, { id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false || !filesEnabled },
]; ];
const isSettingsActive = !activeAppId && pathname.startsWith("/settings"); const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
@@ -504,6 +548,22 @@ export function NavigationRail({
<Keyboard className="w-[18px] h-[18px]" /> <Keyboard className="w-[18px] h-[18px]" />
</button> </button>
)} )}
{!onShowShortcuts && (
<>
<button
onClick={() => setShowShortcutsModal(true)}
data-tour="nav-shortcuts"
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={t("keyboard_shortcuts")}
>
<Keyboard className="w-[18px] h-[18px]" />
</button>
<KeyboardShortcutsModal
isOpen={showShortcutsModal}
onClose={() => setShowShortcutsModal(false)}
/>
</>
)}
{quota && quota.total > 0 && ( {quota && quota.total > 0 && (
<div data-tour="storage-quota"> <div data-tour="storage-quota">
@@ -511,21 +571,85 @@ export function NavigationRail({
</div> </div>
)} )}
{isPushConnected != null && ( {onLogout && showRailAccountList && accounts.length > 0 && (
<span <>
className="relative group" <div className="w-8 border-t my-1" style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }} />
title={isPushConnected ? t("push_connected") : t("push_disconnected")}
> {/* Account circles */}
<span <div className="flex flex-col items-center gap-3">
className={cn( {accounts.map((account) => {
"inline-block w-1.5 h-1.5 rounded-full transition-all duration-300", const isActive = account.id === activeAccountId;
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40" const initials = getInitials(account.displayName || account.label, account.email || account.username);
)} return (
/> <button
</span> key={account.id}
onClick={() => {
if (!isActive) switchAccount(account.id);
}}
className={cn(
"relative flex items-center justify-center w-8 h-8 rounded-full text-white text-[11px] font-medium transition-all flex-shrink-0",
isActive
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
: "opacity-70 hover:opacity-100"
)}
style={{ backgroundColor: account.avatarColor }}
title={`${account.displayName || account.label} (${account.email || account.username})`}
>
{initials}
{isActive && (
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-primary flex items-center justify-center">
<Check className="w-2 h-2 text-primary-foreground" />
</span>
)}
</button>
);
})}
</div>
{/* Logout button with popover */}
<button
ref={logoutBtnRef}
onClick={() => setLogoutMenuOpen(!logoutMenuOpen)}
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={t("sign_out")}
aria-expanded={logoutMenuOpen}
aria-haspopup="true"
>
<LogOut className="w-4 h-4" />
</button>
{logoutMenuOpen && createPortal(
<div
ref={logoutPopoverRef}
style={logoutPopoverStyle}
className="w-56 rounded-lg border border-border bg-background text-foreground shadow-lg z-50 overflow-hidden"
role="menu"
>
<button
onClick={() => { setLogoutMenuOpen(false); logout(); }}
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-foreground hover:bg-muted transition-colors"
role="menuitem"
>
<LogOut className="w-4 h-4" />
{t("sign_out")}
</button>
{accounts.length > 1 && (
<button
onClick={() => { setLogoutMenuOpen(false); logoutAll(); }}
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-destructive hover:bg-muted transition-colors"
role="menuitem"
>
<LogOut className="w-4 h-4" />
{t("sign_out_all")}
</button>
)}
</div>,
document.body
)}
</>
)} )}
{onLogout && ( {onLogout && !showRailAccountList && (
<AccountSwitcher variant="rail" /> <AccountSwitcher variant="rail" />
)} )}
</div> </div>
+2 -2
View File
@@ -4,7 +4,7 @@ import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight } from 'lucide-react'; import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight } from 'lucide-react';
import { icons as lucideIcons, type LucideIcon } from 'lucide-react'; import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn, generateUUID } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { IconPicker } from './icon-picker'; import { IconPicker } from './icon-picker';
@@ -207,7 +207,7 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
}); });
const handleCreate = useCallback((data: SidebarAppFormData) => { const handleCreate = useCallback((data: SidebarAppFormData) => {
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const id = `app-${generateUUID()}`;
addSidebarApp({ id, ...data }); addSidebarApp({ id, ...data });
setIsCreating(false); setIsCreating(false);
}, [addSidebarApp]); }, [addSidebarApp]);
+132 -42
View File
@@ -12,14 +12,12 @@ import {
Star, Star,
Trash2, Trash2,
Archive, Archive,
PenSquare,
ChevronsLeft, ChevronsLeft,
ChevronsRight, ChevronsRight,
ChevronRight, ChevronRight,
ChevronDown, ChevronDown,
Folder, Folder,
FolderOpen, FolderOpen,
Users,
User, User,
Palmtree, Palmtree,
Settings, Settings,
@@ -60,10 +58,6 @@ interface SidebarProps {
const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean, isShared?: boolean, id?: string) => { const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean, isShared?: boolean, id?: string) => {
const lowerName = name?.toLowerCase() || ""; const lowerName = name?.toLowerCase() || "";
if (id === 'shared-folders-root') {
return isExpanded ? FolderOpen : Users;
}
if (id?.startsWith('shared-account-')) { if (id?.startsWith('shared-account-')) {
return isExpanded ? FolderOpen : User; return isExpanded ? FolderOpen : User;
} }
@@ -309,7 +303,7 @@ function TagItem({
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", palette?.dot || "bg-gray-400", !isCollapsed && "mr-2")} /> <span className={cn("w-3 h-3 rounded-full flex-shrink-0", palette?.dot || "bg-gray-400", !isCollapsed && "mr-2")} />
{!isCollapsed && ( {!isCollapsed && (
<> <>
<span className="truncate">{kw.label}</span> <span className="flex-1 truncate">{kw.label}</span>
<span className="flex items-center gap-1.5 ml-2 flex-shrink-0"> <span className="flex items-center gap-1.5 ml-2 flex-shrink-0">
{unreadCount > 0 && ( {unreadCount > 0 && (
<span className={cn( <span className={cn(
@@ -428,9 +422,16 @@ export function Sidebar({
onUnreadFilterClick, onUnreadFilterClick,
className, className,
}: SidebarProps) { }: SidebarProps) {
const router = useRouter();
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore(); const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
const { primaryIdentity: _primaryIdentity } = useAuthStore(); const { primaryIdentity: _primaryIdentity } = useAuthStore();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set()); const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [foldersExpanded, setFoldersExpanded] = useState(() => {
try {
const stored = localStorage.getItem('sidebarFoldersExpanded');
return stored !== null ? JSON.parse(stored) : true;
} catch { return true; }
});
const [tagsExpanded, setTagsExpanded] = useState(() => { const [tagsExpanded, setTagsExpanded] = useState(() => {
try { try {
const stored = localStorage.getItem('sidebarTagsExpanded'); const stored = localStorage.getItem('sidebarTagsExpanded');
@@ -438,6 +439,7 @@ export function Sidebar({
} catch { return true; } } catch { return true; }
}); });
const emailKeywords = useSettingsStore(s => s.emailKeywords); const emailKeywords = useSettingsStore(s => s.emailKeywords);
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher);
const tagCounts = useEmailStore(s => s.tagCounts); const tagCounts = useEmailStore(s => s.tagCounts);
const t = useTranslations('sidebar'); const t = useTranslations('sidebar');
@@ -452,10 +454,17 @@ export function Sidebar({
} }
} else { } else {
const tree = buildMailboxTree(mailboxes); const tree = buildMailboxTree(mailboxes);
const defaultExpanded = tree const collectExpandable = (nodes: MailboxNode[]): string[] => {
.filter(node => node.children.length > 0) const ids: string[] = [];
.map(node => node.id); for (const node of nodes) {
setExpandedFolders(new Set(defaultExpanded)); if (node.children.length > 0) {
ids.push(node.id);
ids.push(...collectExpandable(node.children));
}
}
return ids;
};
setExpandedFolders(new Set(collectExpandable(tree)));
} }
}, [mailboxes]); }, [mailboxes]);
@@ -539,7 +548,7 @@ export function Sidebar({
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />} {isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
</Button> </Button>
{!isCollapsed && ( {!isCollapsed && !hideAccountSwitcher && (
<AccountSwitcher variant="expanded" className="flex-1" /> <AccountSwitcher variant="expanded" className="flex-1" />
)} )}
</div> </div>
@@ -553,24 +562,103 @@ export function Sidebar({
{/* Mailbox List */} {/* Mailbox List */}
<div className="flex-1 overflow-y-auto" data-tour="sidebar"> <div className="flex-1 overflow-y-auto" data-tour="sidebar">
<div className="py-1"> <div className="py-1">
{mailboxes.length === 0 ? ( {/* Folders Section Header */}
<div className="px-4 py-2 text-sm text-muted-foreground"> <div
{!isCollapsed && t("loading_mailboxes")} style={{ paddingBlock: 'var(--density-sidebar-py)' }}
</div> className={cn(
) : ( "group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 font-medium",
isCollapsed ? "justify-center px-1" : "px-2",
"text-foreground hover:bg-muted"
)}
>
{!isCollapsed && (
<button
onClick={() => {
setFoldersExpanded((prev: boolean) => {
const next = !prev;
try { localStorage.setItem('sidebarFoldersExpanded', JSON.stringify(next)); } catch { /* */ }
return next;
});
}}
className={cn(
"p-0.5 rounded mr-1 transition-all duration-200",
"hover:bg-muted active:bg-accent"
)}
title={foldersExpanded ? t('collapse_tooltip') : t('expand_tooltip')}
>
{foldersExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
)}
</button>
)}
<button
onClick={() => {
if (isCollapsed) return;
setFoldersExpanded((prev: boolean) => {
const next = !prev;
try { localStorage.setItem('sidebarFoldersExpanded', JSON.stringify(next)); } catch { /* */ }
return next;
});
}}
className={cn(
"flex items-center px-1 rounded",
"transition-colors duration-150",
isCollapsed ? "justify-center" : "flex-1 text-left"
)}
style={{ paddingBlock: 'var(--density-sidebar-py)', ...(isCollapsed ? {} : { paddingLeft: '4px' }) }}
title={isCollapsed ? t("folders") : undefined}
>
<Folder className={cn(
"w-4 h-4 flex-shrink-0 transition-colors",
!isCollapsed && "mr-2",
foldersExpanded && "text-primary"
)} />
{!isCollapsed && (
<span className="flex-1 truncate">{t("folders")}</span>
)}
</button>
{!isCollapsed && (
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'folders'); } catch { /* */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted active:bg-accent ml-auto mr-1"
title={t('settings')}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
)}
</div>
{/* Folder Items */}
{((foldersExpanded && !isCollapsed) || isCollapsed) && (
<> <>
{mailboxTree.map((node) => ( {mailboxes.length === 0 ? (
<MailboxTreeItem <div className="px-4 py-2 text-sm text-muted-foreground">
key={node.id} {!isCollapsed && t("loading_mailboxes")}
node={node} </div>
selectedMailbox={selectedKeyword ? "" : selectedMailbox} ) : (
expandedFolders={expandedFolders} <>
onMailboxSelect={onMailboxSelect} {mailboxTree.map((node) => (
onToggleExpand={handleToggleExpand} <MailboxTreeItem
isCollapsed={isCollapsed} key={node.id}
onUnreadFilterClick={onUnreadFilterClick} node={node}
/> selectedMailbox={selectedKeyword ? "" : selectedMailbox}
))} expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
/>
))}
</>
)}
</> </>
)} )}
</div> </div>
@@ -635,6 +723,20 @@ export function Sidebar({
<span className="flex-1 truncate">{t("tags")}</span> <span className="flex-1 truncate">{t("tags")}</span>
)} )}
</button> </button>
{!isCollapsed && (
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'keywords'); } catch { /* */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted active:bg-accent ml-auto mr-1"
title={t('settings')}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
)}
</div> </div>
{((tagsExpanded && !isCollapsed) || isCollapsed) && ( {((tagsExpanded && !isCollapsed) || isCollapsed) && (
@@ -661,19 +763,7 @@ export function Sidebar({
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />} {!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
</div> </div>
{/* Compose Button */}
<div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}>
{isCollapsed ? (
<Button onClick={onCompose} variant="ghost" size="icon" title={t("compose_hint")} data-tour="compose-button">
<PenSquare className="w-5 h-5" />
</Button>
) : (
<Button onClick={onCompose} className="w-full" title={t("compose_hint")} data-tour="compose-button">
<PenSquare className="w-4 h-4 mr-2" />
{t("compose")}
</Button>
)}
</div>
</div> </div>
); );
} }
+8
View File
@@ -6,24 +6,32 @@ import { useLocaleStore } from '@/stores/locale-store';
import enMessages from '@/locales/en/common.json'; import enMessages from '@/locales/en/common.json';
import frMessages from '@/locales/fr/common.json'; import frMessages from '@/locales/fr/common.json';
import jaMessages from '@/locales/ja/common.json'; import jaMessages from '@/locales/ja/common.json';
import koMessages from '@/locales/ko/common.json';
import esMessages from '@/locales/es/common.json'; import esMessages from '@/locales/es/common.json';
import itMessages from '@/locales/it/common.json'; import itMessages from '@/locales/it/common.json';
import deMessages from '@/locales/de/common.json'; import deMessages from '@/locales/de/common.json';
import lvMessages from '@/locales/lv/common.json';
import nlMessages from '@/locales/nl/common.json'; import nlMessages from '@/locales/nl/common.json';
import plMessages from '@/locales/pl/common.json';
import ptMessages from '@/locales/pt/common.json'; import ptMessages from '@/locales/pt/common.json';
import ruMessages from '@/locales/ru/common.json'; import ruMessages from '@/locales/ru/common.json';
import zhMessages from '@/locales/zh/common.json';
// Pre-loaded translations (loaded at build time, not runtime) // Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = { const ALL_MESSAGES = {
en: enMessages, en: enMessages,
fr: frMessages, fr: frMessages,
ja: jaMessages, ja: jaMessages,
ko: koMessages,
es: esMessages, es: esMessages,
it: itMessages, it: itMessages,
de: deMessages, de: deMessages,
lv: lvMessages,
nl: nlMessages, nl: nlMessages,
pl: plMessages,
pt: ptMessages, pt: ptMessages,
ru: ruMessages, ru: ruMessages,
zh: zhMessages,
}; };
interface IntlProviderProps { interface IntlProviderProps {
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { useEffect, useState } from "react";
import { X, Download } from "lucide-react";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
const DISMISSED_KEY = "pwa-install-dismissed";
export function PWAInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
if (localStorage.getItem(DISMISSED_KEY)) return;
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setShowPrompt(true);
};
window.addEventListener("beforeinstallprompt", handler);
return () => {
window.removeEventListener("beforeinstallprompt", handler);
};
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") {
setDeferredPrompt(null);
setShowPrompt(false);
}
};
const handleDismiss = () => {
setShowPrompt(false);
};
const handleDismissForever = () => {
localStorage.setItem(DISMISSED_KEY, "1");
setShowPrompt(false);
};
if (!showPrompt || !deferredPrompt) {
return null;
}
return (
<div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-start gap-3">
<Download className="w-5 h-5 mt-0.5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div>
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
Install Bulwark
</h3>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
Install our app for quick access and offline support.
</p>
</div>
</div>
<button
onClick={handleDismiss}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
aria-label="Dismiss install prompt"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<button
onClick={handleDismiss}
className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
>
Not now
</button>
<button
onClick={handleInstall}
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
>
Install
</button>
</div>
<button
onClick={handleDismissForever}
className="w-full text-xs text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors text-center"
>
Don&apos;t remind me again
</button>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
"use client";
import { useEffect } from "react";
export function ServiceWorkerRegistration() {
useEffect(() => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
return;
}
if (process.env.NODE_ENV !== "production") {
// In development, unregister any previously installed service worker
// and clear its caches so hot reload always serves fresh assets.
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((registration) => registration.unregister());
});
if ("caches" in window) {
caches.keys().then((keys) => {
keys.forEach((key) => caches.delete(key));
});
}
return;
}
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log("Service Worker registered successfully:", registration);
})
.catch((error) => {
console.error("Service Worker registration failed:", error);
});
}, []);
return null;
}
@@ -157,6 +157,7 @@ function DisplayNameSection() {
<Input <Input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder={displayName || t('display_name.placeholder')}
className="w-48" className="w-48"
/> />
<Button <Button
@@ -523,7 +524,7 @@ export function AccountSecuritySettings() {
if (isStalwart === false) { if (isStalwart === false) {
return ( return (
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
<p className="text-sm text-muted-foreground py-4">{t('not_available')}</p> <div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: t('not_available') }} />
</SettingsSection> </SettingsSection>
); );
} }
@@ -0,0 +1,247 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Book, Pencil, Share2, Tag } from "lucide-react";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
import { SettingsSection } from "./settings-section";
import { cn } from "@/lib/utils";
import type { AddressBook } from "@/lib/jmap/types";
function AddressBookEditRow({
initial,
onSave,
onCancel,
isLoading,
}: {
initial: string;
onSave: (name: string) => void;
onCancel: () => void;
isLoading: boolean;
}) {
const t = useTranslations("contacts.address_books");
const tCal = useTranslations("calendar.management");
const [name, setName] = useState(initial);
const isValid = name.trim().length > 0;
return (
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name_label")}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && isValid) onSave(name.trim());
if (e.key === "Escape") onCancel();
}}
className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
disabled={isLoading}
/>
</div>
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => isValid && onSave(name.trim())}
disabled={isLoading || !isValid}
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{tCal("save")}
</button>
<button
onClick={onCancel}
disabled={isLoading}
className="px-3 py-1.5 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{tCal("cancel")}
</button>
</div>
</div>
);
}
export function AddressBookManagementSettings() {
const t = useTranslations("contacts.address_books");
const tContacts = useTranslations("contacts");
const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore();
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, renameKeyword } = useContactStore();
const [editingId, setEditingId] = useState<string | null>(null);
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (client && addressBooks.length === 0) {
fetchAddressBooks(client);
}
}, [client, addressBooks.length, fetchAddressBooks]);
const handleUpdate = async (book: AddressBook, newName: string) => {
if (!client) return;
setIsLoading(true);
try {
await renameAddressBook(client, book, newName);
setEditingId(null);
toast.success(t("renamed"));
} catch {
toast.error(t("rename_failed"));
} finally {
setIsLoading(false);
}
};
// Group: personal first, then by shared account
const personal = addressBooks.filter((b) => !b.isShared);
const sharedGroups = new Map<string, { accountName: string; books: AddressBook[] }>();
for (const book of addressBooks) {
if (!book.isShared || !book.accountId) continue;
const key = book.accountId;
const existing = sharedGroups.get(key);
if (existing) existing.books.push(book);
else sharedGroups.set(key, { accountName: book.accountName || book.accountId, books: [book] });
}
const renderBook = (book: AddressBook) => {
if (editingId === book.id) {
return (
<AddressBookEditRow
key={book.id}
initial={book.name}
onSave={(name) => handleUpdate(book, name)}
onCancel={() => setEditingId(null)}
isLoading={isLoading}
/>
);
}
const canRename = !book.isShared || book.myRights?.mayWrite !== false;
return (
<div
key={book.id}
className={cn(
"flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
)}
>
<Book className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{book.name}</span>
</div>
{book.isDefault && (
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{t("default")}
</span>
)}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{canRename && (
<button
type="button"
onClick={() => setEditingId(book.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t("rename")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
};
// Collect keywords with counts
const keywordCounts: Record<string, number> = {};
for (const c of contacts) {
if (c.kind === "group" || !c.keywords) continue;
for (const [kw, active] of Object.entries(c.keywords)) {
if (active) keywordCounts[kw] = (keywordCounts[kw] || 0) + 1;
}
}
const sortedKeywords = Object.entries(keywordCounts).sort(([a], [b]) => a.localeCompare(b));
const handleRenameKeyword = async (oldKw: string, newKw: string) => {
setIsLoading(true);
try {
await renameKeyword(supportsSync && client ? client : null, oldKw, newKw);
setEditingKeyword(null);
toast.success(tContacts("category_renamed"));
} catch {
toast.error(tContacts("category_rename_failed"));
} finally {
setIsLoading(false);
}
};
return (
<>
<SettingsSection title={tSettings("manage_title")} description={tSettings("manage_description")}>
<div className="space-y-2">
{personal.map(renderBook)}
{Array.from(sharedGroups.entries()).map(([accountId, group]) => (
<div key={accountId} className="mt-4 space-y-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
{t("shared_prefix", { name: group.accountName })}
</h4>
{group.books.map(renderBook)}
</div>
))}
{addressBooks.length === 0 && (
<p className="text-sm text-muted-foreground py-2">{tSettings("no_address_books")}</p>
)}
</div>
</SettingsSection>
<div className="mt-8">
<SettingsSection title={tSettings("categories_title")} description={tSettings("categories_description")}>
<div className="space-y-2">
{sortedKeywords.map(([keyword, count]) => {
if (editingKeyword === keyword) {
return (
<AddressBookEditRow
key={keyword}
initial={keyword}
onSave={(name) => handleRenameKeyword(keyword, name)}
onCancel={() => setEditingKeyword(null)}
isLoading={isLoading}
/>
);
}
return (
<div
key={keyword}
className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
>
<Tag className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{keyword}</span>
</div>
<span className="text-xs text-muted-foreground tabular-nums">{count}</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => setEditingKeyword(keyword)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={tContacts("rename_category")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
{sortedKeywords.length === 0 && (
<p className="text-sm text-muted-foreground py-2">{tSettings("no_categories")}</p>
)}
</div>
</SettingsSection>
</div>
</>
);
}
+84 -1
View File
@@ -7,16 +7,36 @@ import { useConfig } from '@/hooks/use-config';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
import { ExternalLink } from 'lucide-react';
import { SpamSiegeGame } from './spam-siege-game';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
export function AdvancedSettings() { export function AdvancedSettings() {
const t = useTranslations('settings.advanced'); const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const { debugMode, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } = const { debugMode, debugCategories, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
useSettingsStore(); useSettingsStore();
const { settingsSyncEnabled } = useConfig(); const { settingsSyncEnabled } = useConfig();
const [showResetConfirm, setShowResetConfirm] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore(); const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const [showGame, setShowGame] = useState(false);
const logoClickCount = useRef(0);
const logoClickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleLogoClick = () => {
logoClickCount.current++;
if (logoClickTimer.current) clearTimeout(logoClickTimer.current);
if (logoClickCount.current >= 3) {
logoClickCount.current = 0;
setShowGame(true);
} else {
logoClickTimer.current = setTimeout(() => { logoClickCount.current = 0; }, 2000);
}
};
const handleExport = () => { const handleExport = () => {
const settingsJson = exportSettings(); const settingsJson = exportSettings();
@@ -64,6 +84,44 @@ export function AdvancedSettings() {
}; };
return ( return (
<>
{showGame && <SpamSiegeGame onClose={() => setShowGame(false)} />}
{/* About */}
<div className="rounded-lg border border-border bg-card p-5 mb-6">
<div className="flex items-center gap-4">
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About">
<div className="shrink-0">
<img
src="/branding/Bulwark_Logo_Color.svg"
alt="Bulwark"
className="w-12 h-12 object-contain dark:hidden group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
/>
<img
src="/branding/Bulwark_Logo_White.svg"
alt="Bulwark"
className="w-12 h-12 object-contain hidden dark:block group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">
{t('about.title')}
</p>
<p className="text-xs text-muted-foreground group-hover/about:translate-x-0.5 group-active/about:translate-y-px transition-transform">
v{APP_VERSION} <span className="text-muted-foreground/60">({GIT_COMMIT})</span>
</p>
</div>
</button>
<a
href="https://github.com/bulwarkmail/webmail"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
GitHub <ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
{/* Debug Mode */} {/* Debug Mode */}
{!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && ( {!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
@@ -72,6 +130,30 @@ export function AdvancedSettings() {
</SettingItem> </SettingItem>
)} )}
{/* Debug Categories */}
{debugMode && !isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
<div className="ml-4 border-l-2 border-muted pl-4 space-y-1">
<p className="text-xs text-muted-foreground mb-2">{t('debug_categories.description')}</p>
{ALL_DEBUG_CATEGORIES.map((cat) => (
<SettingItem
key={cat.id}
label={t(`debug_categories.${cat.labelKey}`)}
description={t(`debug_categories.${cat.labelKey}_description`)}
>
<ToggleSwitch
checked={debugCategories?.[cat.id] !== false}
onChange={(checked) => {
updateSetting('debugCategories', {
...debugCategories,
[cat.id]: checked,
});
}}
/>
</SettingItem>
))}
</div>
)}
{/* Settings Sync */} {/* Settings Sync */}
{settingsSyncEnabled && ( {settingsSyncEnabled && (
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}> <SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
@@ -122,5 +204,6 @@ export function AdvancedSettings() {
</Button> </Button>
</SettingItem> </SettingItem>
</SettingsSection> </SettingsSection>
</>
); );
} }
+17 -1
View File
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
const t = useTranslations('settings.appearance'); const t = useTranslations('settings.appearance');
const tTour = useTranslations('tour'); const tTour = useTranslations('tour');
const { theme, setTheme } = useThemeStore(); const { theme, setTheme } = useThemeStore();
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, updateSetting } = useSettingsStore(); const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, updateSetting } = useSettingsStore();
const { startTour, resetTourCompletion } = useTour(); const { startTour, resetTourCompletion } = useTour();
const { isSettingLocked, isSettingHidden } = usePolicyStore(); const { isSettingLocked, isSettingHidden } = usePolicyStore();
@@ -145,6 +145,22 @@ export function AppearanceSettings() {
/> />
</SettingItem> </SettingItem>
{/* Hide Account Switcher */}
<SettingItem label={t('hide_account_switcher.label')} description={t('hide_account_switcher.description')}>
<ToggleSwitch
checked={hideAccountSwitcher}
onChange={(checked) => updateSetting('hideAccountSwitcher', checked)}
/>
</SettingItem>
{/* Show Rail Account List */}
<SettingItem label={t('show_rail_account_list.label')} description={t('show_rail_account_list.description')}>
<ToggleSwitch
checked={showRailAccountList}
onChange={(checked) => updateSetting('showRailAccountList', checked)}
/>
</SettingItem>
{/* Animations */} {/* Animations */}
{!isSettingHidden('animationsEnabled') && ( {!isSettingHidden('animationsEnabled') && (
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}> <SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
@@ -4,6 +4,7 @@ import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useCalendarStore } from '@/stores/calendar-store'; import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { toast } from '@/stores/toast-store'; import { toast } from '@/stores/toast-store';
import { SettingsSection } from './settings-section'; import { SettingsSection } from './settings-section';
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react'; import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
@@ -164,6 +165,7 @@ export function CalendarManagementSettings() {
const [colorPickerId, setColorPickerId] = useState<string | null>(null); const [colorPickerId, setColorPickerId] = useState<string | null>(null);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
const [deletingSubId, setDeletingSubId] = useState<string | null>(null); const [deletingSubId, setDeletingSubId] = useState<string | null>(null);
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null); const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
const tImport = useTranslations('calendar.import'); const tImport = useTranslations('calendar.import');
@@ -202,7 +204,10 @@ export function CalendarManagementSettings() {
fetch('/api/caldav/discover', { fetch('/api/caldav/discover', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
...getActiveAccountSlotHeaders(),
},
body: JSON.stringify({ body: JSON.stringify({
accounts: Array.from(accounts.entries()).map(([key, candidates]) => ({ key, candidates })), accounts: Array.from(accounts.entries()).map(([key, candidates]) => ({ key, candidates })),
}), }),
@@ -629,6 +634,14 @@ export function CalendarManagementSettings() {
)} )}
</div> </div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => setEditingSubscription(sub)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={tSub('edit')}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button <button
type="button" type="button"
onClick={() => handleRefreshSubscription(sub.id)} onClick={() => handleRefreshSubscription(sub.id)}
@@ -667,6 +680,14 @@ export function CalendarManagementSettings() {
onClose={() => setShowSubscriptionModal(false)} onClose={() => setShowSubscriptionModal(false)}
/> />
)} )}
{editingSubscription && client && (
<ICalSubscriptionModal
client={client}
editSubscription={editingSubscription}
onClose={() => setEditingSubscription(null)}
/>
)}
</SettingsSection> </SettingsSection>
); );
} }
+29
View File
@@ -19,6 +19,8 @@ export function CalendarSettings() {
showWeekNumbers, showWeekNumbers,
enableCalendarTasks, enableCalendarTasks,
showTasksOnCalendar, showTasksOnCalendar,
showBirthdayCalendar,
calendarHoverPreview,
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
const { isFeatureEnabled } = usePolicyStore(); const { isFeatureEnabled } = usePolicyStore();
@@ -80,6 +82,33 @@ export function CalendarSettings() {
/> />
</SettingItem> </SettingItem>
<SettingItem
label={t('hover_preview')}
description={t('hover_preview_desc')}
>
<Select
value={calendarHoverPreview}
onChange={(value) => updateSetting('calendarHoverPreview', value as 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s')}
options={[
{ value: 'instant', label: t('hover_preview_instant') },
{ value: 'delay-500ms', label: t('hover_preview_delay_500ms') },
{ value: 'delay-1s', label: t('hover_preview_delay_1s') },
{ value: 'delay-2s', label: t('hover_preview_delay_2s') },
{ value: 'off', label: t('hover_preview_off') },
]}
/>
</SettingItem>
<SettingItem
label={t('show_birthday_calendar')}
description={t('show_birthday_calendar_desc')}
>
<ToggleSwitch
checked={showBirthdayCalendar}
onChange={(checked) => updateSetting('showBirthdayCalendar', checked)}
/>
</SettingItem>
{isFeatureEnabled('calendarTasksEnabled') && ( {isFeatureEnabled('calendarTasksEnabled') && (
<> <>
<SettingItem <SettingItem
+184 -5
View File
@@ -4,15 +4,92 @@ import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import type { ArchiveMode, HoverAction } from '@/stores/settings-store'; import type { ArchiveMode, HoverAction, MailLayout } from '@/stores/settings-store';
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store'; import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store'; import { useEmailStore } from '@/stores/email-store';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal'; import { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react'; import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail, X } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
import { useContactStore } from '@/stores/contact-store';
const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
{ sender: 'Nadia', subject: 'Design sync', preview: 'Pushed updated mocks and notes.', selected: true },
{ sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false },
];
function MailLayoutPreview({
value,
t,
}: {
value: MailLayout;
t: (key: string) => string;
}) {
const isSplit = value === 'split';
return (
<div className="mt-3 rounded-xl border border-border bg-background p-3">
<div>
<div className="text-sm font-medium text-foreground">{t(`mail_layout.${value}`)}</div>
<div className="mt-1 text-xs text-muted-foreground">{t(`mail_layout.${value}_description`)}</div>
</div>
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-muted/20">
<div className="flex h-28">
<div className="w-11 border-r border-border bg-muted/40" />
{isSplit ? (
<>
<div className="w-28 border-r border-border bg-background">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
className={cn(
'border-b border-border px-2 py-1.5 text-[10px] last:border-b-0',
row.selected && 'bg-primary/10'
)}
>
<div className="truncate font-medium text-foreground">{row.sender}</div>
<div className="truncate text-muted-foreground">{row.subject}</div>
</div>
))}
</div>
<div className="flex-1 bg-background px-3 py-2">
<div className="h-2.5 w-20 rounded bg-foreground/10" />
<div className="mt-2 h-2 w-full rounded bg-foreground/10" />
<div className="mt-1.5 h-2 w-5/6 rounded bg-foreground/10" />
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
</div>
</>
) : (
<div className="flex-1 bg-background px-2 py-2">
<div className="space-y-1.5">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
className={cn(
'rounded-md px-2 py-1 text-[10px]',
row.selected ? 'bg-primary/10' : 'bg-muted/20'
)}
>
<div className="truncate text-foreground">
<span className="font-medium">{row.sender}</span>
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
<span className="text-muted-foreground/80">{row.preview}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
export function EmailSettings() { export function EmailSettings() {
const t = useTranslations('settings.email_behavior'); const t = useTranslations('settings.email_behavior');
@@ -34,12 +111,16 @@ export function EmailSettings() {
} }
}, []); }, []);
const [newKeyword, setNewKeyword] = useState('');
const { const {
markAsReadDelay, markAsReadDelay,
deleteAction, deleteAction,
permanentlyDeleteJunk, permanentlyDeleteJunk,
showPreview, showPreview,
mailLayout,
disableThreading, disableThreading,
autoSelectReplyIdentity,
plainTextMode, plainTextMode,
emailsPerPage, emailsPerPage,
externalContentPolicy, externalContentPolicy,
@@ -51,17 +132,23 @@ export function EmailSettings() {
hoverActionsMode, hoverActionsMode,
hoverActionsCorner, hoverActionsCorner,
trustedSenders, trustedSenders,
trustedSendersAddressBook,
attachmentReminderEnabled,
attachmentReminderKeywords,
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
const { trustedSenderEmails } = useContactStore();
// Get count label for trusted senders button // Get count label for trusted senders button
const getTrustedSendersCount = () => { const getTrustedSendersCount = () => {
const count = trustedSenders.length; const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length;
if (count === 0) return t('trusted_senders.count_zero'); if (count === 0) return t('trusted_senders.count_zero');
if (count === 1) return t('trusted_senders.count_one'); if (count === 1) return t('trusted_senders.count_one');
return t('trusted_senders.count_other', { count }); return t('trusted_senders.count_other', { count });
}; };
const isFocusedLayout = mailLayout === 'focus';
const handleReorganizeArchive = async () => { const handleReorganizeArchive = async () => {
const { client } = useAuthStore.getState(); const { client } = useAuthStore.getState();
const { mailboxes, fetchMailboxes } = useEmailStore.getState(); const { mailboxes, fetchMailboxes } = useEmailStore.getState();
@@ -207,9 +294,29 @@ export function EmailSettings() {
/> />
</SettingItem> </SettingItem>
{!isSettingHidden('mailLayout') && (
<SettingItem label={t('mail_layout.label')} description={t('mail_layout.description')} locked={isSettingLocked('mailLayout')}>
<div className="w-[22rem] max-w-full">
<RadioGroup
value={mailLayout}
onChange={(value) => updateSetting('mailLayout', value as MailLayout)}
options={[
{ value: 'split', label: t('mail_layout.split') },
{ value: 'focus', label: t('mail_layout.focus') },
]}
/>
<MailLayoutPreview value={mailLayout} t={t} />
</div>
</SettingItem>
)}
{/* Show Preview */} {/* Show Preview */}
{!isSettingHidden('showPreview') && ( {!isSettingHidden('showPreview') && (
<SettingItem label={t('show_preview.label')} description={t('show_preview.description')} locked={isSettingLocked('showPreview')}> <SettingItem
label={t('show_preview.label')}
description={isFocusedLayout ? t('show_preview.focus_description') : t('show_preview.description')}
locked={isSettingLocked('showPreview')}
>
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} /> <ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
</SettingItem> </SettingItem>
)} )}
@@ -230,6 +337,70 @@ export function EmailSettings() {
/> />
</SettingItem> </SettingItem>
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
<ToggleSwitch
checked={autoSelectReplyIdentity}
onChange={(checked) => updateSetting('autoSelectReplyIdentity', checked)}
/>
</SettingItem>
{/* Attachment Reminder */}
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
<ToggleSwitch
checked={attachmentReminderEnabled}
onChange={(checked) => updateSetting('attachmentReminderEnabled', checked)}
/>
</SettingItem>
{attachmentReminderEnabled && (
<div className="py-3 border-b border-border space-y-2">
<div>
<label className="text-sm font-medium text-foreground">{t('attachment_reminder.keywords_label')}</label>
<p className="text-xs text-muted-foreground mt-1">{t('attachment_reminder.keywords_description')}</p>
</div>
<div className="flex flex-wrap gap-1.5">
{attachmentReminderKeywords.map((kw) => (
<span key={kw} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-foreground">
{kw}
<button
type="button"
aria-label={t('attachment_reminder.remove')}
onClick={() => updateSetting('attachmentReminderKeywords', attachmentReminderKeywords.filter(k => k !== kw))}
className="text-muted-foreground hover:text-foreground"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
const trimmed = newKeyword.trim().toLowerCase();
if (trimmed && !attachmentReminderKeywords.includes(trimmed)) {
updateSetting('attachmentReminderKeywords', [...attachmentReminderKeywords, trimmed]);
}
setNewKeyword('');
}}
>
<input
type="text"
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
placeholder={t('attachment_reminder.add_placeholder')}
className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
/>
<button
type="submit"
disabled={!newKeyword.trim()}
className="px-3 py-1 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('attachment_reminder.add')}
</button>
</form>
</div>
)}
{/* Quick Hover Actions */} {/* Quick Hover Actions */}
{isFeatureEnabled('hoverActionsConfigEnabled') && ( {isFeatureEnabled('hoverActionsConfigEnabled') && (
<div className="py-3 border-b border-border space-y-3"> <div className="py-3 border-b border-border space-y-3">
@@ -404,6 +575,14 @@ export function EmailSettings() {
</button> </button>
</SettingItem> </SettingItem>
{/* Trusted Senders — address book storage */}
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
<ToggleSwitch
checked={trustedSendersAddressBook}
onChange={(checked) => updateSetting('trustedSendersAddressBook', checked)}
/>
</SettingItem>
{/* Trusted Senders Modal */} {/* Trusted Senders Modal */}
<TrustedSendersModal <TrustedSendersModal
isOpen={showTrustedModal} isOpen={showTrustedModal}
+19 -3
View File
@@ -99,11 +99,17 @@ function IconPicker({ currentIcon, onSelect, onClose }: {
export function FolderSettings() { export function FolderSettings() {
const t = useTranslations('settings.folders'); const t = useTranslations('settings.folders');
const { client } = useAuthStore(); const { client } = useAuthStore();
const { mailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore(); const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore();
const { folderIcons, setFolderIcon } = useSettingsStore(); const { folderIcons, setFolderIcon } = useSettingsStore();
const { isFeatureEnabled } = usePolicyStore(); const { isFeatureEnabled } = usePolicyStore();
const folderIconsAllowed = isFeatureEnabled('folderIconsEnabled'); const folderIconsAllowed = isFeatureEnabled('folderIconsEnabled');
useEffect(() => {
if (client && mailboxes.length === 0) {
fetchMailboxes(client);
}
}, [client, mailboxes.length, fetchMailboxes]);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [creatingParentId, setCreatingParentId] = useState<string | null>(null); const [creatingParentId, setCreatingParentId] = useState<string | null>(null);
const [newFolderName, setNewFolderName] = useState(''); const [newFolderName, setNewFolderName] = useState('');
@@ -200,8 +206,18 @@ export function FolderSettings() {
await deleteMailbox(client, mailboxId); await deleteMailbox(client, mailboxId);
setDeletingId(null); setDeletingId(null);
toast.success(t('folder_deleted')); toast.success(t('folder_deleted'));
} catch { } catch (err: unknown) {
toast.error(t('error_delete')); const jmapType = (err as Error & { jmapType?: string })?.jmapType;
switch (jmapType) {
case 'mailboxHasChild':
toast.error(t('error_delete_has_children'));
break;
case 'mailboxHasEmail':
toast.error(t('error_delete_has_email'));
break;
default:
toast.error(t('error_delete'));
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
+62 -3
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import React, { useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store"; import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
@@ -41,16 +41,39 @@ function KeywordRow({
keyword, keyword,
onEdit, onEdit,
onDelete, onDelete,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
isDragOver,
isDragging,
}: { }: {
keyword: KeywordDefinition; keyword: KeywordDefinition;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onDragStart: () => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: () => void;
onDragEnd: () => void;
isDragOver: boolean;
isDragging: boolean;
}) { }) {
const t = useTranslations("settings.keywords"); const t = useTranslations("settings.keywords");
const palette = KEYWORD_PALETTE[keyword.color]; const palette = KEYWORD_PALETTE[keyword.color];
return ( return (
<div className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"> <div
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
className={cn(
"flex items-center gap-3 py-2.5 px-3 rounded-md border bg-background group transition-opacity",
isDragging ? "opacity-40" : "opacity-100",
isDragOver ? "border-primary" : "border-border"
)}
>
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" /> <GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} /> <div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span> <span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
@@ -166,9 +189,39 @@ export function KeywordSettings() {
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [isMigrating, setIsMigrating] = useState(false); const [isMigrating, setIsMigrating] = useState(false);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const existingIds = emailKeywords.map((k) => k.id); const existingIds = emailKeywords.map((k) => k.id);
const handleDragStart = (index: number) => {
setDragIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (index !== dragOverIndex) setDragOverIndex(index);
};
const handleDrop = (index: number) => {
if (dragIndex === null || dragIndex === index) {
setDragIndex(null);
setDragOverIndex(null);
return;
}
const reordered = [...emailKeywords];
const [moved] = reordered.splice(dragIndex, 1);
reordered.splice(index, 0, moved);
reorderKeywords(reordered);
setDragIndex(null);
setDragOverIndex(null);
};
const handleDragEnd = () => {
setDragIndex(null);
setDragOverIndex(null);
};
const handleAdd = (keyword: KeywordDefinition) => { const handleAdd = (keyword: KeywordDefinition) => {
addKeyword(keyword); addKeyword(keyword);
setIsAdding(false); setIsAdding(false);
@@ -220,7 +273,7 @@ export function KeywordSettings() {
{t("migrating")} {t("migrating")}
</div> </div>
)} )}
{emailKeywords.map((keyword) => {emailKeywords.map((keyword, index) =>
editingId === keyword.id ? ( editingId === keyword.id ? (
<KeywordEditForm <KeywordEditForm
key={keyword.id} key={keyword.id}
@@ -238,6 +291,12 @@ export function KeywordSettings() {
setIsAdding(false); setIsAdding(false);
}} }}
onDelete={() => handleDelete(keyword.id)} onDelete={() => handleDelete(keyword.id)}
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={() => handleDrop(index)}
onDragEnd={handleDragEnd}
isDragOver={dragOverIndex === index && dragIndex !== index}
isDragging={dragIndex === index}
/> />
) )
)} )}
+28 -5
View File
@@ -20,7 +20,7 @@ const STATUS_COLORS: Record<PluginStatus, string> = {
export function PluginsSettings() { export function PluginsSettings() {
const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore(); const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore();
const { isFeatureEnabled, isPluginForceEnabled, fetchPolicy, loaded } = usePolicyStore(); const { isFeatureEnabled, isPluginForceEnabled, isPluginApproved, fetchPolicy, loaded } = usePolicyStore();
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null); const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -75,6 +75,13 @@ export function PluginsSettings() {
return; return;
} }
const requireApproval = isFeatureEnabled('requirePluginApproval');
const isApproved = plugin.adminApproved || plugin.managed || isPluginApproved(plugin.id);
if (!plugin.enabled && requireApproval && !isApproved) {
toast.info(`Plugin "${plugin.name}" requires admin approval before it can be enabled`);
return;
}
if (plugin.enabled) { if (plugin.enabled) {
disablePlugin(plugin.id); disablePlugin(plugin.id);
toast.info(`Plugin "${plugin.name}" disabled`); toast.info(`Plugin "${plugin.name}" disabled`);
@@ -108,20 +115,26 @@ export function PluginsSettings() {
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{plugins.map(plugin => ( {plugins.map(plugin => {
const requireApproval = isFeatureEnabled('requirePluginApproval');
const isApproved = plugin.adminApproved || plugin.managed || isPluginApproved(plugin.id);
const needsApproval = requireApproval && !isApproved;
return (
<PluginCard <PluginCard
key={plugin.id} key={plugin.id}
plugin={plugin} plugin={plugin}
isExpanded={expandedPlugin === plugin.id} isExpanded={expandedPlugin === plugin.id}
isForceEnabled={plugin.forceEnabled || isPluginForceEnabled(plugin.id)} isForceEnabled={plugin.forceEnabled || isPluginForceEnabled(plugin.id)}
isManaged={Boolean(plugin.managed)} isManaged={Boolean(plugin.managed)}
needsApproval={needsApproval}
controlsDisabled={!initialized} controlsDisabled={!initialized}
onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)} onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)}
onToggle={() => handleToggle(plugin)} onToggle={() => handleToggle(plugin)}
onUninstall={() => handleUninstall(plugin)} onUninstall={() => handleUninstall(plugin)}
onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)} onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)}
/> />
))} );
})}
</div> </div>
)} )}
@@ -166,6 +179,7 @@ interface PluginCardProps {
isExpanded: boolean; isExpanded: boolean;
isForceEnabled: boolean; isForceEnabled: boolean;
isManaged: boolean; isManaged: boolean;
needsApproval: boolean;
controlsDisabled: boolean; controlsDisabled: boolean;
onToggleExpand: () => void; onToggleExpand: () => void;
onToggle: () => void; onToggle: () => void;
@@ -173,7 +187,7 @@ interface PluginCardProps {
onUpdateSettings: (settings: Record<string, unknown>) => void; onUpdateSettings: (settings: Record<string, unknown>) => void;
} }
function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDisabled, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) { function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, needsApproval, controlsDisabled, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) {
return ( return (
<div className={cn( <div className={cn(
'rounded-lg border transition-colors', 'rounded-lg border transition-colors',
@@ -197,6 +211,11 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
<Server className="w-2.5 h-2.5" /> Managed <Server className="w-2.5 h-2.5" /> Managed
</span> </span>
)} )}
{needsApproval && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400">
Awaiting approval
</span>
)}
</div> </div>
<div className="flex items-center gap-2 mt-0.5"> <div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">{plugin.author}</span> <span className="text-xs text-muted-foreground">{plugin.author}</span>
@@ -206,7 +225,7 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} disabled={controlsDisabled || isForceEnabled} /> <ToggleSwitch checked={plugin.enabled} onChange={onToggle} disabled={controlsDisabled || isForceEnabled || needsApproval} />
</div> </div>
</div> </div>
@@ -217,6 +236,10 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
<p className="text-xs text-amber-600 dark:text-amber-400">This plugin is forced by an administrator and cannot be disabled or uninstalled.</p> <p className="text-xs text-amber-600 dark:text-amber-400">This plugin is forced by an administrator and cannot be disabled or uninstalled.</p>
)} )}
{needsApproval && (
<p className="text-xs text-orange-600 dark:text-orange-400">This plugin is awaiting admin approval and cannot be enabled until an administrator approves it.</p>
)}
{/* Description */} {/* Description */}
{plugin.description && ( {plugin.description && (
<p className="text-xs text-muted-foreground">{plugin.description}</p> <p className="text-xs text-muted-foreground">{plugin.description}</p>
@@ -11,7 +11,7 @@ import { IconPicker } from "@/components/layout/icon-picker";
import { useSettingsStore, type SidebarApp } from "@/stores/settings-store"; import { useSettingsStore, type SidebarApp } from "@/stores/settings-store";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { cn } from "@/lib/utils"; import { cn, generateUUID } from "@/lib/utils";
interface SidebarAppFormData { interface SidebarAppFormData {
name: string; name: string;
@@ -188,7 +188,7 @@ export function SidebarAppsSettings() {
const draggedIndexRef = useRef<number | null>(null); const draggedIndexRef = useRef<number | null>(null);
const handleAdd = useCallback((data: SidebarAppFormData) => { const handleAdd = useCallback((data: SidebarAppFormData) => {
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const id = `app-${generateUUID()}`;
addSidebarApp({ id, ...data }); addSidebarApp({ id, ...data });
setShowAddForm(false); setShowAddForm(false);
}, [addSidebarApp]); }, [addSidebarApp]);
+4 -2
View File
@@ -19,6 +19,7 @@ import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-di
import { SmimeCertificateModal } from "@/components/settings/smime-certificate-modal"; import { SmimeCertificateModal } from "@/components/settings/smime-certificate-modal";
import { useSmimeStore } from "@/stores/smime-store"; import { useSmimeStore } from "@/stores/smime-store";
import { useIdentityStore } from "@/stores/identity-store"; import { useIdentityStore } from "@/stores/identity-store";
import { useAuthStore } from "@/stores/auth-store";
import { exportPkcs12, downloadPkcs12 } from "@/lib/smime/pkcs12-export"; import { exportPkcs12, downloadPkcs12 } from "@/lib/smime/pkcs12-export";
import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types"; import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types";
@@ -50,6 +51,7 @@ export function SmimeSettings() {
} = useSmimeStore(); } = useSmimeStore();
const { identities } = useIdentityStore(); const { identities } = useIdentityStore();
const activeAccountId = useAuthStore((s) => s.activeAccountId);
// Local UI state // Local UI state
const [importDialogOpen, setImportDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false);
@@ -75,8 +77,8 @@ export function SmimeSettings() {
const [exportError, setExportError] = useState<string | null>(null); const [exportError, setExportError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
load(); load(activeAccountId ?? undefined);
}, [load]); }, [load, activeAccountId]);
// ── PKCS#12 import flow ──────────────────────────────────────── // ── PKCS#12 import flow ────────────────────────────────────────
+387
View File
@@ -0,0 +1,387 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { Shield, Mail, X, AlertTriangle, Trophy, RotateCcw, Inbox, MailCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
const GAME_WIDTH = 400;
const GAME_HEIGHT = 520;
const FORTRESS_Y = GAME_HEIGHT - 48;
const SPAWN_INTERVAL_START = 850;
const SPAWN_INTERVAL_MIN = 320;
const GAME_DURATION = 30;
const ENEMY_SPEED_START = 1.2;
const ENEMY_SPEED_INCREASE = 0.04;
interface Enemy {
id: number;
x: number;
y: number;
speed: number;
type: "spam" | "phishing" | "legit";
}
type GameState = "idle" | "playing" | "won" | "lost";
export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
const [gameState, setGameState] = useState<GameState>("idle");
const [enemies, setEnemies] = useState<Enemy[]>([]);
const [score, setScore] = useState(0);
const [timeLeft, setTimeLeft] = useState(GAME_DURATION);
const [shieldHealth, setShieldHealth] = useState(3);
const [hitEffects, setHitEffects] = useState<{ id: number; x: number; y: number; color: string }[]>([]);
const [destroyEffects, setDestroyEffects] = useState<{ id: number; x: number; y: number }[]>([]);
const [deliverEffects, setDeliverEffects] = useState<{ id: number; x: number; y: number }[]>([]);
const nextId = useRef(0);
const animFrameRef = useRef<number>(0);
const lastTimeRef = useRef<number>(0);
const spawnTimerRef = useRef<number>(0);
const gameStateRef = useRef<GameState>("idle");
const elapsedRef = useRef(0);
const destroyedRef = useRef(new Set<number>());
useEffect(() => {
gameStateRef.current = gameState;
}, [gameState]);
const startGame = useCallback(() => {
setGameState("playing");
setEnemies([]);
setScore(0);
setTimeLeft(GAME_DURATION);
setShieldHealth(3);
setHitEffects([]);
setDestroyEffects([]);
setDeliverEffects([]);
nextId.current = 0;
spawnTimerRef.current = 0;
elapsedRef.current = 0;
destroyedRef.current = new Set();
lastTimeRef.current = performance.now();
}, []);
const spawnEnemy = useCallback(() => {
const id = nextId.current++;
const rand = Math.random();
const type = rand > 0.7 ? "legit" : rand > 0.45 ? "phishing" : "spam";
const x = 20 + Math.random() * (GAME_WIDTH - 60);
const elapsed = elapsedRef.current;
const speed = ENEMY_SPEED_START + (elapsed / 1000) * ENEMY_SPEED_INCREASE;
setEnemies((prev) => [...prev, { id, x, y: -30, speed, type }]);
}, []);
const handleHover = useCallback((enemy: Enemy) => {
if (destroyedRef.current.has(enemy.id)) return;
destroyedRef.current.add(enemy.id);
if (enemy.type === "legit") {
// Penalty for blocking legit mail
setShieldHealth((prev) => {
const nh = prev - 1;
if (nh <= 0) setGameState("lost");
return Math.max(0, nh);
});
setScore((prev) => Math.max(0, prev - 15));
const effectId = nextId.current++;
setHitEffects((p) => [...p, { id: effectId, x: enemy.x, y: enemy.y, color: "rgba(34, 197, 94, 0.5)" }]);
setTimeout(() => setHitEffects((p) => p.filter((h) => h.id !== effectId)), 500);
} else {
setScore((prev) => prev + 10);
const effectId = nextId.current++;
setDestroyEffects((prev) => [...prev, { id: effectId, x: enemy.x, y: enemy.y }]);
setTimeout(() => setDestroyEffects((prev) => prev.filter((e) => e.id !== effectId)), 400);
}
setEnemies((prev) => prev.filter((e) => e.id !== enemy.id));
}, []);
// Game loop
useEffect(() => {
if (gameState !== "playing") return;
const tick = (now: number) => {
if (gameStateRef.current !== "playing") return;
const dt = now - lastTimeRef.current;
lastTimeRef.current = now;
elapsedRef.current += dt;
// Timer
const newTimeLeft = GAME_DURATION - Math.floor(elapsedRef.current / 1000);
setTimeLeft(Math.max(0, newTimeLeft));
if (newTimeLeft <= 0) {
setGameState("won");
return;
}
// Spawn
spawnTimerRef.current += dt;
const spawnInterval = Math.max(
SPAWN_INTERVAL_MIN,
SPAWN_INTERVAL_START - (elapsedRef.current / 1000) * 35
);
if (spawnTimerRef.current >= spawnInterval) {
spawnTimerRef.current = 0;
spawnEnemy();
}
// Move enemies
setEnemies((prev) => {
const next: Enemy[] = [];
let spamBreached = false;
for (const e of prev) {
const ny = e.y + e.speed * (dt / 16);
if (ny >= FORTRESS_Y) {
if (e.type === "legit") {
// Legit mail delivered — bonus
setScore((s) => s + 5);
const effectId = nextId.current++;
setDeliverEffects((p) => [...p, { id: effectId, x: e.x, y: FORTRESS_Y }]);
setTimeout(() => setDeliverEffects((p) => p.filter((d) => d.id !== effectId)), 500);
} else {
spamBreached = true;
const effectId = nextId.current++;
setHitEffects((p) => [...p, { id: effectId, x: e.x, y: FORTRESS_Y, color: "rgba(219, 45, 84, 0.3)" }]);
setTimeout(() => setHitEffects((p) => p.filter((h) => h.id !== effectId)), 500);
}
} else {
next.push({ ...e, y: ny });
}
}
if (spamBreached) {
setShieldHealth((prev) => {
const nh = prev - 1;
if (nh <= 0) setGameState("lost");
return Math.max(0, nh);
});
}
return next;
});
animFrameRef.current = requestAnimationFrame(tick);
};
animFrameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(animFrameRef.current);
}, [gameState, spawnEnemy]);
const getEnemyStyle = (type: Enemy["type"]) => {
switch (type) {
case "phishing":
return { bg: "rgba(234, 179, 8, 0.15)", border: "rgba(234, 179, 8, 0.4)", color: "rgb(234, 179, 8)" };
case "legit":
return { bg: "rgba(34, 197, 94, 0.12)", border: "rgba(34, 197, 94, 0.4)", color: "rgb(34, 197, 94)" };
default:
return { bg: "rgba(219, 45, 84, 0.1)", border: "rgba(219, 45, 84, 0.3)", color: "rgb(219, 45, 84)" };
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="relative rounded-xl border border-border bg-card shadow-2xl overflow-hidden select-none"
style={{ width: GAME_WIDTH, maxWidth: "95vw" }}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-card">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4" style={{ color: "rgb(219, 45, 84)" }} />
<span className="text-sm font-semibold text-foreground">Spam Siege</span>
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-muted transition-colors">
<X className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{/* HUD */}
<div className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b border-border text-xs">
<div className="flex items-center gap-3">
<span className="text-muted-foreground">Score: <span className="font-semibold text-foreground">{score}</span></span>
<span className="text-muted-foreground">Time: <span className="font-semibold text-foreground">{timeLeft}s</span></span>
</div>
<div className="flex items-center gap-1">
{[...Array(3)].map((_, i) => (
<Shield
key={i}
className="w-3.5 h-3.5 transition-colors"
style={{ color: i < shieldHealth ? "rgb(219, 45, 84)" : "rgb(100, 100, 100)" }}
fill={i < shieldHealth ? "rgb(219, 45, 84)" : "none"}
strokeWidth={i < shieldHealth ? 0 : 1.5}
/>
))}
</div>
</div>
{/* Game area */}
<div
className="relative bg-background overflow-hidden"
style={{ height: GAME_HEIGHT }}
>
{/* Grid lines for depth */}
<div className="absolute inset-0 opacity-[0.03]" style={{
backgroundImage: "linear-gradient(to bottom, currentColor 1px, transparent 1px), linear-gradient(to right, currentColor 1px, transparent 1px)",
backgroundSize: "40px 40px",
}} />
{/* Fortress wall */}
<div className="absolute left-0 right-0 bottom-0 flex flex-col items-center" style={{ height: GAME_HEIGHT - FORTRESS_Y }}>
<div className="relative w-full">
{/* Shield centered above the line */}
<div className="absolute -top-5 left-1/2 -translate-x-1/2 z-10">
<Shield
className="w-7 h-7 drop-shadow-sm"
style={{ color: shieldHealth > 0 ? "rgb(219, 45, 84)" : "rgb(100, 100, 100)" }}
fill={shieldHealth > 0 ? "rgba(219, 45, 84, 0.2)" : "none"}
/>
</div>
{/* Solid line */}
<div
className="h-[2px] w-full"
style={{ backgroundColor: shieldHealth > 0 ? "rgba(219, 45, 84, 0.35)" : "rgba(100, 100, 100, 0.3)" }}
/>
</div>
{/* Subtle gradient fill below */}
<div
className="flex-1 w-full"
style={{
background: shieldHealth > 0
? "linear-gradient(to bottom, rgba(219, 45, 84, 0.06), transparent)"
: "linear-gradient(to bottom, rgba(100, 100, 100, 0.04), transparent)",
}}
/>
</div>
{/* Enemies */}
{enemies.map((e) => {
const style = getEnemyStyle(e.type);
return (
<div
key={e.id}
className="absolute flex items-center justify-center w-8 h-8 rounded-md transition-transform"
style={{
left: e.x,
top: e.y,
backgroundColor: style.bg,
border: `1px solid ${style.border}`,
}}
onMouseEnter={() => handleHover(e)}
>
{e.type === "phishing" ? (
<AlertTriangle className="w-4 h-4" style={{ color: style.color }} />
) : e.type === "legit" ? (
<MailCheck className="w-4 h-4" style={{ color: style.color }} />
) : (
<Mail className="w-4 h-4" style={{ color: style.color }} />
)}
</div>
);
})}
{/* Destroy effects */}
{destroyEffects.map((e) => (
<div
key={e.id}
className="absolute pointer-events-none animate-ping"
style={{ left: e.x + 4, top: e.y + 4 }}
>
<X className="w-5 h-5 text-muted-foreground/50" />
</div>
))}
{/* Deliver effects (legit mail arrived) */}
{deliverEffects.map((e) => (
<div
key={e.id}
className="absolute pointer-events-none animate-ping"
style={{ left: e.x + 4, top: e.y - 8 }}
>
<Inbox className="w-5 h-5" style={{ color: "rgb(34, 197, 94)" }} />
</div>
))}
{/* Hit effects on fortress */}
{hitEffects.map((e) => (
<div
key={e.id}
className="absolute pointer-events-none"
style={{ left: e.x, top: e.y - 10 }}
>
<div className="w-6 h-6 rounded-full animate-ping" style={{ backgroundColor: e.color }} />
</div>
))}
{/* Idle overlay */}
{gameState === "idle" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
<Shield className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} fill="rgba(219, 45, 84, 0.1)" />
<div className="text-center">
<p className="text-base font-semibold text-foreground">Spam Siege</p>
<p className="text-xs text-muted-foreground mt-1.5 max-w-[280px] leading-relaxed">
Hover over threats to block them. Let legitimate mail through. Survive {GAME_DURATION} seconds.
</p>
<div className="flex items-center justify-center gap-4 mt-3 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Mail className="w-3 h-3" style={{ color: "rgb(219, 45, 84)" }} /> Spam
</span>
<span className="inline-flex items-center gap-1">
<AlertTriangle className="w-3 h-3" style={{ color: "rgb(234, 179, 8)" }} /> Phishing
</span>
<span className="inline-flex items-center gap-1">
<MailCheck className="w-3 h-3" style={{ color: "rgb(34, 197, 94)" }} /> Legit
</span>
</div>
</div>
<Button size="sm" onClick={startGame} className="mt-1 text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
<Shield className="w-3.5 h-3.5 mr-1.5" />
Defend
</Button>
</div>
)}
{/* Won overlay */}
{gameState === "won" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
<Trophy className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} />
<div className="text-center">
<p className="text-base font-semibold text-foreground">Fortress Secured</p>
<p className="text-xs text-muted-foreground mt-1">
Score: <span className="font-semibold text-foreground">{score}</span>
</p>
</div>
<div className="flex gap-2 mt-1">
<Button size="sm" variant="outline" onClick={onClose}>
Close
</Button>
<Button size="sm" onClick={startGame} className="text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
Again
</Button>
</div>
</div>
)}
{/* Lost overlay */}
{gameState === "lost" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
<Shield className="w-14 h-14 text-muted-foreground/40" />
<div className="text-center">
<p className="text-base font-semibold text-foreground">Fortress Breached</p>
<p className="text-xs text-muted-foreground mt-1">
Score: <span className="font-semibold text-foreground">{score}</span>
</p>
</div>
<div className="flex gap-2 mt-1">
<Button size="sm" variant="outline" onClick={onClose}>
Close
</Button>
<Button size="sm" onClick={startGame} className="text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
Retry
</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
+50 -46
View File
@@ -174,64 +174,68 @@ interface ThemeCardProps {
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, disabled, variants, onActivate, onRemove }: ThemeCardProps) { function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, disabled, variants, onActivate, onRemove }: ThemeCardProps) {
return ( return (
<button <div className="relative">
onClick={onActivate} <button
disabled={disabled} type="button"
className={cn( onClick={onActivate}
'relative flex flex-col items-center p-3 rounded-xl border-2 transition-all text-left w-full disabled:cursor-not-allowed disabled:opacity-60', disabled={disabled}
isActive className={cn(
? 'border-primary bg-primary/5 ring-1 ring-primary/20' 'flex flex-col items-center p-3 rounded-xl border-2 transition-all text-left w-full disabled:cursor-not-allowed disabled:opacity-60',
: 'border-border hover:border-primary/40 bg-card', isActive
disabled && !isActive && 'hover:border-border' ? 'border-primary bg-primary/5 ring-1 ring-primary/20'
)} : 'border-border hover:border-primary/40 bg-card',
> disabled && !isActive && 'hover:border-border'
{/* Preview / Placeholder */}
<div className="w-full aspect-[16/10] rounded-lg mb-2 overflow-hidden bg-muted flex items-center justify-center">
{preview ? (
<img src={preview} alt={name} className="w-full h-full object-cover" />
) : (
<Palette className="w-8 h-8 text-muted-foreground/40" />
)} )}
</div> >
{/* Preview / Placeholder */}
{/* Info */} <div className="w-full aspect-[16/10] rounded-lg mb-2 overflow-hidden bg-muted flex items-center justify-center">
<div className="w-full"> {preview ? (
<div className="flex items-center justify-between gap-1"> <img src={preview} alt={name} className="w-full h-full object-cover" />
<span className="text-sm font-medium text-foreground truncate">{name}</span> ) : (
<div className="flex items-center gap-1 flex-shrink-0"> <Palette className="w-8 h-8 text-muted-foreground/40" />
{isForceEnabled && ( )}
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 font-medium flex items-center gap-0.5" title="Admin enforced">
<Lock className="w-2.5 h-2.5" />
</span>
)}
{isDefault && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">Default</span>
)}
{isActive && <Check className="w-4 h-4 text-primary" />}
</div>
</div> </div>
<span className="text-xs text-muted-foreground truncate block">{author}</span>
{variants && ( {/* Info */}
<div className="flex gap-1 mt-1"> <div className="w-full">
{variants.map(v => ( <div className="flex items-center justify-between gap-1">
<span key={v} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground"> <span className="text-sm font-medium text-foreground truncate">{name}</span>
{v} <div className="flex items-center gap-1 flex-shrink-0">
</span> {isForceEnabled && (
))} <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 font-medium flex items-center gap-0.5" title="Admin enforced">
<Lock className="w-2.5 h-2.5" />
</span>
)}
{isDefault && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">Default</span>
)}
{isActive && <Check className="w-4 h-4 text-primary" />}
</div>
</div> </div>
)} <span className="text-xs text-muted-foreground truncate block">{author}</span>
</div> {variants && (
<div className="flex gap-1 mt-1">
{variants.map(v => (
<span key={v} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{v}
</span>
))}
</div>
)}
</div>
</button>
{/* Remove button */} {/* Remove button */}
{onRemove && !isActive && ( {onRemove && !isActive && (
<button <button
onClick={(e) => { e.stopPropagation(); onRemove(); }} type="button"
onClick={onRemove}
className="absolute top-2 right-2 p-1 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors" className="absolute top-2 right-2 p-1 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title="Remove theme" title="Remove theme"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
)} )}
</button> </div>
); );
} }
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTotpReauthStore } from "@/stores/totp-reauth-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Shield } from "lucide-react";
/**
* Modal dialog that prompts the user for a fresh TOTP code when their
* 2FA session expires (TOTP rotates every ~30 seconds).
*
* Rendered once at the app root level. The JMAP client triggers it via
* the useTotpReauthStore when a 401 is received on a TOTP-authenticated session.
*/
export function TotpReauthDialog() {
const { isOpen, submit, cancel } = useTotpReauthStore();
const [code, setCode] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useFocusTrap({
isActive: isOpen,
onEscape: cancel,
restoreFocus: true,
});
useEffect(() => {
if (isOpen) {
setCode("");
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [isOpen]);
if (!isOpen) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (code.length >= 6) {
submit(code);
}
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={cancel} />
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Two-factor authentication required"
className="relative z-10 w-full max-w-sm mx-4 bg-background rounded-2xl shadow-xl border border-border p-6"
>
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary/10">
<Shield className="w-5 h-5 text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">Session Expired</h2>
<p className="text-sm text-muted-foreground">Your 2FA code has rotated</p>
</div>
</div>
<p className="text-sm text-muted-foreground mb-4">
Enter a fresh authentication code from your authenticator app to continue.
</p>
<p className="text-xs text-amber-600 dark:text-amber-400 mb-4 leading-relaxed">
To avoid being prompted repeatedly, ask your administrator to enable OAuth authentication
(either Stalwart&apos;s built-in OAuth or an external identity provider).
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
ref={inputRef}
type="text"
inputMode="numeric"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
className="h-12 text-center font-mono tracking-widest text-lg bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
placeholder="000000"
autoComplete="one-time-code"
aria-label="Authentication code"
/>
<div className="flex gap-3">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={cancel}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={code.length < 6}
>
Verify
</Button>
</div>
</form>
</div>
</div>
);
}
+64 -17
View File
@@ -2,9 +2,11 @@
import { useState, useEffect, useRef, useMemo } from "react"; import { useState, useEffect, useRef, useMemo } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { X, ShieldCheck, Search, Trash2, Plus } from "lucide-react"; import { X, ShieldCheck, Search, Trash2, Plus, Loader2 } from "lucide-react";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface TrustedSendersModalProps { interface TrustedSendersModalProps {
@@ -17,22 +19,43 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const { trustedSenders, addTrustedSender, removeTrustedSender } = useSettingsStore(); const { trustedSenders, addTrustedSender, removeTrustedSender, trustedSendersAddressBook } = useSettingsStore();
const {
trustedSenderEmails,
trustedSendersLoaded,
trustedSendersLoading,
loadTrustedSendersBook,
addToTrustedSendersBook,
removeFromTrustedSendersBook,
} = useContactStore();
const { client } = useAuthStore();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [newEmail, setNewEmail] = useState(""); const [newEmail, setNewEmail] = useState("");
const [emailError, setEmailError] = useState(""); const [emailError, setEmailError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
// When address book mode is on, load the book on first open
useEffect(() => {
if (isOpen && trustedSendersAddressBook && client && !trustedSendersLoaded) {
loadTrustedSendersBook(client);
}
}, [isOpen, trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]);
// The active list depends on mode
const activeSenders = trustedSendersAddressBook ? trustedSenderEmails : trustedSenders;
const isLoading = trustedSendersAddressBook && (!trustedSendersLoaded || trustedSendersLoading);
// Filter senders based on search query // Filter senders based on search query
const filteredSenders = useMemo(() => { const filteredSenders = useMemo(() => {
if (!searchQuery.trim()) return trustedSenders; if (!searchQuery.trim()) return activeSenders;
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
return trustedSenders.filter((email) => email.toLowerCase().includes(query)); return activeSenders.filter((email) => email.toLowerCase().includes(query));
}, [trustedSenders, searchQuery]); }, [activeSenders, searchQuery]);
// Show search only when 5+ senders // Show search only when 5+ senders
const showSearch = trustedSenders.length >= 5; const showSearch = activeSenders.length >= 5;
// Close on Escape key // Close on Escape key
useEffect(() => { useEffect(() => {
@@ -90,7 +113,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
return emailRegex.test(email); return emailRegex.test(email);
}; };
const handleAddSender = () => { const handleAddSender = async () => {
const trimmedEmail = newEmail.trim().toLowerCase(); const trimmedEmail = newEmail.trim().toLowerCase();
if (!trimmedEmail) { if (!trimmedEmail) {
@@ -103,15 +126,34 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
return; return;
} }
if (trustedSenders.includes(trimmedEmail)) { if (activeSenders.includes(trimmedEmail)) {
setEmailError(t("already_added")); setEmailError(t("already_added"));
return; return;
} }
addTrustedSender(trimmedEmail); setIsSubmitting(true);
setNewEmail(""); try {
setIsAdding(false); if (trustedSendersAddressBook && client) {
setEmailError(""); await addToTrustedSendersBook(client, trimmedEmail);
} else {
addTrustedSender(trimmedEmail);
}
setNewEmail("");
setIsAdding(false);
setEmailError("");
} catch {
setEmailError(t("save_error"));
} finally {
setIsSubmitting(false);
}
};
const handleRemoveSender = async (email: string) => {
if (trustedSendersAddressBook && client) {
await removeFromTrustedSendersBook(client, email);
} else {
removeTrustedSender(email);
}
}; };
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -170,7 +212,11 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{trustedSenders.length === 0 ? ( {isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : activeSenders.length === 0 ? (
/* Empty State */ /* Empty State */
<div className="flex flex-col items-center justify-center py-12 px-6 text-center"> <div className="flex flex-col items-center justify-center py-12 px-6 text-center">
<ShieldCheck className="w-12 h-12 text-muted-foreground/50 mb-4" /> <ShieldCheck className="w-12 h-12 text-muted-foreground/50 mb-4" />
@@ -209,7 +255,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
{email} {email}
</span> </span>
<button <button
onClick={() => removeTrustedSender(email)} onClick={() => handleRemoveSender(email)}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100" className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
aria-label={`${t("remove")} ${email}`} aria-label={`${t("remove")} ${email}`}
> >
@@ -222,7 +268,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
</div> </div>
{/* Footer - Add sender */} {/* Footer - Add sender */}
{trustedSenders.length > 0 && ( {!isLoading && activeSenders.length > 0 && (
<div className="px-6 py-4 border-t border-border flex-shrink-0"> <div className="px-6 py-4 border-t border-border flex-shrink-0">
{isAdding ? ( {isAdding ? (
<div className="space-y-2"> <div className="space-y-2">
@@ -244,9 +290,10 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
/> />
<button <button
onClick={handleAddSender} onClick={handleAddSender}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors text-sm font-medium" disabled={isSubmitting}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors text-sm font-medium disabled:opacity-50"
> >
{t("add_button")} {isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : t("add_button")}
</button> </button>
</div> </div>
{emailError && ( {emailError && (
+30 -7
View File
@@ -1,10 +1,11 @@
"use client"; "use client";
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback, useMemo, useEffect } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore, getContactPhotoUri } from "@/stores/contact-store"; import { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
import { avatarHooks } from "@/lib/plugin-hooks";
const IS_DEV = process.env.NODE_ENV !== "production"; const IS_DEV = process.env.NODE_ENV !== "production";
@@ -143,10 +144,26 @@ interface AvatarProps {
export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) { export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) {
const [imgError, setImgError] = useState(false); const [imgError, setImgError] = useState(false);
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
const senderFavicons = useSettingsStore((s) => s.senderFavicons); const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const contacts = useContactStore((s) => s.contacts); const contacts = useContactStore((s) => s.contacts);
const { devMode } = useConfig(); const { devMode } = useConfig();
// Ask plugins (e.g. Gravatar) to resolve an avatar URL for this email address.
// Runs whenever email or name changes; resets plugin avatar state on each change.
useEffect(() => {
setPluginAvatarUrl(null);
setPluginAvatarFailed(false);
if (!email || avatarHooks.onAvatarResolve.size === 0) return;
let cancelled = false;
avatarHooks.onAvatarResolve
.transform(null as string | null, { email, name })
.then((url) => { if (!cancelled) setPluginAvatarUrl(url); })
.catch(() => { if (!cancelled) setPluginAvatarFailed(true); });
return () => { cancelled = true; };
}, [email, name]);
// Look up contact photo by email from the contact store // Look up contact photo by email from the contact store
const resolvedContactPhoto = useMemo(() => { const resolvedContactPhoto = useMemo(() => {
if (contactPhotoUri) return contactPhotoUri; if (contactPhotoUri) return contactPhotoUri;
@@ -202,19 +219,25 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className }:
const showFavicon = const showFavicon =
senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed; senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
// Priority: contact photo > custom avatar > profile picture > company favicon > initials // Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null; const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
const imgSrc = !imgError && !domainFailed const imgSrc = !imgError && !domainFailed
? resolvedContactPhoto || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null) ? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
: (resolvedContactPhoto || customAvatar || profilePic || null); : (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
const handleImgError = useCallback(() => { const handleImgError = useCallback(() => {
// If the plugin avatar just failed, mark it and fall through to the next source
if (pluginAvatar && imgSrc === pluginAvatar) {
setPluginAvatarFailed(true);
return;
}
setImgError(true); setImgError(true);
// If this was a favicon URL (not a contact photo, custom avatar or profile pic), remember the domain // If this was a favicon URL (not a contact photo, plugin avatar, custom avatar or profile pic), remember the domain
if (faviconDomain && !resolvedContactPhoto && !customAvatar && !profilePic) { if (faviconDomain && !resolvedContactPhoto && !pluginAvatar && !customAvatar && !profilePic) {
failedFaviconDomains.add(faviconDomain); failedFaviconDomains.add(faviconDomain);
} }
}, [faviconDomain, resolvedContactPhoto, customAvatar, profilePic]); }, [imgSrc, pluginAvatar, faviconDomain, resolvedContactPhoto, customAvatar, profilePic]);
return ( return (
<div <div
+5 -1
View File
@@ -12,12 +12,16 @@ export function LanguageSwitcher({ className }: { className?: string }) {
{ value: 'en', label: 'English' }, { value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' }, { value: 'fr', label: 'Français' },
{ value: 'ja', label: '日本語' }, { value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'es', label: 'Español' }, { value: 'es', label: 'Español' },
{ value: 'it', label: 'Italiano' }, { value: 'it', label: 'Italiano' },
{ value: 'de', label: 'Deutsch' }, { value: 'de', label: 'Deutsch' },
{ value: 'lv', label: 'Latviešu' },
{ value: 'nl', label: 'Nederlands' }, { value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' }, { value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' } { value: 'ru', label: 'Русский' },
{ value: 'zh', label: '简体中文' }
]; ];
return ( return (
+1
View File
@@ -71,6 +71,7 @@ export default [
".next/**", ".next/**",
"node_modules/**", "node_modules/**",
"repos/**", "repos/**",
"data/admin/plugins/**",
"*.config.js", "*.config.js",
"*.config.mjs", "*.config.mjs",
"e2e/**", "e2e/**",
+186
View File
@@ -0,0 +1,186 @@
"use client";
import { useEffect, useRef } from "react";
export interface NavSnapshot {
mailboxId: string | null;
emailId: string | null;
threadId: string | null;
composerOpen: boolean;
sidebarOpen: boolean;
}
interface StoredNavState extends NavSnapshot {
navId: number;
}
interface UseBrowserNavigationOptions extends NavSnapshot {
onRestore: (state: NavSnapshot) => void | Promise<void>;
enabled?: boolean;
}
const STATE_KEY = "__mailNav";
let navIdCounter = 0;
function snapshotsEqual(
a: NavSnapshot | undefined | null,
b: NavSnapshot,
): boolean {
if (!a) return false;
return (
a.mailboxId === b.mailboxId &&
a.emailId === b.emailId &&
a.threadId === b.threadId &&
a.composerOpen === b.composerOpen &&
a.sidebarOpen === b.sidebarOpen
);
}
function readStoredState(): StoredNavState | undefined {
if (typeof window === "undefined") return undefined;
const raw = window.history.state as Record<string, unknown> | null;
if (!raw) return undefined;
return raw[STATE_KEY] as StoredNavState | undefined;
}
/**
* Syncs in-app navigation state to the browser history stack so the browser
* back / forward buttons (mouse buttons on desktop, gesture / hardware button
* on mobile) navigate within the mail UI.
*
* - Pushes a new history entry whenever the captured snapshot changes from
* user action.
* - Listens for popstate and calls onRestore so the page can apply the
* previous snapshot (mailbox, email, view, sidebar, conversation thread).
*
* The URL is left untouched so Next.js routes (e.g. /calendar, /settings)
* continue to behave normally.
*/
export function useBrowserNavigation({
mailboxId,
emailId,
threadId,
composerOpen,
sidebarOpen,
onRestore,
enabled = true,
}: UseBrowserNavigationOptions) {
// Counter so overlapping restores don't accidentally clear the flag
// belonging to a later restore.
const popDepthRef = useRef(0);
const isApplyingPopRef = useRef(false);
const restoreRef = useRef(onRestore);
const initializedRef = useRef(false);
// Always keep the latest restore callback in a ref so the popstate
// listener never sees a stale closure.
restoreRef.current = onRestore;
// Install the popstate listener once.
useEffect(() => {
if (typeof window === "undefined") return;
const handlePop = (event: PopStateEvent) => {
const raw = event.state as Record<string, unknown> | null;
const state = raw ? (raw[STATE_KEY] as StoredNavState | undefined) : undefined;
if (!state) return;
// Hold the "applying pop" flag for the entire restore — including any
// async work like fetching email content — so the resulting state
// updates don't trigger a fresh history push that would undo the
// user's back / forward navigation.
popDepthRef.current += 1;
isApplyingPopRef.current = true;
const settle = () => {
popDepthRef.current -= 1;
if (popDepthRef.current === 0) {
// One extra macrotask so React has flushed any state updates
// dispatched at the very end of the restore.
setTimeout(() => {
if (popDepthRef.current === 0) {
isApplyingPopRef.current = false;
}
}, 0);
}
};
let result: void | Promise<void>;
try {
result = restoreRef.current(state);
} catch (error) {
settle();
throw error;
}
if (result && typeof (result as Promise<void>).then === "function") {
(result as Promise<void>).finally(settle);
} else {
settle();
}
};
window.addEventListener("popstate", handlePop);
return () => window.removeEventListener("popstate", handlePop);
}, []);
// Push a new history entry whenever the navigation snapshot changes.
useEffect(() => {
if (!enabled) return;
if (typeof window === "undefined") return;
if (isApplyingPopRef.current) return;
const snapshot: NavSnapshot = {
mailboxId,
emailId,
threadId,
composerOpen,
sidebarOpen,
};
const existing = readStoredState();
if (snapshotsEqual(existing, snapshot)) return;
const stored: StoredNavState = { ...snapshot, navId: ++navIdCounter };
const baseState = (window.history.state ?? {}) as Record<string, unknown>;
const newState = { ...baseState, [STATE_KEY]: stored };
if (!initializedRef.current) {
initializedRef.current = true;
if (emailId || threadId) {
// The app is initializing directly on an email/thread view (e.g. the
// user navigated here from /settings or an external link). Seed a
// "list" history entry first so that the toolbar back button returns
// to the list instead of leaving the app entirely.
const listSnapshot: NavSnapshot = {
mailboxId,
emailId: null,
threadId: null,
composerOpen: false,
sidebarOpen,
};
const listStored: StoredNavState = {
...listSnapshot,
navId: ++navIdCounter,
};
const baseState = (window.history.state ?? {}) as Record<
string,
unknown
>;
window.history.replaceState(
{ ...baseState, [STATE_KEY]: listStored },
"",
);
// Now push the actual email state on top of the synthetic list entry.
window.history.pushState(newState, "");
} else {
// Replace the current entry on the very first run so we don't
// create an extra step the user has to back through to leave the app.
window.history.replaceState(newState, "");
}
} else {
window.history.pushState(newState, "");
}
}, [enabled, mailboxId, emailId, threadId, composerOpen, sidebarOpen]);
}
+4
View File
@@ -25,6 +25,7 @@ interface ConfigData {
loginWebsiteUrl: string; loginWebsiteUrl: string;
demoMode: boolean; demoMode: boolean;
autoSsoEnabled: boolean; autoSsoEnabled: boolean;
allowCustomJmapEndpoint: boolean;
embeddedMode: boolean; embeddedMode: boolean;
parentOrigin: string; parentOrigin: string;
} }
@@ -100,6 +101,7 @@ export function useConfig(): AppConfig {
loginWebsiteUrl: configCache?.loginWebsiteUrl || '', loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
demoMode: configCache?.demoMode || false, demoMode: configCache?.demoMode || false,
autoSsoEnabled: configCache?.autoSsoEnabled || false, autoSsoEnabled: configCache?.autoSsoEnabled || false,
allowCustomJmapEndpoint: configCache?.allowCustomJmapEndpoint || false,
embeddedMode: configCache?.embeddedMode || false, embeddedMode: configCache?.embeddedMode || false,
parentOrigin: configCache?.parentOrigin || '', parentOrigin: configCache?.parentOrigin || '',
isLoading: !configCache, isLoading: !configCache,
@@ -131,6 +133,7 @@ export function useConfig(): AppConfig {
loginWebsiteUrl: configCache.loginWebsiteUrl, loginWebsiteUrl: configCache.loginWebsiteUrl,
demoMode: configCache.demoMode, demoMode: configCache.demoMode,
autoSsoEnabled: configCache.autoSsoEnabled, autoSsoEnabled: configCache.autoSsoEnabled,
allowCustomJmapEndpoint: configCache.allowCustomJmapEndpoint,
embeddedMode: configCache.embeddedMode, embeddedMode: configCache.embeddedMode,
parentOrigin: configCache.parentOrigin, parentOrigin: configCache.parentOrigin,
isLoading: false, isLoading: false,
@@ -163,6 +166,7 @@ export function useConfig(): AppConfig {
loginWebsiteUrl: data.loginWebsiteUrl, loginWebsiteUrl: data.loginWebsiteUrl,
demoMode: data.demoMode, demoMode: data.demoMode,
autoSsoEnabled: data.autoSsoEnabled, autoSsoEnabled: data.autoSsoEnabled,
allowCustomJmapEndpoint: data.allowCustomJmapEndpoint,
embeddedMode: data.embeddedMode, embeddedMode: data.embeddedMode,
parentOrigin: data.parentOrigin, parentOrigin: data.parentOrigin,
isLoading: false, isLoading: false,
+31
View File
@@ -0,0 +1,31 @@
'use client';
import { useEffect } from 'react';
import { useAuthStore } from '@/stores/auth-store';
// Re-sync identities every 30 minutes while the app is open
const SYNC_INTERVAL_MS = 30 * 60 * 1000;
export function useIdentitySync() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const refreshIdentities = useAuthStore((s) => s.refreshIdentities);
useEffect(() => {
if (!isAuthenticated) return;
// Sync when the user returns to the tab (e.g. after adding an alias in Stalwart)
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refreshIdentities();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
const interval = setInterval(refreshIdentities, SYNC_INTERVAL_MS);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
clearInterval(interval);
};
}, [isAuthenticated, refreshIdentities]);
}
+1 -8
View File
@@ -78,14 +78,7 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us
const email = currentEmails.find(em => em.id === emailId); const email = currentEmails.find(em => em.id === emailId);
const keywords = { ...(email?.keywords || {}) }; const keywords = { ...(email?.keywords || {}) };
// Remove old label/color keywords // Add the tag without removing existing ones
Object.keys(keywords).forEach(key => {
if (key.startsWith("$label:") || key.startsWith("$color:")) {
keywords[key] = false;
}
});
// Add the new tag
keywords[`$label:${tagId}`] = true; keywords[`$label:${tagId}`] = true;
await client.updateEmailKeywords(emailId, keywords); await client.updateEmailKeywords(emailId, keywords);
+13 -1
View File
@@ -26,15 +26,27 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'ja': case 'ja':
messages = (await import('../locales/ja/common.json')).default; messages = (await import('../locales/ja/common.json')).default;
break; break;
case 'ko':
messages = (await import('../locales/ko/common.json')).default;
break;
case 'lv':
messages = (await import('../locales/lv/common.json')).default;
break;
case 'nl': case 'nl':
messages = (await import('../locales/nl/common.json')).default; messages = (await import('../locales/nl/common.json')).default;
break; break;
case 'pl':
messages = (await import('../locales/pl/common.json')).default;
break;
case 'pt': case 'pt':
messages = (await import('../locales/pt/common.json')).default; messages = (await import('../locales/pt/common.json')).default;
break; break;
case 'ru': case 'ru':
messages = (await import('../locales/ru/common.json')).default; messages = (await import('../locales/ru/common.json')).default;
break; break;
case 'zh':
messages = (await import('../locales/zh/common.json')).default;
break;
default: default:
messages = (await import('../locales/en/common.json')).default; messages = (await import('../locales/en/common.json')).default;
} }
@@ -45,4 +57,4 @@ export default getRequestConfig(async ({ requestLocale }) => {
timeZone: 'Europe/Paris', timeZone: 'Europe/Paris',
now: new Date() now: new Date()
}; };
}); });
+1 -1
View File
@@ -1,7 +1,7 @@
import { defineRouting } from 'next-intl/routing'; import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({ export const routing = defineRouting({
locales: ['en', 'fr', 'de', 'es', 'it', 'ja', 'nl', 'pt', 'ru'], locales: ['en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'zh'],
defaultLocale: 'en', defaultLocale: 'en',
localePrefix: 'never' localePrefix: 'never'
}); });
@@ -45,4 +45,43 @@ describe('calendar event normalization', () => {
showWithoutTime: true, showWithoutTime: true,
}); });
}); });
describe('recurrenceRule normalization (JSCalendar 2.0 singular→plural)', () => {
it('wraps a single recurrenceRule object in an array', () => {
const raw = {
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' },
} as Record<string, unknown>;
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
expect(result.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'weekly' }]);
expect((result as Record<string, unknown>).recurrenceRule).toBeUndefined();
});
it('passes through recurrenceRule when already an array', () => {
const raw = {
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
recurrenceRule: [{ '@type': 'RecurrenceRule', frequency: 'daily' }],
} as Record<string, unknown>;
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
expect(result.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'daily' }]);
});
it('passes through null recurrenceRule as-is', () => {
const raw = {
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
recurrenceRule: null,
} as Record<string, unknown>;
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
expect(result.recurrenceRules).toBeNull();
});
it('wraps a single excludedRecurrenceRule object in an array', () => {
const raw = {
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
excludedRecurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily' },
} as Record<string, unknown>;
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
expect(result.excludedRecurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'daily' }]);
});
});
}); });
+109 -1
View File
@@ -1,11 +1,16 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { CalendarEvent } from '@/lib/jmap/types'; import type { CalendarEvent } from '@/lib/jmap/types';
import { import {
buildTimedFullDayWeekSegments,
buildWeekSegments, buildWeekSegments,
buildAllDayDuration, buildAllDayDuration,
getEventDayBounds, getEventDayBounds,
getEventDisplayEndDate, getEventDisplayEndDate,
getEventEndDate, getEventEndDate,
getEventStartDate,
getTimedEventBoundsForDay,
isTimedEventFullDayOnDate,
layoutOverlappingEvents,
normalizeAllDayDuration, normalizeAllDayDuration,
} from '../calendar-utils'; } from '../calendar-utils';
@@ -92,7 +97,110 @@ describe('calendar-utils all-day handling', () => {
utcEnd: '2026-03-14T11:00:00Z', utcEnd: '2026-03-14T11:00:00Z',
}); });
expectLocalDateParts(getEventDisplayEndDate(event), 2026, 3, 14, 11); expect(getEventDisplayEndDate(event).toISOString()).toBe('2026-03-14T11:00:00.000Z');
});
it('prefers utc timestamps for timed events with an event timezone', () => {
const event = makeEvent({
start: '2026-03-15T09:00:00',
duration: 'PT1H',
timeZone: 'America/New_York',
showWithoutTime: false,
utcStart: '2026-03-15T13:00:00Z',
utcEnd: '2026-03-15T14:00:00Z',
});
expect(getEventStartDate(event).toISOString()).toBe('2026-03-15T13:00:00.000Z');
expect(getEventEndDate(event).toISOString()).toBe('2026-03-15T14:00:00.000Z');
});
it('clips timed multi-day events to the visible day bounds', () => {
const event = makeEvent({
start: '2026-03-14T22:00:00',
duration: 'PT4H',
showWithoutTime: false,
utcStart: '2026-03-14T22:00:00Z',
utcEnd: '2026-03-15T02:00:00Z',
});
expect(getTimedEventBoundsForDay(event, new Date('2026-03-14T00:00:00Z'))).toMatchObject({
startMinutes: 1380,
endMinutes: 1440,
continuesBefore: false,
continuesAfter: true,
});
expect(getTimedEventBoundsForDay(event, new Date('2026-03-15T00:00:00Z'))).toMatchObject({
startMinutes: 0,
endMinutes: 180,
continuesBefore: true,
continuesAfter: false,
});
});
it('lays out continued timed events using clipped bounds for the active day', () => {
const event = makeEvent({
start: '2026-03-14T22:00:00',
duration: 'PT4H',
showWithoutTime: false,
utcStart: '2026-03-14T22:00:00Z',
utcEnd: '2026-03-15T02:00:00Z',
});
const layout = layoutOverlappingEvents([event], new Date('2026-03-15T00:00:00Z'));
expect(layout).toHaveLength(1);
expect(layout[0]).toMatchObject({
startMinutes: 0,
endMinutes: 180,
column: 0,
totalColumns: 1,
continuesBefore: true,
continuesAfter: false,
});
});
it('detects when a timed multi-day event fully occupies an intermediate day', () => {
const event = makeEvent({
start: '2026-03-14T12:00:00',
duration: 'PT48H',
showWithoutTime: false,
utcStart: '2026-03-14T12:00:00Z',
utcEnd: '2026-03-16T12:00:00Z',
});
expect(isTimedEventFullDayOnDate(event, new Date('2026-03-15T00:00:00Z'))).toBe(true);
expect(isTimedEventFullDayOnDate(event, new Date('2026-03-14T00:00:00Z'))).toBe(false);
expect(isTimedEventFullDayOnDate(event, new Date('2026-03-16T00:00:00Z'))).toBe(false);
});
it('creates week-bar segments for timed events that fully cover visible days', () => {
const week = [
new Date('2026-03-14T00:00:00Z'),
new Date('2026-03-15T00:00:00Z'),
new Date('2026-03-16T00:00:00Z'),
new Date('2026-03-17T00:00:00Z'),
new Date('2026-03-18T00:00:00Z'),
new Date('2026-03-19T00:00:00Z'),
new Date('2026-03-20T00:00:00Z'),
];
const event = makeEvent({
start: '2026-03-14T12:00:00',
duration: 'PT72H',
showWithoutTime: false,
utcStart: '2026-03-14T12:00:00Z',
utcEnd: '2026-03-17T12:00:00Z',
});
const segments = buildTimedFullDayWeekSegments([event], week);
expect(segments).toHaveLength(1);
expect(segments[0]).toMatchObject({
startIndex: 1,
span: 2,
continuesBefore: false,
continuesAfter: false,
});
}); });
it('normalizes imported all-day durations to day units', () => { it('normalizes imported all-day durations to day units', () => {

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