Compare commits

...
64 Commits
Author SHA1 Message Date
Linus Rath 92c7f74420 feat: add right-click context menu to contacts 2026-04-21 19:33:00 +02:00
Linus Rath c2e4518cfa feat: add account addition button to navigation rail 2026-04-21 19:20:50 +02:00
Linus Rath 00f33afdf9 feat: add contact activity component with recent emails and upcoming events 2026-04-21 19:18:36 +02:00
Linus Rath e566cfe687 feat: add API Keys management and IP allowlist for App Passwords 2026-04-21 18:59:47 +02:00
Linus Rath 6b7c849332 refactor: remove Stalwart API URL configuration 2026-04-21 17:50:18 +02:00
Linus Rath 30c4afb977 i18n: add missing translation keys 2026-04-21 17:42:52 +02:00
Linus Rath 1f60671886 fix: clean up unused imports and improve TOTP QR code rendering 2026-04-21 17:30:00 +02:00
Linus Rath 794001fdbd feat: migrate Stalwart management API to JMAP x: methods (0.16)
Drops the 0.15 REST management API and routes all account/auth/crypto/
principal operations through Stalwart 0.16's schema-driven JMAP
endpoint via a single passthrough (/api/account/stalwart/jmap).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Usage:
  NEXT_PUBLIC_LOCALE_PREFIX=always npm run build

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

### Problem

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

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

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

### Fix

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

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

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

### Migration

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

### Compat

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

### Testing

Should run the existing suite; smoke-tested by Jabali Panel which
reverse-proxies Bulwark at `/webmail/` (https://github.com/shukiv/jabali-panel).
2026-04-14 14:37:19 +02:00
Linus Rath 7fcefa53c9 Merge branch 'dev' of https://github.com/bulwarkmail/webmail into dev 2026-04-14 14:30:22 +02:00
Linus Rath bdb76c3d90 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-14 14:28:29 +02:00
Linus Rath 168b36d419 fix: add calendarAddress and replyTo to calendar participants for Stalwart compatibility #189 #192 2026-04-14 14:26:28 +02:00
chrilep a4f57e7a5c fix: add missing lang texts, feat: add ukrainian lang, add flags 2026-04-14 09:39:44 +02:00
Linus Rath 6678501501 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-13 00:51:21 +02:00
Linus Rath fa0045e01b fix: use onSuccessUpdateEmail to send before storing in Sent #188 2026-04-13 00:50:13 +02:00
Linus Rath 1b816d3185 fix: standardize tag naming and fix unknown keyword display #184 #185 2026-04-12 15:37:57 +02:00
Linus Rath 24949e183f feat: add i18n API, render hooks, and new intercept hooks to plugin system 2026-04-12 13:57:20 +02:00
196 changed files with 11756 additions and 3753 deletions
+3 -3
View File
@@ -1,11 +1,11 @@
# Bulwark Webmail Development Configuration # Bulwark Webmail - Development Configuration
# Copy this file to .env.local to run with the built-in mock JMAP server. # Copy this file to .env.local to run with the built-in mock JMAP server.
# No external mail server required great for UI development and testing. # No external mail server required - great for UI development and testing.
# #
# Usage: # Usage:
# cp .env.dev.example .env.local # cp .env.dev.example .env.local
# npm run dev # npm run dev
# Open http://localhost:3000 log in with any username/password. # Open http://localhost:3000 - log in with any username/password.
# ============================================================================= # =============================================================================
# Mock JMAP Server # Mock JMAP Server
+57 -22
View File
@@ -1,4 +1,4 @@
# Bulwark Webmail Production Configuration # Bulwark Webmail - Production Configuration
# Copy this file to .env.local and fill in your values. # Copy this file to .env.local and fill in your values.
# For development with the built-in mock server, see .env.dev.example instead. # For development with the built-in mock server, see .env.dev.example instead.
@@ -6,7 +6,7 @@
# JMAP Server (required) # JMAP Server (required)
# ============================================================================= # =============================================================================
# App name displayed in the UI # App name displayed in the UI, browser tab title, and PWA manifest.
APP_NAME=Bulwark Webmail APP_NAME=Bulwark Webmail
# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set) # URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
@@ -27,11 +27,6 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Set to "false" to disable if using a non-Stalwart JMAP server. # Set to "false" to disable if using a non-Stalwart JMAP server.
# STALWART_FEATURES=true # STALWART_FEATURES=true
# If your reverse proxy doesn't forward Stalwart management API paths
# (/api/account/*, /api/principal/*), set this to the URL where Stalwart's
# HTTP listener is directly reachable. Defaults to JMAP_SERVER_URL if not set.
# STALWART_API_URL=https://admin.example.com
# ============================================================================= # =============================================================================
# OAuth / OpenID Connect (optional) # OAuth / OpenID Connect (optional)
# ============================================================================= # =============================================================================
@@ -76,7 +71,7 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Directory for storing encrypted settings files (default: ./data/settings). # Directory for storing encrypted settings files (default: ./data/settings).
# For Docker, the working directory is /app, so the default resolves to # For Docker, the working directory is /app, so the default resolves to
# /app/data/settings mount a persistent volume there: # /app/data/settings - mount a persistent volume there:
# volumes: # volumes:
# - bulwark-settings:/app/data/settings # - bulwark-settings:/app/data/settings
# SETTINGS_DATA_DIR=./data/settings # SETTINGS_DATA_DIR=./data/settings
@@ -106,41 +101,81 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Branding (all optional) # Branding (all optional)
# ============================================================================= # =============================================================================
# Custom favicon for the browser tab. # ---------------------------------------------------------------------------
# App identity
# ---------------------------------------------------------------------------
# Short name for the app, used in contexts where space is limited
# (e.g. home screen label on mobile). Defaults to APP_NAME if not set.
# APP_SHORT_NAME=Bulwark
# Description shown in the PWA manifest (displayed by the OS during install).
# Defaults to a generic Bulwark description if not set.
# APP_DESCRIPTION=Your personal webmail
# ---------------------------------------------------------------------------
# Icons & favicon
# ---------------------------------------------------------------------------
# Custom favicon shown in the browser tab.
# Supported formats: SVG (recommended), PNG, ICO. # Supported formats: SVG (recommended), PNG, ICO.
# Recommended size: 32×32px minimum, 512×512px maximum (or SVG for best scaling). # Can be an absolute URL (https://...) or a path relative to the public/ directory.
# Can be an absolute URL or a path relative to the public/ directory.
# Defaults to the Bulwark favicon if not set. # Defaults to the Bulwark favicon if not set.
# FAVICON_URL=/branding/my-favicon.svg # FAVICON_URL=/branding/my-favicon.svg
# Custom logos for the sidebar (shown in the main app after login). # Source image used to auto-generate PWA icons (192×192 and 512×512 PNG).
# Supported formats: SVG (recommended for best quality) or PNG (≥512×512px recommended).
# Can be an absolute URL (https://...) or a path relative to the public/ directory.
# Falls back to FAVICON_URL if not set, and to the default Bulwark icons if neither is set.
# PWA_ICON_URL=/branding/my-icon.svg
# ---------------------------------------------------------------------------
# PWA appearance
# ---------------------------------------------------------------------------
# Color applied to the browser UI chrome when the app is installed as a PWA
# (address bar, status bar on Android). Default: #ffffff
# PWA_THEME_COLOR=#3b82f6
# Background color shown on the PWA splash screen while the app is loading.
# Should match your app's main background color. Default: #ffffff
# PWA_BACKGROUND_COLOR=#ffffff
# ---------------------------------------------------------------------------
# Logos
# ---------------------------------------------------------------------------
# Logos shown in the sidebar (main app, after login).
# Supported formats: SVG (recommended), PNG, WebP. # Supported formats: SVG (recommended), PNG, WebP.
# Recommended size: min 24×24px, max 128×128px # Recommended size: min 24×24px, max 128×128px.
# Can be absolute URLs or paths relative to the public/ directory. # Can be absolute URLs or paths relative to the public/ directory.
# If not set, no logo is shown in the sidebar. # If not set, no logo is shown in the sidebar.
# APP_LOGO_LIGHT_URL=/branding/my-logo-color.svg # APP_LOGO_LIGHT_URL=/branding/my-logo-color.svg
# APP_LOGO_DARK_URL=/branding/my-logo-white.svg # APP_LOGO_DARK_URL=/branding/my-logo-white.svg
# Custom logo images for the login page. # Logos shown on the login page.
# Supported formats: SVG (recommended), PNG, WebP. # Supported formats: SVG (recommended), PNG, WebP.
# Recommended size: min 32×32px, max 512×512px # Recommended size: min 32×32px, max 512×512px.
# Can be absolute URLs or paths relative to the public/ directory. # Can be absolute URLs or paths relative to the public/ directory.
# Light mode logo (shown on light backgrounds), defaults to Bulwark logo. # Light mode logo (shown on light backgrounds). Defaults to the Bulwark logo.
LOGIN_LOGO_LIGHT_URL=/branding/Bulwark_Logo_Color.svg LOGIN_LOGO_LIGHT_URL=/branding/Bulwark_Logo_Color.svg
# # Dark mode logo (shown on dark backgrounds). Defaults to the Bulwark white logo.
# Dark mode logo (shown on dark backgrounds), defaults to Bulwark white logo.
LOGIN_LOGO_DARK_URL=/branding/Bulwark_Logo_Color.svg LOGIN_LOGO_DARK_URL=/branding/Bulwark_Logo_Color.svg
# Company or organization name displayed above the version on the login page # ---------------------------------------------------------------------------
# Login page
# ---------------------------------------------------------------------------
# Company name shown above the version number on the login page.
LOGIN_COMPANY_NAME=Bulwark Webmail LOGIN_COMPANY_NAME=Bulwark Webmail
# URL for the imprint/legal notice link on the login page # URL for the imprint / legal notice link on the login page.
# LOGIN_IMPRINT_URL=https://example.com/imprint # LOGIN_IMPRINT_URL=https://example.com/imprint
# URL for the privacy policy link on the login page # URL for the privacy policy link on the login page.
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy # LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
# URL for the company website link on the login page # URL for the company website link on the login page.
LOGIN_WEBSITE_URL=https://bulwarkmail.org LOGIN_WEBSITE_URL=https://bulwarkmail.org
# ============================================================================= # =============================================================================
+46 -2
View File
@@ -1,14 +1,56 @@
# Changelog # Changelog
## 1.4.14 (2026-04-16)
Thank you for your donations:
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
**One-time**
- [@mkorthaus-private](https://github.com/mkorthaus-private)
- [@boris22100](https://github.com/boris22100)
**Monthly**
- [@pr0ton11](https://github.com/pr0ton11)
### Features
- **Email**: Add unified mailbox across accounts and sidebar icons toggle
- **Email**: Enhance email deletion and spam handling with improved parameterization
- **Sieve**: Enhance external rule handling in parser and store (#201)
- **Plugins**: Add i18n API, render hooks, and new intercept hooks to plugin system
- **PWA**: Dynamic PWA manifest with configurable name, description, and icons
- **PWA**: Show app name and logo in install prompt
- **i18n**: Add Ukrainian language with flags and missing translation keys
- **i18n**: Configurable locale prefix via `NEXT_PUBLIC_LOCALE_PREFIX`
- **API**: Add `apiFetch` helper for mount-prefix-aware API calls
### Fixes
- **Calendar**: Send iMIP invitation emails when creating or updating calendar events (#192)
- **Calendar**: RFC 5545/6047 compliance for outgoing iMIP calendar emails
- **Calendar**: Add `calendarAddress` and `replyTo` to participants for Stalwart compatibility (#189, #192)
- **Calendar**: Improve CalDAV task detection for external clients like Thunderbird (#84)
- **Email**: Hide ICS attachments from attachment list when invitation banner is shown
- **Email**: Send before storing in Sent via `onSuccessUpdateEmail` (#188)
- **Email**: Standardize tag naming and fix unknown keyword display (#184, #185)
- **i18n**: Skip intl middleware for paths already containing a locale prefix
- **Docs**: Document PWA and branding env vars in `.env.example`
- **Docs**: Use `company` consistently in `.env.example` branding comments
## 1.4.13 (2026-04-12) ## 1.4.13 (2026-04-12)
Thank you for your donations: Thank you for your donations:
**One-time** **One-time**
- [@boris22100](https://github.com/boris22100) - [@boris22100](https://github.com/boris22100)
- [@mkorthaus-private](https://github.com/mkorthaus-private) - [@mkorthaus-private](https://github.com/mkorthaus-private)
**Monthly** **Monthly**
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_ - _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
### Features ### Features
@@ -38,9 +80,11 @@ Thank you for your donations:
Thank you for your donations: Thank you for your donations:
**One-time** **One-time**
- [@mkorthaus-private](https://github.com/mkorthaus-private) - [@mkorthaus-private](https://github.com/mkorthaus-private)
**Monthly** **Monthly**
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_ - _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
### Features ### Features
@@ -436,7 +480,7 @@ Thank you for your donations:
### Fixes ### Fixes
- **Context menu**: Fix "Move to folder" submenu closing when scrolling the folder list or moving the mouse to the submenu (#19) - **Context menu**: Fix "Move to folder" submenu closing when scrolling the folder list or moving the mouse to the submenu (#19)
- **Move to folder**: Fix emails not actually moving on the server JMAP response errors were silently ignored and shared account IDs were not resolved correctly - **Move to folder**: Fix emails not actually moving on the server - JMAP response errors were silently ignored and shared account IDs were not resolved correctly
- **Dependencies**: Update tailwindcss, lucide-react, @tanstack/react-virtual, @typescript-eslint/\*, globals, @types/node - **Dependencies**: Update tailwindcss, lucide-react, @tanstack/react-virtual, @typescript-eslint/\*, globals, @types/node
## 1.1.1 (2026-02-28) ## 1.1.1 (2026-02-28)
@@ -446,7 +490,7 @@ Thank you for your donations:
- **Email viewer**: Show/hide details toggle now stays in place when expanded instead of jumping to the bottom of the details section (#18) - **Email viewer**: Show/hide details toggle now stays in place when expanded instead of jumping to the bottom of the details section (#18)
- **Email viewer**: Details toggle text is now properly translated (was hardcoded in English) - **Email viewer**: Details toggle text is now properly translated (was hardcoded in English)
- **Instrumentation**: Resolve Edge Runtime warnings by splitting Node.js-only code into a separate module - **Instrumentation**: Resolve Edge Runtime warnings by splitting Node.js-only code into a separate module
- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5 - **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) - upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5
## 1.1.0 (2026-02-28) ## 1.1.0 (2026-02-28)
+163 -61
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.13-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-1.4.14-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>
@@ -26,16 +26,16 @@ Built with Next.js and the JMAP protocol.
<tr> <tr>
<td width="50%"> <td width="50%">
<img src="screenshots/inbox.png" width="100%" alt="Inbox three-pane layout with sidebar, email list, and viewer (dark mode)"> <img src="screenshots/inbox.png" width="100%" alt="Inbox - three-pane layout with sidebar, email list, and viewer (dark mode)">
**Mail** Three-pane layout with sidebar, email list, and viewer **Mail** - Three-pane layout with sidebar, email list, and viewer
</td> </td>
<td width="50%"> <td width="50%">
<img src="screenshots/calendar.png" width="100%" alt="Calendar"> <img src="screenshots/calendar.png" width="100%" alt="Calendar">
**Calendar** Month, week, day, and agenda views with event management **Calendar** - Month, week, day, and agenda views with event management
</td> </td>
</tr> </tr>
@@ -44,14 +44,14 @@ Built with Next.js and the JMAP protocol.
<img src="screenshots/contacts.png" width="100%" alt="Contacts"> <img src="screenshots/contacts.png" width="100%" alt="Contacts">
**Contacts** Contact management with groups and vCard support **Contacts** - Contact management with groups and vCard support
</td> </td>
<td width="50%"> <td width="50%">
<img src="screenshots/files.png" width="100%" alt="File browser"> <img src="screenshots/files.png" width="100%" alt="File browser">
**Files** Cloud file browser with upload, preview, and folder navigation **Files** - Cloud file browser with upload, preview, and folder navigation
</td> </td>
</tr> </tr>
@@ -63,16 +63,16 @@ Built with Next.js and the JMAP protocol.
<tr> <tr>
<td width="50%"> <td width="50%">
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox light mode"> <img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox - light mode">
**Light mode** Full theme support with intelligent color transformation **Light mode** - Full theme support with intelligent color transformation
</td> </td>
<td width="50%"> <td width="50%">
<img src="screenshots/settings.png" width="100%" alt="Settings"> <img src="screenshots/settings.png" width="100%" alt="Settings">
**Settings** Appearance, identities, filters, templates, and more **Settings** - Appearance, identities, filters, templates, and more
</td> </td>
</tr> </tr>
@@ -81,7 +81,7 @@ Built with Next.js and the JMAP protocol.
<img src="screenshots/login.png" width="100%" alt="Login page"> <img src="screenshots/login.png" width="100%" alt="Login page">
**Login** Configurable branding with OAuth2/OIDC and 2FA support **Login** - Configurable branding with OAuth2/OIDC and 2FA support
</td> </td>
<td width="50%"> <td width="50%">
@@ -94,34 +94,46 @@ Built with Next.js and the JMAP protocol.
### Mail ### Mail
- **Read, compose, reply, reply-all, forward** with rich HTML rendering - **Read, compose, reply, reply-all, forward** with rich HTML rendering and a Tiptap-based rich text editor (inline image upload, drag-and-drop embedding)
- **Threading** Gmail-style inline expansion with thread navigation - **Threading** - Gmail-style inline expansion with thread navigation; optional conversation threading toggle
- **Draft auto-save** with discard confirmation - **Unified mailbox** - view emails across all accounts in a single list
- **Attachments** — upload, download, and inline preview - **Draft auto-save** with discard confirmation; full draft editing with identity preservation
- **Search** — full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions - **Attachments** - upload, download, and inline preview; attachment-keyword warning if a file is forgotten
- **Batch operations** — multi-select with checkboxes, archive, delete, move, tag - **Search** - full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
- **Archive modes** — archive directly or organize archived mail by year or month - **Batch operations** - multi-select with checkboxes, archive, delete, move, tag
- **Archive modes** - archive directly or organize archived mail by year or month
- **Print** emails directly from the viewer - **Print** emails directly from the viewer
- **Answered/forwarded status icons** in email list and thread views - **Answered/forwarded status icons** in email list and thread views
- **Color tags/labels** and star/unstar - **Multi-tag per email** with color labels, reorderable tags, and drag-and-drop tag assignment
- **Star/unstar** with configurable mark-as-read delay
- **Virtual scrolling** for large mailboxes - **Virtual scrolling** for large mailboxes
- **Quick reply** from the viewer - **Quick reply** from the viewer
- **Sender avatars** — favicon-based with negative caching for performance - **Hover actions** - configurable quick-action buttons on email rows with customizable placement
- **Sender avatars** - favicon-based with negative caching for performance
- **Recipient popover** for quick contact interaction - **Recipient popover** for quick contact interaction
- **TNEF support** — extract Outlook `winmail.dat` message bodies and attachments automatically - **Plain text composer mode** and auto-select reply identity
- **Folder management** — create, rename, delete folders with icon picker and subfolder support - **Reply-to addresses** support in the composer
- **Tag counts** — unread and total counts displayed in sidebar - **TNEF support** - extract Outlook `winmail.dat` message bodies and attachments automatically
- **message/rfc822 unwrapping** for embedded messages
- **Folder management** - create, rename, delete folders with icon picker and subfolder support
- **Tag counts** - unread and total counts displayed in sidebar
- **Browser history sync** - back/forward navigation mirrors mail view state
### Calendar ### Calendar
- **Month, week, day, and agenda views** with mini-calendar sidebar - **Month, week, day, and agenda views** with mini-calendar sidebar and a dedicated task list view
- **Event hover preview** popover with details - **Event hover preview** popover with configurable details
- **Drag-and-drop rescheduling**, click-drag creation, edge-resize (15-min snap) - **Drag-and-drop rescheduling**, click-drag or double-click creation, edge-resize (15-min snap)
- **Recurring events** with edit/delete scope (this / this and following / all) - **Recurring events** with edit/delete scope (this / this and following / all) and client-side recurrence expansion
- **Participant scheduling** iTIP invitations, organizer/attendee UI, RSVP - **Participant scheduling** - iMIP invitations sent on create and update (RFC 5545/6047 compliant), organizer/attendee UI, RSVP with trust assessment
- **Inline calendar invitations** in email viewer auto-detect `.ics`, RSVP, import - **Inline calendar invitations** in email viewer - auto-detect `.ics`, RSVP, import
- **iCalendar import** with preview and bulk create - **iCalendar import** with preview, bulk create, and UID deduplication
- **Task management** — create, edit, and track tasks with due dates, priority, and completion status - **iCal / webcal subscriptions** with editing and batch import
- **Birthday calendar** - auto-generated from contacts
- **Virtual locations** - video conference URLs as first-class event fields
- **Task management** - create, edit, and track tasks with due dates, priority, and completion status; external CalDAV client detection (Thunderbird)
- **Shared calendars** with visual grouping in the sidebar
- **CalDAV discovery** with automatic calendar home resolution for multi-account setups
- **Week numbers** in mini-calendar sidebar - **Week numbers** in mini-calendar sidebar
- **Notifications** with configurable sound, alert persistence, and sound picker with preview playback - **Notifications** with configurable sound, alert persistence, and sound picker with preview playback
- **Real-time sync** via JMAP push - **Real-time sync** via JMAP push
@@ -129,77 +141,103 @@ Built with Next.js and the JMAP protocol.
### Contacts ### Contacts
- **Contact management** with JMAP sync (RFC 9553/9610) and local fallback - **Contact management** with JMAP sync (RFC 9553/9610) and local fallback
- **Multiple address books** - create, rename, drag-and-drop between books, with editor picker in contact form
- **Collapsible sidebar** with address book grouping and bulk operations
- **Contact groups** with group expansion and member management - **Contact groups** with group expansion and member management
- **vCard import/export** (RFC 6350) with duplicate detection - **vCard import/export** (RFC 6350) with duplicate detection
- **Trusted senders** stored in a dedicated JMAP address book
- **Autocomplete** in composer (To/Cc/Bcc) - **Autocomplete** in composer (To/Cc/Bcc)
- **Bulk operations** multi-select, delete, group add, export - **Bulk operations** - multi-select, delete, group add, export
### Filters & Automation ### Filters & Automation
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661) - **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
- **Visual rule builder** conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…) - **Visual rule builder** - conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…) with an expanded visual view
- **External rule preservation** - rules authored in other clients are displayed and preserved
- **Raw Sieve editor** with syntax validation - **Raw Sieve editor** with syntax validation
- **Vacation responder** with date range scheduling and sidebar indicator - **Vacation responder** with date range scheduling and sidebar indicator
- **Email templates** reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.) - **Email templates** - reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
### Files ### Files
- **File browser** with JMAP FileNode cloud storage (Stalwart native) - **File browser** with JMAP FileNode cloud storage (Stalwart native)
- **Upload and download** files with progress tracking and folder upload support - **Upload and download** files with progress tracking, folder upload, and streamed WebDAV PUT (no in-memory buffering)
- **Dynamic upload limits** - respects the server-configured maximum upload size
- **Folder navigation** with breadcrumb path and tree sidebar - **Folder navigation** with breadcrumb path and tree sidebar
- **Grid and list views** with sorting by name, size, or date - **Grid and list views** with sorting by name, size, or date
- **Clipboard operations** cut, copy, paste, duplicate files - **Clipboard operations** - cut, copy, paste, duplicate files
- **File preview** for images, text, audio, video, and more - **File preview** for images, text, audio, video, and more
- **Favorites and recent files** for quick access - **Favorites and recent files** for quick access
- **Bulk operations** multi-select, delete, move, download - **Bulk operations** - multi-select, delete, move, download
### Security & Privacy ### Security & Privacy
- **External content blocked** by default trusted senders list for auto-load - **External content blocked** by default - trusted senders list for auto-load
- **HTML sanitization** via DOMPurify with XSS prevention - **HTML sanitization** via DOMPurify with XSS prevention
- **S/MIME** manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures - **S/MIME** - manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures; self-signed certificate detection; legacy 3DES / PBE support; per-account key isolation
- **SPF/DKIM/DMARC** status indicators - **SPF/DKIM/DMARC** status indicators
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode and non-interactive SSO for embedded/iframe deployments - **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode, OAuth app passwords, configurable scopes, and non-interactive SSO for embedded/iframe deployments
- **TOTP two-factor authentication** - **TOTP two-factor authentication**
- **Account security panel** manage passwords and 2FA via Stalwart admin API - **Account security panel** - manage passwords and 2FA via Stalwart admin API
- **"Remember me"** AES-256-GCM encrypted httpOnly cookie (opt-in) - **"Remember me"** - AES-256-GCM encrypted httpOnly cookie (opt-in)
- **Security headers** CSP with per-request nonce, X-Frame-Options, Referrer-Policy - **Security headers** - enforced CSP with per-request nonce, X-Frame-Options, Referrer-Policy; SSRF redirect validation; PDF iframe sandbox; IP spoofing prevention
- **Plugin hardening** - dangerous-pattern detection, admin approval required, secure HTTP proxy API (no auth-header exposure)
- **Newsletter unsubscribe** (RFC 2369) - **Newsletter unsubscribe** (RFC 2369)
### Interface ### Interface
- **Three-pane layout** sidebar, email list, viewer with resizable columns - **Three-pane layout** - sidebar, email list, viewer with resizable columns
- **Dark and light themes** with intelligent email color transformation - **Dark and light themes** with intelligent email color transformation
- **Always-light email rendering** option for problematic HTML messages in dark theme - **Always-light email rendering** option for problematic HTML messages in dark theme
- **Responsive** desktop sidebar + mobile bottom tab bar with tablet support - **Responsive** - desktop sidebar + mobile bottom tab bar with tablet support
- **Keyboard shortcuts** full navigation without a mouse - **Keyboard shortcuts** - full navigation without a mouse
- **Drag-and-drop** email organization between mailboxes and tag assignment - **Drag-and-drop** email organization between mailboxes and tag assignment
- **Interactive guided tour** onboarding walkthrough for new users - **Interactive guided tour** - onboarding walkthrough for new users
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback - **Right-click context menus**, toast notifications with undo, form validation with shake feedback
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding - **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
- **Sidebar apps** pin custom tools to the navigation rail and open them inline or in a new tab - **Sidebar apps** - pin custom tools to the navigation rail with drag-and-drop reordering, mobile visibility toggles, and inline or new-tab launch modes
- **Settings sync** preferences synchronized with the server (encrypted) - **Settings sync** - preferences synchronized with the server (encrypted)
- **Storage quota** display - **Storage quota** display
- **Shared folders** — multi-account access - **Version badge** in settings
- **Accessibility** — WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions - **Focused mode** with proper viewport bounds
- **Accessibility** - WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
### Internationalization ### Internationalization
8 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português 14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська
Automatic browser detection with persistent preference. Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
### Identity Management ### Identity Management
- **Multiple sender identities** with per-identity signatures - **Multiple sender identities** with per-identity signatures
- **Identity refresh** — keep the identity manager aligned with server-side changes after edits - **Automatic identity synchronization** and refresh to keep the identity manager aligned with server-side changes
- **Sub-addressing** `user+tag@domain.com` with contextual tag suggestions - **Sub-addressing** - `user+tag@domain.com` with contextual tag suggestions
- **Identity badges** in viewer and email list - **Identity badges** in viewer and email list
### Multi-Account
- **Up to 5 simultaneous accounts** with instant switching and per-account session persistence
- **Account switcher** with connection status, default account selection, and per-account logout
- **Per-account settings** - encrypted settings storage with server-side sync
- **Shared folders** across accounts
- **Custom JMAP server endpoints** - optionally let users connect to any JMAP server from the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
### Admin & Extensibility
- **Stalwart admin dashboard** - sidebar access with reorganized dashboard and dedicated policy sections
- **Plugin system** - schema-driven admin config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event action slots, forced enable/disable and managed policy enforcement
- **Themes** - upload, enforce, and manage admin-controlled themes with ZIP bundles
- **Extension marketplace** - browse and install plugins/themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
- **Bundled plugins** - Jitsi Meet calendar integration
### Operations ### Operations
- **Automatic update check** — server logs when a newer release is available - **Progressive Web App (PWA)** - installable with service worker, install prompt, and dynamic manifest (app name, description, icons, theme and background colors)
- **Demo mode** — try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes — no mail server required - **Automatic update check** - server logs when a newer release is available
- **Logging categories** with `text` or `json` formats for log aggregation
- **Docker images** - release (`main`) and development (`dev`) channels on GHCR
- **Demo mode** - try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes - no mail server required
--- ---
@@ -217,7 +255,7 @@ Or with Docker Compose:
```bash ```bash
cp .env.example .env.local cp .env.example .env.local
# Edit .env.local set JMAP_SERVER_URL # Edit .env.local - set JMAP_SERVER_URL
docker compose up -d docker compose up -d
``` ```
@@ -228,7 +266,7 @@ git clone https://github.com/bulwarkmail/webmail.git
cd webmail cd webmail
npm install npm install
cp .env.example .env.local cp .env.example .env.local
# Edit .env.local set JMAP_SERVER_URL # Edit .env.local - set JMAP_SERVER_URL
npm run build && npm start npm run build && npm start
``` ```
@@ -252,7 +290,7 @@ JMAP_SERVER_URL=https://mail.example.com
APP_NAME=My Webmail APP_NAME=My Webmail
``` ```
All variables are **runtime** Docker deployments can be configured without rebuilding. All variables are **runtime** - Docker deployments can be configured without rebuilding.
<details> <details>
<summary>Server Listen Address</summary> <summary>Server Listen Address</summary>
@@ -280,14 +318,78 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
</details> </details>
<details> <details>
<summary>Remember Me</summary> <summary>Remember Me & Settings Sync</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 SESSION_SECRET_FILE=/session-secret # Path to a file containing the session secret
SETTINGS_SYNC_ENABLED=true # Persist encrypted user settings on the server
SETTINGS_DATA_DIR=./data/settings # Storage location (mount a volume in Docker)
``` ```
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).
Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
</details>
<details>
<summary>Custom JMAP Endpoint</summary>
```env
ALLOW_CUSTOM_JMAP_ENDPOINT=true # Shows a "JMAP Server" field on login
```
Lets users connect to any JMAP-compatible server. External servers must CORS-allow the webmail origin.
</details>
<details>
<summary>Branding & PWA</summary>
```env
APP_NAME=My Webmail
APP_SHORT_NAME=Webmail # Home-screen label on mobile
APP_DESCRIPTION=Your personal mail # Shown during PWA install
FAVICON_URL=/branding/favicon.svg
PWA_ICON_URL=/branding/icon.svg # Falls back to FAVICON_URL
PWA_THEME_COLOR=#3b82f6 # Browser chrome color
PWA_BACKGROUND_COLOR=#ffffff # PWA splash background
APP_LOGO_LIGHT_URL=/branding/logo-light.svg
APP_LOGO_DARK_URL=/branding/logo-dark.svg
LOGIN_LOGO_LIGHT_URL=/branding/login-light.svg
LOGIN_LOGO_DARK_URL=/branding/login-dark.svg
LOGIN_COMPANY_NAME=My Company
LOGIN_WEBSITE_URL=https://example.com
LOGIN_IMPRINT_URL=https://example.com/imprint
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
```
</details>
<details>
<summary>Extension Directory</summary>
```env
EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
```
Enables the admin marketplace for browsing and installing plugins and themes.
</details>
<details>
<summary>Stalwart Integration & Logging</summary>
```env
STALWART_FEATURES=true # Password change, sieve filters, etc.
LOG_FORMAT=text # "text" or "json"
LOG_LEVEL=info # "error", "warn", "info", "debug"
```
</details> </details>
@@ -321,7 +423,7 @@ Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day exp
## Why Stalwart? ## Why Stalwart?
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies. [Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** - not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies.
## Contributing ## Contributing
+1 -1
View File
@@ -1 +1 @@
1.4.12 1.4.14
+2 -2
View File
@@ -35,7 +35,7 @@ function OAuthCallbackInner() {
const savedState = sessionStorage.getItem("oauth_state"); const savedState = sessionStorage.getItem("oauth_state");
if (savedState) { if (savedState) {
// Classic flow sessionStorage has the PKCE state (same-tab OAuth) // Classic flow - sessionStorage has the PKCE state (same-tab OAuth)
if (!state || state !== savedState) { if (!state || state !== savedState) {
setError("invalid_state"); setError("invalid_state");
return; return;
@@ -76,7 +76,7 @@ function OAuthCallbackInner() {
setError("token_exchange_failed"); setError("token_exchange_failed");
}); });
} else if (state) { } else if (state) {
// Server-side SSO flow state was stored in encrypted httpOnly cookie // Server-side SSO flow - state was stored in encrypted httpOnly cookie
const ssoPrefix = getPathPrefix(params.locale as string); const ssoPrefix = getPathPrefix(params.locale as string);
loginWithServerSso(code, state) loginWithServerSso(code, state)
.then((success) => { .then((success) => {
+134 -2
View File
@@ -30,6 +30,10 @@ import { MiniCalendar } from "@/components/calendar/mini-calendar";
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel"; import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal"; import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
import { EventDetailPopover } from "@/components/calendar/event-detail-popover"; import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
import { EventContextMenu } from "@/components/calendar/event-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { downloadEventICS } from "@/lib/calendar-ics-export";
import { ICalImportModal } from "@/components/calendar/ical-import-modal"; import { ICalImportModal } from "@/components/calendar/ical-import-modal";
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal"; import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog"; import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
@@ -112,6 +116,16 @@ export default function CalendarPage() {
// Swipe navigation ref (handlers defined after navigatePrev/navigateNext) // Swipe navigation ref (handlers defined after navigatePrev/navigateNext)
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null); const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
// Keep detailEvent in sync with store events (e.g. after update + refetch)
useEffect(() => {
if (detailEvent) {
const updated = events.find(e => e.id === detailEvent.id);
if (updated && updated !== detailEvent) {
setDetailEvent(updated);
}
}
}, [events, detailEvent]);
// Check auth on mount // Check auth on mount
useEffect(() => { useEffect(() => {
checkAuth().finally(() => { checkAuth().finally(() => {
@@ -151,9 +165,14 @@ export default function CalendarPage() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [client, refreshAllSubscriptions]); }, [client, refreshAllSubscriptions]);
// Auto-add birthday calendar to selected IDs when enabled // Auto-add birthday calendar to selected IDs only when the setting flips
// off→on. Firing on every mount would undo a user's manual hide via the
// sidebar each time they navigate back to the calendar (see #204).
const prevShowBirthdayRef = useRef(showBirthdayCalendar);
useEffect(() => { useEffect(() => {
if (showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) { const wasShown = prevShowBirthdayRef.current;
prevShowBirthdayRef.current = showBirthdayCalendar;
if (!wasShown && showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
toggleCalendarVisibility(BIRTHDAY_CALENDAR_ID); toggleCalendarVisibility(BIRTHDAY_CALENDAR_ID);
} }
}, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps }, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -347,6 +366,18 @@ export default function CalendarPage() {
openEditModal(event); openEditModal(event);
}, [closeDetail, openEditModal]); }, [closeDetail, openEditModal]);
const {
contextMenu: eventContextMenu,
openContextMenu: openEventContextMenu,
closeContextMenu: closeEventContextMenu,
menuRef: eventContextMenuRef,
} = useContextMenu<CalendarEvent>();
const handleContextMenuEvent = useCallback((e: React.MouseEvent, event: CalendarEvent) => {
closeDetail();
openEventContextMenu(e, event);
}, [closeDetail, openEventContextMenu]);
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 (calendarHoverPreview === 'off') return;
@@ -407,6 +438,20 @@ export default function CalendarPage() {
} }
}, [client, fetchEvents]); }, [client, fetchEvents]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh calendar data via JMAP instead of reloading the page.
useRefreshGesture({
enabled: isAuthenticated && !!client,
onRefresh: async () => {
if (!client) return;
await Promise.all([
fetchCalendars(client),
refetchCurrentRange(),
refreshAllSubscriptions(client),
]);
},
});
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => { const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
if (!event.start) { if (!event.start) {
return; return;
@@ -722,6 +767,73 @@ export default function CalendarPage() {
} }
}, [detailEvent, client, updateEvent, t]); }, [detailEvent, client, updateEvent, t]);
const handleDuplicateContextMenu = useCallback(async (event: CalendarEvent) => {
if (!client) { toast.error(t("notifications.event_error")); return; }
const start = parseISO(event.start);
const newStart = addDays(start, 1);
const data = sanitizeOutgoingCalendarEventData<Partial<CalendarEvent>>({
title: event.title,
description: event.description,
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
duration: event.duration,
timeZone: event.timeZone,
showWithoutTime: event.showWithoutTime,
calendarIds: { ...event.calendarIds },
status: "confirmed",
freeBusyStatus: event.freeBusyStatus,
privacy: event.privacy,
});
if (event.locations) data.locations = structuredClone(event.locations);
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
if (event.alerts) data.alerts = structuredClone(event.alerts);
if (event.participants) data.participants = structuredClone(event.participants);
try {
const created = await createEvent(client, data);
if (created) {
toast.success(t("notifications.event_duplicated"));
openEditModal(created);
}
} catch {
toast.error(t("notifications.event_error"));
}
}, [client, createEvent, openEditModal, t]);
const handleExportICS = useCallback((event: CalendarEvent) => {
try {
downloadEventICS(event);
toast.success(t("notifications.event_exported"));
} catch {
toast.error(t("notifications.event_error"));
}
}, [t]);
const handleCopyTitle = useCallback(async (event: CalendarEvent) => {
try {
await navigator.clipboard.writeText(event.title || "");
toast.success(t("notifications.title_copied"));
} catch {
toast.error(t("notifications.event_error"));
}
}, [t]);
const handleCopyMeetingLink = useCallback(async (event: CalendarEvent) => {
const uri = event.virtualLocations
? Object.values(event.virtualLocations).find((v) => v.uri)?.uri
: undefined;
if (!uri) return;
try {
await navigator.clipboard.writeText(uri);
toast.success(t("notifications.link_copied"));
} catch {
toast.error(t("notifications.event_error"));
}
}, [t]);
const handleDeleteContextMenu = useCallback((event: CalendarEvent) => {
const hasParticipants = event.participants && Object.keys(event.participants).length > 0;
handleDeleteEvent(event.id, hasParticipants || undefined);
}, [handleDeleteEvent]);
const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus']) => { const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus']) => {
if (!detailEvent || !client) return; if (!detailEvent || !client) return;
const participantId = getUserParticipantId(detailEvent, currentUserEmails); const participantId = getUserParticipantId(detailEvent, currentUserEmails);
@@ -831,6 +943,7 @@ export default function CalendarPage() {
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave} onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onCreateAtTime={openCreateModal} onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek} firstDayOfWeek={firstDayOfWeek}
isMobile={isMobile} isMobile={isMobile}
@@ -847,6 +960,7 @@ export default function CalendarPage() {
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave} onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onCreateAtTime={openCreateModal} onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek} firstDayOfWeek={firstDayOfWeek}
timeFormat={timeFormat} timeFormat={timeFormat}
@@ -865,6 +979,7 @@ export default function CalendarPage() {
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave} onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onCreateAtTime={openCreateModal} onCreateAtTime={openCreateModal}
timeFormat={timeFormat} timeFormat={timeFormat}
isMobile={isMobile} isMobile={isMobile}
@@ -882,6 +997,7 @@ export default function CalendarPage() {
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave} onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
timeFormat={timeFormat} timeFormat={timeFormat}
/> />
); );
@@ -1096,6 +1212,22 @@ export default function CalendarPage() {
</div> </div>
)} )}
{eventContextMenu.data && (
<EventContextMenu
event={eventContextMenu.data}
position={eventContextMenu.position}
isOpen={eventContextMenu.isOpen}
onClose={closeEventContextMenu}
menuRef={eventContextMenuRef}
onEdit={() => openEditModal(eventContextMenu.data!)}
onDuplicate={() => handleDuplicateContextMenu(eventContextMenu.data!)}
onExportICS={() => handleExportICS(eventContextMenu.data!)}
onCopyTitle={() => handleCopyTitle(eventContextMenu.data!)}
onCopyMeetingLink={() => handleCopyMeetingLink(eventContextMenu.data!)}
onDelete={() => handleDeleteContextMenu(eventContextMenu.data!)}
/>
)}
{detailEvent && detailAnchorRect && ( {detailEvent && detailAnchorRect && (
<EventDetailPopover <EventDetailPopover
event={detailEvent} event={detailEvent}
+42 -6
View File
@@ -26,6 +26,7 @@ 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 { useIsMobile } from "@/hooks/use-media-query"; import { useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import type { ContactCard, AddressBook } from "@/lib/jmap/types"; import type { ContactCard, AddressBook } from "@/lib/jmap/types";
type View = type View =
@@ -123,6 +124,16 @@ export default function ContactsPage() {
} }
}, [client, supportsSync, fetchContacts]); }, [client, supportsSync, fetchContacts]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh contacts via JMAP instead of reloading the page.
useRefreshGesture({
enabled: isAuthenticated && !!client && supportsSync,
onRefresh: async () => {
if (!client) return;
await fetchContacts(client);
},
});
const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]); const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]);
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]); const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null; const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
@@ -248,9 +259,7 @@ export default function ContactsPage() {
setView("edit"); setView("edit");
}; };
const handleDelete = async () => { const deleteContactById = useCallback(async (contactId: string) => {
if (!selectedContact) return;
const confirmed = await confirmDialog({ const confirmed = await confirmDialog({
title: t("delete_confirm_title"), title: t("delete_confirm_title"),
message: t("delete_confirm"), message: t("delete_confirm"),
@@ -261,18 +270,42 @@ export default function ContactsPage() {
try { try {
if (supportsSync && client) { if (supportsSync && client) {
await deleteContact(client, selectedContact.id); await deleteContact(client, contactId);
} else { } else {
deleteLocalContact(selectedContact.id); deleteLocalContact(contactId);
} }
toast.success(t("toast.deleted")); toast.success(t("toast.deleted"));
setView("list"); if (selectedContactId === contactId) setView("list");
} catch (error) { } catch (error) {
console.error('Failed to delete contact:', error); console.error('Failed to delete contact:', error);
toast.error(t("toast.error_delete")); toast.error(t("toast.error_delete"));
} }
}, [confirmDialog, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]);
const handleDelete = async () => {
if (!selectedContact) return;
await deleteContactById(selectedContact.id);
}; };
const handleEditContact = useCallback((id: string) => {
setSelectedContact(id);
setView("edit");
}, [setSelectedContact]);
const handleDeleteContact = useCallback((contact: ContactCard) => {
void deleteContactById(contact.id);
}, [deleteContactById]);
const handleAddContactToGroup = useCallback((id: string) => {
clearSelection();
toggleContactSelection(id);
if (groups.length === 0) {
setView("group-create");
return;
}
setView("bulk-add-to-group");
}, [clearSelection, toggleContactSelection, groups.length]);
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => { const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
if (supportsSync && client) { if (supportsSync && client) {
await createContact(client, data); await createContact(client, data);
@@ -679,6 +712,9 @@ export default function ContactsPage() {
onBulkDelete={handleBulkDelete} onBulkDelete={handleBulkDelete}
onBulkAddToGroup={handleBulkAddToGroup} onBulkAddToGroup={handleBulkAddToGroup}
onBulkExport={handleBulkExport} onBulkExport={handleBulkExport}
onEditContact={handleEditContact}
onDeleteContact={handleDeleteContact}
onAddContactToGroup={handleAddContactToGroup}
/> />
</div> </div>
+10
View File
@@ -17,6 +17,7 @@ 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 { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { usePolicyStore } from "@/stores/policy-store"; 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";
@@ -127,6 +128,15 @@ export default function FilesPage() {
} }
}, [isAuthenticated, client, initClient]); }, [isAuthenticated, client, initClient]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh files via JMAP instead of reloading the page.
useRefreshGesture({
enabled: isAuthenticated && !!client && supportsFiles === true,
onRefresh: async () => {
await refresh();
},
});
// Check support and load root after client is initialized // Check support and load root after client is initialized
const storeClient = useFileStore(s => s.client); const storeClient = useFileStore(s => s.client);
useEffect(() => { useEffect(() => {
+4 -4
View File
@@ -10,7 +10,7 @@ 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 { apiFetch, 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, Copy } 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";
@@ -234,7 +234,7 @@ export default function LoginPage() {
try { try {
const prefix = getPathPrefix(params.locale as string); const prefix = getPathPrefix(params.locale as string);
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
const res = await fetch('/api/auth/sso/start', { const res = await apiFetch('/api/auth/sso/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
@@ -255,7 +255,7 @@ export default function LoginPage() {
try { try {
window.top!.location.href = authorize_url; window.top!.location.href = authorize_url;
} catch { } catch {
// Cross-origin restriction fall back to current frame // Cross-origin restriction - fall back to current frame
window.location.href = authorize_url; window.location.href = authorize_url;
} }
} else { } else {
@@ -759,7 +759,7 @@ export default function LoginPage() {
)} )}
</Button> </Button>
<p className="text-center text-xs text-muted-foreground"> <p className="text-center text-xs text-muted-foreground">
Dev mode logging in as dev@localhost Dev mode - logging in as dev@localhost
</p> </p>
</div> </div>
) : oauthOnly ? ( ) : oauthOnly ? (
+157 -31
View File
@@ -9,7 +9,9 @@ import { EmailComposer } from "@/components/email/email-composer";
import type { ComposerDraftData } from "@/components/email/email-composer"; import type { ComposerDraftData } from "@/components/email/email-composer";
import { ThreadConversationView } from "@/components/email/thread-conversation-view"; import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header"; import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email } from "@/lib/jmap/types"; import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
import { useAccountStore } from "@/stores/account-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; 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";
@@ -19,6 +21,7 @@ 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 { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation"; import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
@@ -155,8 +158,54 @@ export default function Home() {
hasMoreEmails, hasMoreEmails,
fetchTagCounts, fetchTagCounts,
fetchEmailContent, fetchEmailContent,
isUnifiedView,
fetchUnifiedEmails: fetchUnifiedEmailsAction,
refreshUnifiedCounts,
exitUnifiedView,
} = useEmailStore(); } = useEmailStore();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
const accounts = useAccountStore((s) => s.accounts);
const connectedAccountsSignature = useMemo(
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
[accounts],
);
const buildUnifiedAccounts = useCallback((): UnifiedAccountClient[] => {
const connected = useAccountStore.getState().accounts.filter((a) => a.isConnected);
const clients = useAuthStore.getState().getAllConnectedClients();
const result: UnifiedAccountClient[] = [];
for (const account of connected) {
const accountClient = clients.get(account.id);
if (!accountClient) continue;
result.push({
accountId: account.id,
accountLabel: account.label || account.email,
client: accountClient,
mailboxes: [],
});
}
return result;
}, []);
const populateUnifiedAccountMailboxes = useCallback(
async (list: UnifiedAccountClient[]): Promise<UnifiedAccountClient[]> => {
const populated = await Promise.all(
list.map(async (entry) => {
try {
const mailboxes = await entry.client.getMailboxes();
return { ...entry, mailboxes };
} catch (err) {
debug.error('Failed to load mailboxes for unified account', entry.accountId, err);
return entry;
}
}),
);
return populated;
},
[],
);
// Browser back / forward integration. The restore handler reads the // Browser back / forward integration. The restore handler reads the
// latest values from a ref so we don't have to recreate the callback on // 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). // every render (and so the popstate listener is never stale).
@@ -208,7 +257,7 @@ export default function Home() {
// Restore conversation thread (mobile only). We can clear it directly, // Restore conversation thread (mobile only). We can clear it directly,
// but reopening requires the thread group; if the user pressed forward // but reopening requires the thread group; if the user pressed forward
// to return to a thread, we silently skip back navigation always works. // to return to a thread, we silently skip - back navigation always works.
if ((state.threadId ?? null) !== ctx.conversationThreadId) { if ((state.threadId ?? null) !== ctx.conversationThreadId) {
if (state.threadId === null) { if (state.threadId === null) {
setConversationThread(null); setConversationThread(null);
@@ -360,6 +409,20 @@ export default function Home() {
handlers: keyboardHandlers, handlers: keyboardHandlers,
}); });
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh mail data via JMAP instead of reloading the page.
useRefreshGesture({
enabled: isAuthenticated && !!client,
onRefresh: async () => {
if (!client) return;
const state = useEmailStore.getState();
await Promise.all([
state.fetchMailboxes(client),
state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client),
]);
},
});
// Update page title based on context // Update page title based on context
useEffect(() => { useEffect(() => {
let title = appName; let title = appName;
@@ -485,13 +548,30 @@ export default function Home() {
}; };
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]); }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]);
// Keep unified mailbox counts in sync when the feature is enabled and more
// than one account is connected. Runs whenever the set of connected accounts
// or the primary account's mailboxes change (a proxy for "something worth
// recounting happened").
useEffect(() => {
if (!enableUnifiedMailbox || !isAuthenticated || !client) return;
const built = buildUnifiedAccounts();
if (built.length < 2) return;
populateUnifiedAccountMailboxes(built).then((populated) => {
refreshUnifiedCounts(populated);
});
}, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive) // Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
useEffect(() => { useEffect(() => {
if (!selectedEmail || !client) return; if (!selectedEmail || !client) return;
// If the email lacks bodyValues, it was auto-selected from the list and needs full content // If the email lacks bodyValues, it was auto-selected from the list and needs full content
if (!selectedEmail.bodyValues) { if (!selectedEmail.bodyValues) {
const perAccountClient = isUnifiedView && selectedEmail.accountId
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
: undefined;
const fetchClient = perAccountClient ?? client;
setLoadingEmail(true); setLoadingEmail(true);
fetchEmailContent(client, selectedEmail.id).finally(() => { fetchEmailContent(fetchClient, selectedEmail.id).finally(() => {
setLoadingEmail(false); setLoadingEmail(false);
}); });
} }
@@ -579,7 +659,7 @@ export default function Home() {
fromEmail?: string; fromEmail?: string;
fromName?: string; fromName?: string;
identityId?: string; identityId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
}) => { }) => {
if (!client) return; if (!client) return;
@@ -693,8 +773,8 @@ export default function Home() {
if (isMobile) setActiveView('viewer'); if (isMobile) setActiveView('viewer');
}; };
const handleDelete = async () => { const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
if (!client || !selectedEmail) return; if (!client || !emailToDelete) return;
// Check if we're currently in the trash or junk folder // Check if we're currently in the trash or junk folder
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
@@ -713,7 +793,7 @@ export default function Home() {
if (!confirmed) return; if (!confirmed) return;
try { try {
await deleteEmail(client, selectedEmail.id, true); await deleteEmail(client, emailToDelete.id, true);
} catch (error) { } catch (error) {
console.error("Failed to permanently delete email:", error); console.error("Failed to permanently delete email:", error);
} }
@@ -722,7 +802,7 @@ export default function Home() {
const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared); const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared);
if (trashMailbox) { if (trashMailbox) {
try { try {
await moveToMailbox(client, selectedEmail.id, trashMailbox.id); await moveToMailbox(client, emailToDelete.id, trashMailbox.id);
} catch (error) { } catch (error) {
console.error("Failed to move email to trash:", error); console.error("Failed to move email to trash:", error);
} }
@@ -761,7 +841,7 @@ export default function Home() {
if (archiveMode === 'year') { if (archiveMode === 'year') {
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id); await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
} else { } else {
// archiveMode === 'month' find or create month subfolder under year // archiveMode === 'month' - find or create month subfolder under year
const yearId = yearMailbox.originalId || yearMailbox.id; const yearId = yearMailbox.originalId || yearMailbox.id;
let monthMailbox = mailboxes.find( let monthMailbox = mailboxes.find(
m => m.name === month && m.parentId === yearId m => m.name === month && m.parentId === yearId
@@ -795,10 +875,10 @@ export default function Home() {
} }
}; };
const handleMarkAsSpam = async () => { const handleMarkAsSpam = async (emailToMark: Email | null = selectedEmail) => {
if (!client || !selectedEmail) return; if (!client || !emailToMark) return;
const emailId = selectedEmail.id; const emailId = emailToMark.id;
try { try {
await markAsSpam(client, emailId); await markAsSpam(client, emailId);
@@ -826,11 +906,11 @@ export default function Home() {
} }
}; };
const handleUndoSpam = async () => { const handleUndoSpam = async (emailToRestore: Email | null = selectedEmail) => {
if (!client || !selectedEmail) return; if (!client || !emailToRestore) return;
try { try {
await undoSpam(client, selectedEmail.id); await undoSpam(client, emailToRestore.id);
const toastInstance = (await import('sonner')).toast; const toastInstance = (await import('sonner')).toast;
toastInstance.success(t('email_viewer.spam.toast_not_spam_success')); toastInstance.success(t('email_viewer.spam.toast_not_spam_success'));
@@ -886,6 +966,32 @@ export default function Home() {
}; };
const handleMailboxSelect = async (mailboxId: string) => { const handleMailboxSelect = async (mailboxId: string) => {
if (isUnifiedMailboxId(mailboxId)) {
const role = UNIFIED_ROLE_BY_ID[mailboxId];
if (!role) return;
selectMailbox(mailboxId);
selectEmail(null);
if (isMobile) {
setSidebarOpen(false);
setActiveView("list");
}
if (isTablet) {
setTabletListVisible(true);
}
const built = buildUnifiedAccounts();
const populated = await populateUnifiedAccountMailboxes(built);
await fetchUnifiedEmailsAction(populated, role);
refreshUnifiedCounts(populated);
return;
}
if (isUnifiedView) {
exitUnifiedView();
}
selectMailbox(mailboxId); selectMailbox(mailboxId);
selectEmail(null); // Clear selected email when switching mailboxes selectEmail(null); // Clear selected email when switching mailboxes
@@ -969,6 +1075,7 @@ export default function Home() {
const handleSearch = async (query: string) => { const handleSearch = async (query: string) => {
if (!client) return; if (!client) return;
if (isUnifiedView) return;
setSearchQuery(query); setSearchQuery(query);
if (!isFilterEmpty(searchFilters)) { if (!isFilterEmpty(searchFilters)) {
await advancedSearch(client); await advancedSearch(client);
@@ -987,6 +1094,7 @@ export default function Home() {
const handleAdvancedSearch = async () => { const handleAdvancedSearch = async () => {
if (!client) return; if (!client) return;
if (isUnifiedView) return;
await advancedSearch(client); await advancedSearch(client);
}; };
@@ -996,9 +1104,9 @@ export default function Home() {
clearTimeout(advancedSearchDebounceRef.current); clearTimeout(advancedSearchDebounceRef.current);
} }
advancedSearchDebounceRef.current = setTimeout(() => { advancedSearchDebounceRef.current = setTimeout(() => {
if (client) advancedSearch(client); if (client && !isUnifiedView) advancedSearch(client);
}, 300); }, 300);
}, [client, advancedSearch]); }, [client, advancedSearch, isUnifiedView]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -1127,13 +1235,29 @@ export default function Home() {
// Fetch the full content // Fetch the full content
try { try {
// Find selected mailbox to determine accountId (for shared folders) // In unified view each email carries its own accountId. Use that
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); // account's client so we fetch from the server that actually owns it.
// Only pass accountId for shared mailboxes const listEmail = emails.find(e => e.id === email.id);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined; const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
const perAccountClient = emailAccountId
? useAuthStore.getState().getClientForAccount(emailAccountId)
: undefined;
const fetchClient = perAccountClient ?? client;
const fullEmail = await client.getEmail(email.id, accountId); // For shared folders on the primary client, we still need to pass the
// shared account's id. In unified view we use the per-account client
// directly, so no explicit accountId is needed.
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = perAccountClient
? undefined
: mailbox?.isShared ? mailbox.accountId : undefined;
const fullEmail = await fetchClient.getEmail(email.id, accountId);
if (fullEmail) { if (fullEmail) {
if (emailAccountId) {
fullEmail.accountId = emailAccountId;
fullEmail.accountLabel = listEmail?.accountLabel;
}
selectEmail(fullEmail); selectEmail(fullEmail);
// Mark-as-read logic is now handled by useEffect // Mark-as-read logic is now handled by useEffect
} }
@@ -1146,7 +1270,7 @@ 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 // Delegate to the browser history stack so this button is equivalent to
// the OS back button / mouse back button popstate then restores the // the OS back button / mouse back button - popstate then restores the
// previous snapshot via handleNavRestore. The viewer is only reachable // previous snapshot via handleNavRestore. The viewer is only reachable
// from a state that pushed history, so back() always lands on an app entry. // from a state that pushed history, so back() always lands on an app entry.
const handleMobileBack = () => { const handleMobileBack = () => {
@@ -1397,6 +1521,8 @@ export default function Home() {
className={cn("pl-9 h-9", searchQuery && "pr-8")} className={cn("pl-9 h-9", searchQuery && "pr-8")}
data-search-input data-search-input
data-tour="search-input" data-tour="search-input"
disabled={isUnifiedView}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : undefined}
/> />
{searchQuery && ( {searchQuery && (
<button <button
@@ -1412,13 +1538,15 @@ export default function Home() {
<button <button
type="button" type="button"
onClick={toggleAdvancedSearch} onClick={toggleAdvancedSearch}
disabled={isUnifiedView}
className={cn( className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors", "relative flex-shrink-0 p-2 rounded-md transition-colors",
isUnifiedView && "opacity-50 cursor-not-allowed",
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0 isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted" : "text-muted-foreground hover:text-foreground hover:bg-muted"
)} )}
title={t("advanced_search.toggle_filters")} title={isUnifiedView ? t("unified_mailbox.search_unavailable") : t("advanced_search.toggle_filters")}
> >
<Filter className="w-4 h-4" /> <Filter className="w-4 h-4" />
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && ( {!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
@@ -1602,8 +1730,7 @@ export default function Home() {
} }
}} }}
onDelete={async (email) => { onDelete={async (email) => {
selectEmail(email); await handleDelete(email);
await handleDelete();
}} }}
onArchive={async (email) => { onArchive={async (email) => {
await handleArchive(email); await handleArchive(email);
@@ -1617,12 +1744,10 @@ export default function Home() {
} }
}} }}
onMarkAsSpam={async (email) => { onMarkAsSpam={async (email) => {
selectEmail(email); await handleMarkAsSpam(email);
await handleMarkAsSpam();
}} }}
onUndoSpam={async (email) => { onUndoSpam={async (email) => {
selectEmail(email); await handleUndoSpam(email);
await handleUndoSpam();
}} }}
onEditDraft={(email) => { onEditDraft={(email) => {
handleEditDraft(email); handleEditDraft(email);
@@ -1694,7 +1819,8 @@ export default function Home() {
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,
receivedAt: selectedEmail.receivedAt receivedAt: selectedEmail.receivedAt,
attachments: selectedEmail.attachments,
} : undefined)} } : undefined)}
initialDraftText={composerDraftText} initialDraftText={composerDraftText}
initialData={pendingDraft} initialData={pendingDraft}
+4 -3
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Save, Loader2, RotateCcw } from 'lucide-react'; import { Save, Loader2, RotateCcw } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry { interface ConfigEntry {
value: unknown; value: unknown;
@@ -19,7 +20,7 @@ export default function AdminAuthPage() {
async function fetchConfig() { async function fetchConfig() {
setLoading(true); setLoading(true);
const res = await fetch('/api/admin/config'); const res = await apiFetch('/api/admin/config');
if (res.ok) setConfig(await res.json()); if (res.ok) setConfig(await res.json());
setLoading(false); setLoading(false);
} }
@@ -39,7 +40,7 @@ export default function AdminAuthPage() {
setSaving(true); setSaving(true);
setMessage(null); setMessage(null);
const res = await fetch('/api/admin/config', { const res = await apiFetch('/api/admin/config', {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits), body: JSON.stringify(edits),
@@ -57,7 +58,7 @@ export default function AdminAuthPage() {
} }
async function handleRevert(key: string) { async function handleRevert(key: string) {
const res = await fetch('/api/admin/config', { const res = await apiFetch('/api/admin/config', {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }), body: JSON.stringify({ key }),
+6 -5
View File
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react'; import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry { interface ConfigEntry {
value: unknown; value: unknown;
@@ -38,7 +39,7 @@ export default function AdminBrandingPage() {
async function fetchConfig() { async function fetchConfig() {
setLoading(true); setLoading(true);
const res = await fetch('/api/admin/config'); const res = await apiFetch('/api/admin/config');
if (res.ok) setConfig(await res.json()); if (res.ok) setConfig(await res.json());
setLoading(false); setLoading(false);
} }
@@ -58,7 +59,7 @@ export default function AdminBrandingPage() {
setSaving(true); setSaving(true);
setMessage(null); setMessage(null);
const res = await fetch('/api/admin/config', { const res = await apiFetch('/api/admin/config', {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits), body: JSON.stringify(edits),
@@ -83,7 +84,7 @@ export default function AdminBrandingPage() {
formData.append('file', file); formData.append('file', file);
formData.append('slot', slot); formData.append('slot', slot);
const res = await fetch('/api/admin/branding', { const res = await apiFetch('/api/admin/branding', {
method: 'POST', method: 'POST',
body: formData, body: formData,
}); });
@@ -112,7 +113,7 @@ export default function AdminBrandingPage() {
async function handleDeleteUpload(slot: string) { async function handleDeleteUpload(slot: string) {
setMessage(null); setMessage(null);
const res = await fetch('/api/admin/branding', { const res = await apiFetch('/api/admin/branding', {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slot }), body: JSON.stringify({ slot }),
@@ -133,7 +134,7 @@ export default function AdminBrandingPage() {
} }
async function handleRevert(key: string) { async function handleRevert(key: string) {
const res = await fetch('/api/admin/config', { const res = await apiFetch('/api/admin/config', {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }), body: JSON.stringify({ key }),
+2 -1
View File
@@ -3,6 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Lock } from 'lucide-react'; import { Lock } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
export default function ChangePasswordPage() { export default function ChangePasswordPage() {
const router = useRouter(); const router = useRouter();
@@ -28,7 +29,7 @@ export default function ChangePasswordPage() {
} }
setLoading(true); setLoading(true);
const res = await fetch('/api/admin/change-password', { const res = await apiFetch('/api/admin/change-password', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword }), body: JSON.stringify({ currentPassword, newPassword }),
+4 -3
View File
@@ -27,6 +27,7 @@ import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { apiFetch } from '@/lib/browser-navigation';
const NAV_GROUPS = [ const NAV_GROUPS = [
{ {
@@ -85,7 +86,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
async function checkAuth() { async function checkAuth() {
try { try {
const jmapHeaders = getJmapHeaders(); const jmapHeaders = getJmapHeaders();
const res = await fetch('/api/admin/auth', { headers: jmapHeaders }); const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
const data = await res.json(); const data = await res.json();
const stalwartAdmin = data.stalwartAdmin === true; const stalwartAdmin = data.stalwartAdmin === true;
@@ -104,7 +105,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
// If Stalwart admin but not yet authenticated, auto-login // If Stalwart admin but not yet authenticated, auto-login
if (stalwartAdmin) { if (stalwartAdmin) {
const loginRes = await fetch('/api/admin/auth', { const loginRes = await apiFetch('/api/admin/auth', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...jmapHeaders }, headers: { 'Content-Type': 'application/json', ...jmapHeaders },
body: JSON.stringify({ stalwartAuth: true }), body: JSON.stringify({ stalwartAuth: true }),
@@ -122,7 +123,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
} }
async function handleLogout() { async function handleLogout() {
await fetch('/api/admin/auth', { method: 'DELETE' }); await apiFetch('/api/admin/auth', { method: 'DELETE' });
router.replace('/admin/login'); router.replace('/admin/login');
} }
+2 -1
View File
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import { Shield } from 'lucide-react'; import { Shield } from 'lucide-react';
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 { apiFetch } from '@/lib/browser-navigation';
export default function AdminLoginPage() { export default function AdminLoginPage() {
const router = useRouter(); const router = useRouter();
@@ -21,7 +22,7 @@ export default function AdminLoginPage() {
setLoading(true); setLoading(true);
try { try {
const res = await fetch('/api/admin/auth', { const res = await apiFetch('/api/admin/auth', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }), body: JSON.stringify({ password }),
+3 -2
View File
@@ -3,6 +3,7 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { RefreshCw } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
import type { AuditEntry } from '@/lib/admin/types'; import type { AuditEntry } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
export default function AdminLogsPage() { export default function AdminLogsPage() {
const [entries, setEntries] = useState<AuditEntry[]>([]); const [entries, setEntries] = useState<AuditEntry[]>([]);
@@ -17,7 +18,7 @@ export default function AdminLogsPage() {
const params = new URLSearchParams({ page: String(page), limit: String(limit) }); const params = new URLSearchParams({ page: String(page), limit: String(limit) });
if (actionFilter) params.set('action', actionFilter); if (actionFilter) params.set('action', actionFilter);
const res = await fetch(`/api/admin/audit?${params}`); const res = await apiFetch(`/api/admin/audit?${params}`);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setEntries(data.entries || []); setEntries(data.entries || []);
@@ -138,7 +139,7 @@ export default function AdminLogsPage() {
} }
function formatDetail(detail: Record<string, unknown>): string { function formatDetail(detail: Record<string, unknown>): string {
if (!detail || Object.keys(detail).length === 0) return ''; if (!detail || Object.keys(detail).length === 0) return '-';
if (detail.reason) return String(detail.reason); if (detail.reason) return String(detail.reason);
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)}${JSON.stringify(detail.new)}`; if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)}${JSON.stringify(detail.new)}`;
if (detail.changes && Array.isArray(detail.changes)) { if (detail.changes && Array.isArray(detail.changes)) {
+4 -3
View File
@@ -2,6 +2,7 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react'; import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface Extension { interface Extension {
slug: string; slug: string;
@@ -57,7 +58,7 @@ export default function AdminMarketplacePage() {
params.set('perPage', String(perPage)); params.set('perPage', String(perPage));
params.set('sort', 'newest'); params.set('sort', 'newest');
const res = await fetch(`/api/admin/marketplace?${params}`); const res = await apiFetch(`/api/admin/marketplace?${params}`);
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
setError(data.error || 'Failed to connect to extension directory'); setError(data.error || 'Failed to connect to extension directory');
@@ -95,7 +96,7 @@ export default function AdminMarketplacePage() {
setMessage(null); setMessage(null);
try { try {
const res = await fetch('/api/admin/marketplace', { const res = await apiFetch('/api/admin/marketplace', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -116,7 +117,7 @@ export default function AdminMarketplacePage() {
setMessage({ type: 'error', text: data.error || 'Installation failed' }); setMessage({ type: 'error', text: data.error || 'Installation failed' });
} }
} catch { } catch {
setMessage({ type: 'error', text: 'Installation failed network error' }); setMessage({ type: 'error', text: 'Installation failed - network error' });
} finally { } finally {
setInstalling(null); setInstalling(null);
} }
+13 -12
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section'; import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
import type { AuditEntry } from '@/lib/admin/types'; import type { AuditEntry } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
interface AdminStatus { interface AdminStatus {
enabled: boolean; enabled: boolean;
@@ -38,13 +39,13 @@ export default function AdminDashboardPage() {
async function fetchDashboardData() { async function fetchDashboardData() {
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([ const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([
fetch('/api/admin/auth'), apiFetch('/api/admin/auth'),
fetch('/api/admin/audit?limit=10'), apiFetch('/api/admin/audit?limit=10'),
fetch('/api/config'), apiFetch('/api/config'),
fetch('/api/admin/config'), apiFetch('/api/admin/config'),
fetch('/api/admin/plugins').catch(() => null), apiFetch('/api/admin/plugins').catch(() => null),
fetch('/api/admin/themes').catch(() => null), apiFetch('/api/admin/themes').catch(() => null),
fetch('/api/admin/policy').catch(() => null), apiFetch('/api/admin/policy').catch(() => null),
]); ]);
if (statusRes.ok) setStatus(await statusRes.json()); if (statusRes.ok) setStatus(await statusRes.json());
@@ -75,7 +76,7 @@ export default function AdminDashboardPage() {
if (configData?.jmapServerUrl) { if (configData?.jmapServerUrl) {
try { try {
const jmapRes = await fetch('/api/config'); const jmapRes = await apiFetch('/api/config');
setJmapHealth(jmapRes.ok ? 'ok' : 'error'); setJmapHealth(jmapRes.ok ? 'ok' : 'error');
} catch { } catch {
setJmapHealth('error'); setJmapHealth('error');
@@ -98,8 +99,8 @@ export default function AdminDashboardPage() {
setWarnings(w); setWarnings(w);
} }
const jmapUrl = config?.jmapServerUrl || ''; const jmapUrl = config?.jmapServerUrl || '-';
const jmapHostname = jmapUrl !== '' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : ''; const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-';
return ( return (
<div className="max-w-3xl space-y-8"> <div className="max-w-3xl space-y-8">
@@ -126,9 +127,9 @@ export default function AdminDashboardPage() {
{/* Server Info */} {/* Server Info */}
<SettingsSection title="Server" description="Application and connection details"> <SettingsSection title="Server" description="Application and connection details">
<SettingItem label="Application"> <SettingItem label="Application">
<span className="text-sm text-foreground">{config?.appName || ''}</span> <span className="text-sm text-foreground">{config?.appName || '-'}</span>
</SettingItem> </SettingItem>
<SettingItem label="JMAP Server" description={jmapUrl !== '' ? jmapUrl : undefined}> <SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
<span className="text-sm text-foreground">{jmapHostname}</span> <span className="text-sm text-foreground">{jmapHostname}</span>
</SettingItem> </SettingItem>
<SettingItem label="JMAP Connection"> <SettingItem label="JMAP Connection">
+6 -5
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react'; import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigField { interface ConfigField {
type: 'string' | 'secret' | 'boolean' | 'number' | 'select'; type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
@@ -68,8 +69,8 @@ export default function PluginConfigPage() {
setLoading(true); setLoading(true);
try { try {
const [pluginsRes, configRes] = await Promise.all([ const [pluginsRes, configRes] = await Promise.all([
fetch('/api/admin/plugins'), apiFetch('/api/admin/plugins'),
fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`), apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
]); ]);
if (pluginsRes.ok) { if (pluginsRes.ok) {
@@ -117,7 +118,7 @@ export default function PluginConfigPage() {
// Delete if clearing a non-required field // Delete if clearing a non-required field
if (!newVal && !field.required) { if (!newVal && !field.required) {
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, { const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }), body: JSON.stringify({ key }),
@@ -130,7 +131,7 @@ export default function PluginConfigPage() {
continue; continue;
} }
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, { const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }), body: JSON.stringify({ key, value }),
@@ -230,7 +231,7 @@ export default function PluginConfigPage() {
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))} 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" 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> <option value="">- Select -</option>
{field.options.map(opt => ( {field.options.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option> <option key={opt.value} value={opt.value}>{opt.label}</option>
))} ))}
+10 -9
View File
@@ -5,6 +5,7 @@ import Link from 'next/link';
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react'; import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
import type { SettingsPolicy } from '@/lib/admin/types'; import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY } from '@/lib/admin/types'; import { DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
interface PluginEntry { interface PluginEntry {
id: string; id: string;
@@ -34,7 +35,7 @@ export default function AdminPluginsPage() {
async function fetchPolicy() { async function fetchPolicy() {
try { try {
const res = await fetch('/api/admin/policy'); const res = await apiFetch('/api/admin/policy');
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setPolicy(data); setPolicy(data);
@@ -73,7 +74,7 @@ export default function AdminPluginsPage() {
setSavingPolicy(true); setSavingPolicy(true);
setMessage(null); setMessage(null);
try { try {
const res = await fetch('/api/admin/policy', { const res = await apiFetch('/api/admin/policy', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policy), body: JSON.stringify(policy),
@@ -95,7 +96,7 @@ export default function AdminPluginsPage() {
async function fetchPlugins() { async function fetchPlugins() {
setLoading(true); setLoading(true);
try { try {
const res = await fetch('/api/admin/plugins'); const res = await apiFetch('/api/admin/plugins');
if (res.ok) setPlugins(await res.json()); if (res.ok) setPlugins(await res.json());
} finally { } finally {
setLoading(false); setLoading(false);
@@ -113,7 +114,7 @@ export default function AdminPluginsPage() {
formData.append('file', file); formData.append('file', file);
try { try {
const res = await fetch('/api/admin/plugins', { const res = await apiFetch('/api/admin/plugins', {
method: 'POST', method: 'POST',
body: formData, body: formData,
}); });
@@ -136,7 +137,7 @@ export default function AdminPluginsPage() {
async function togglePlugin(id: string, enabled: boolean) { async function togglePlugin(id: string, enabled: boolean) {
setMessage(null); setMessage(null);
const res = await fetch('/api/admin/plugins', { const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, enabled }), body: JSON.stringify({ id, enabled }),
@@ -156,7 +157,7 @@ export default function AdminPluginsPage() {
const body: Record<string, unknown> = { id, forceEnabled }; const body: Record<string, unknown> = { id, forceEnabled };
if (forceEnabled) body.enabled = true; if (forceEnabled) body.enabled = true;
const res = await fetch('/api/admin/plugins', { const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -190,7 +191,7 @@ export default function AdminPluginsPage() {
} }
let failed = 0; let failed = 0;
for (const p of disabled) { for (const p of disabled) {
const res = await fetch('/api/admin/plugins', { const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: p.id, enabled: true }), body: JSON.stringify({ id: p.id, enabled: true }),
@@ -216,7 +217,7 @@ export default function AdminPluginsPage() {
} }
let failed = 0; let failed = 0;
for (const p of enabled) { for (const p of enabled) {
const res = await fetch('/api/admin/plugins', { const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: p.id, enabled: false }), body: JSON.stringify({ id: p.id, enabled: false }),
@@ -236,7 +237,7 @@ export default function AdminPluginsPage() {
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return; if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
setMessage(null); setMessage(null);
const res = await fetch('/api/admin/plugins', { const res = await apiFetch('/api/admin/plugins', {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }), body: JSON.stringify({ id }),
+3 -2
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { Save, Loader2, Lock } from 'lucide-react'; import { Save, Loader2, Lock } from 'lucide-react';
import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types'; import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types'; import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
// Feature gates managed on their own admin pages (excluded from this list) // Feature gates managed on their own admin pages (excluded from this list)
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled']; const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
@@ -54,7 +55,7 @@ export default function AdminPolicyPage() {
async function fetchPolicy() { async function fetchPolicy() {
setLoading(true); setLoading(true);
try { try {
const res = await fetch('/api/admin/policy'); const res = await apiFetch('/api/admin/policy');
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setPolicy(data); setPolicy(data);
@@ -106,7 +107,7 @@ export default function AdminPolicyPage() {
setSaving(true); setSaving(true);
setMessage(null); setMessage(null);
const res = await fetch('/api/admin/policy', { const res = await apiFetch('/api/admin/policy', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policy), body: JSON.stringify(policy),
+4 -4
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Save, RotateCcw, Loader2 } from 'lucide-react'; import { Save, RotateCcw, Loader2 } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry { interface ConfigEntry {
value: unknown; value: unknown;
@@ -21,7 +22,7 @@ export default function AdminSettingsPage() {
async function fetchConfig() { async function fetchConfig() {
setLoading(true); setLoading(true);
const res = await fetch('/api/admin/config'); const res = await apiFetch('/api/admin/config');
if (res.ok) { if (res.ok) {
setConfig(await res.json()); setConfig(await res.json());
} }
@@ -43,7 +44,7 @@ export default function AdminSettingsPage() {
setSaving(true); setSaving(true);
setMessage(null); setMessage(null);
const res = await fetch('/api/admin/config', { const res = await apiFetch('/api/admin/config', {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits), body: JSON.stringify(edits),
@@ -61,7 +62,7 @@ export default function AdminSettingsPage() {
} }
async function handleRevert(key: string) { async function handleRevert(key: string) {
const res = await fetch('/api/admin/config', { const res = await apiFetch('/api/admin/config', {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }), body: JSON.stringify({ key }),
@@ -121,7 +122,6 @@ export default function AdminSettingsPage() {
</div> </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" />
<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} />
</SettingsSection> </SettingsSection>
+10 -9
View File
@@ -4,6 +4,7 @@ import { useEffect, useState, useRef } from 'react';
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react'; import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
import type { SettingsPolicy } from '@/lib/admin/types'; import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types'; import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
const BUILTIN_THEME_OPTIONS = [ const BUILTIN_THEME_OPTIONS = [
{ id: 'builtin-nord', name: 'Nord' }, { id: 'builtin-nord', name: 'Nord' },
@@ -38,7 +39,7 @@ export default function AdminThemesPage() {
async function fetchPolicy() { async function fetchPolicy() {
try { try {
const res = await fetch('/api/admin/policy'); const res = await apiFetch('/api/admin/policy');
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setPolicy({ setPolicy({
@@ -122,7 +123,7 @@ export default function AdminThemesPage() {
setSavingPolicy(true); setSavingPolicy(true);
setMessage(null); setMessage(null);
try { try {
const res = await fetch('/api/admin/policy', { const res = await apiFetch('/api/admin/policy', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policy), body: JSON.stringify(policy),
@@ -144,7 +145,7 @@ export default function AdminThemesPage() {
async function fetchThemes() { async function fetchThemes() {
setLoading(true); setLoading(true);
try { try {
const res = await fetch('/api/admin/themes'); const res = await apiFetch('/api/admin/themes');
if (res.ok) setThemes(await res.json()); if (res.ok) setThemes(await res.json());
} finally { } finally {
setLoading(false); setLoading(false);
@@ -162,7 +163,7 @@ export default function AdminThemesPage() {
formData.append('file', file); formData.append('file', file);
try { try {
const res = await fetch('/api/admin/themes', { const res = await apiFetch('/api/admin/themes', {
method: 'POST', method: 'POST',
body: formData, body: formData,
}); });
@@ -185,7 +186,7 @@ export default function AdminThemesPage() {
async function toggleTheme(id: string, enabled: boolean) { async function toggleTheme(id: string, enabled: boolean) {
setMessage(null); setMessage(null);
const res = await fetch('/api/admin/themes', { const res = await apiFetch('/api/admin/themes', {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, enabled }), body: JSON.stringify({ id, enabled }),
@@ -204,7 +205,7 @@ export default function AdminThemesPage() {
const body: Record<string, unknown> = { id, forceEnabled }; const body: Record<string, unknown> = { id, forceEnabled };
if (forceEnabled) body.enabled = true; if (forceEnabled) body.enabled = true;
const res = await fetch('/api/admin/themes', { const res = await apiFetch('/api/admin/themes', {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -237,7 +238,7 @@ export default function AdminThemesPage() {
} }
let failed = 0; let failed = 0;
for (const t of disabled) { for (const t of disabled) {
const res = await fetch('/api/admin/themes', { const res = await apiFetch('/api/admin/themes', {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: t.id, enabled: true }), body: JSON.stringify({ id: t.id, enabled: true }),
@@ -262,7 +263,7 @@ export default function AdminThemesPage() {
} }
let failed = 0; let failed = 0;
for (const t of enabled) { for (const t of enabled) {
const res = await fetch('/api/admin/themes', { const res = await apiFetch('/api/admin/themes', {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: t.id, enabled: false }), body: JSON.stringify({ id: t.id, enabled: false }),
@@ -282,7 +283,7 @@ export default function AdminThemesPage() {
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return; if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
setMessage(null); setMessage(null);
const res = await fetch('/api/admin/themes', { const res = await apiFetch('/api/admin/themes', {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }), body: JSON.stringify({ id }),
-87
View File
@@ -1,87 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/**
* GET /api/account/stalwart/auth
* Proxy to Stalwart GET /api/account/auth
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
const detail = parseStalwartError(text);
logger.warn('Stalwart auth info failed', { status: response.status, detail });
return NextResponse.json(
{ error: detail || 'Failed to fetch auth info' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart auth proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/account/stalwart/auth
* Proxy to Stalwart POST /api/account/auth
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart auth update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart auth update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
-87
View File
@@ -1,87 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/**
* GET /api/account/stalwart/crypto
* Proxy to Stalwart GET /api/account/crypto
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.apiUrl}/api/account/crypto`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
const detail = parseStalwartError(text);
logger.warn('Stalwart crypto info failed', { status: response.status, detail });
return NextResponse.json(
{ error: detail || 'Failed to fetch crypto info' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart crypto proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/account/stalwart/crypto
* Proxy to Stalwart POST /api/account/crypto
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const response = await fetch(`${creds.apiUrl}/api/account/crypto`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart crypto update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart crypto update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* POST /api/account/stalwart/jmap
*
* Passthrough to Stalwart's JMAP endpoint using the stored basic-auth
* context so the browser does not need access to the user's credentials.
*
* Body: standard JMAP request `{ using: string[], methodCalls: [...] }`
*
* In Stalwart 0.16 all management operations (password change, app
* passwords, API keys, account settings, etc.) are exposed as JMAP
* methods under the `x:` namespace on the same endpoint.
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.text();
const response = await fetch(`${creds.serverUrl}/jmap/`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body,
});
const responseText = await response.text();
return new NextResponse(responseText, {
status: response.status,
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
});
} catch (error) {
logger.error('Stalwart JMAP passthrough error', {
error: error instanceof Error ? error.message : 'Unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -1,93 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: SESSION_COOKIE_MAX_AGE,
};
/**
* POST /api/account/stalwart/password
* Change user password via Stalwart PATCH /api/principal/{name}
*
* Body: { currentPassword: string, newPassword: string }
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { currentPassword, newPassword } = await request.json();
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
if (newPassword.length < 8) {
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 });
}
// Verify current password by attempting to authenticate
const verifyAuth = `Basic ${Buffer.from(`${creds.username}:${currentPassword}`).toString('base64')}`;
const verifyResponse = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
method: 'GET',
headers: { 'Authorization': verifyAuth },
});
if (!verifyResponse.ok) {
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 403 });
}
// Change password via Stalwart principal API
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'PATCH',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify([
{ action: 'set', field: 'secrets', value: newPassword },
]),
});
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart password change failed', { status: response.status });
return NextResponse.json(
{ error: 'Failed to change password', details: text },
{ status: response.status }
);
}
// If session cookie exists, update it with the new password
const cookieStore = await cookies();
if (creds.hasSessionCookie) {
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
cookieStore.set(sessionCookieName(creds.slot), newToken, COOKIE_OPTIONS);
}
if (creds.authHeader.startsWith('Basic ')) {
setStalwartAuthContextInStore(cookieStore, creds.slot, {
serverUrl: creds.serverUrl,
username: creds.username,
authHeader: `Basic ${Buffer.from(`${creds.username}:${newPassword}`).toString('base64')}`,
});
}
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Stalwart password change proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -1,96 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/**
* GET /api/account/stalwart/principal
* Proxy to Stalwart GET /api/principal/{username}
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
const detail = parseStalwartError(text);
logger.warn('Stalwart principal fetch failed', { status: response.status, detail });
return NextResponse.json(
{ error: detail || 'Failed to fetch principal' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart principal proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PATCH /api/account/stalwart/principal
* Proxy to Stalwart PATCH /api/principal/{username}
* Body: PrincipalUpdateAction[] (array of {action, field, value})
*/
export async function PATCH(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
// Prevent secrets field from being changed through this endpoint (use /password instead)
if (Array.isArray(body)) {
const hasSecrets = body.some((action: { field?: string }) => action.field === 'secrets');
if (hasSecrets) {
return NextResponse.json({ error: 'Use /api/account/stalwart/password to change passwords' }, { status: 400 });
}
}
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'PATCH',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart principal update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart principal update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
-44
View File
@@ -1,44 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* GET /api/account/stalwart/probe
* Detect whether the JMAP server is Stalwart by probing /api/account/auth
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ isStalwart: false });
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
return NextResponse.json({ isStalwart: false });
}
const data = await response.json();
const isStalwart = data.data !== undefined && typeof data.data.otpEnabled === 'boolean';
return NextResponse.json({ isStalwart });
} catch {
clearTimeout(timeout);
return NextResponse.json({ isStalwart: false });
}
} catch (error) {
logger.error('Stalwart probe error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ isStalwart: false });
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { readAuditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
/** /**
* GET /api/admin/audit Get paginated audit log entries (admin-protected) * GET /api/admin/audit - Get paginated audit log entries (admin-protected)
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
+25 -7
View File
@@ -7,20 +7,38 @@ import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/** /**
* Check if the current user is a Stalwart admin by probing an admin-only endpoint. * Permissions that indicate Stalwart admin privileges.
* If the authenticated user has at least one of these, they can manage
* system-level resources and are considered an admin.
*/
const ADMIN_PERMISSIONS = [
'sysAccountQuery',
'sysTenantQuery',
'sysSystemSettingsGet',
];
/**
* Check if the current user is a Stalwart admin by inspecting the
* permissions list returned by Stalwart's /api/account endpoint.
*/ */
async function checkStalwartAdmin(request: NextRequest): Promise<boolean> { async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
try { try {
const creds = await getStalwartCredentials(request); const creds = await getStalwartCredentials(request);
if (!creds) return false; if (!creds) return false;
// Probe admin-only endpoint: listing principals requires admin privileges const response = await fetch(`${creds.serverUrl}/api/account`, {
const response = await fetch(`${creds.apiUrl}/api/principal?limit=1`, {
method: 'GET', method: 'GET',
headers: { 'Authorization': creds.authHeader }, headers: { 'Authorization': creds.authHeader },
}); });
const isAdmin = response.ok; if (!response.ok) {
logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin: false });
return false;
}
const data = await response.json() as { permissions?: string[] };
const permissions = Array.isArray(data.permissions) ? data.permissions : [];
const isAdmin = ADMIN_PERMISSIONS.some(p => permissions.includes(p));
logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin }); logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin });
return isAdmin; return isAdmin;
} catch (error) { } catch (error) {
@@ -30,7 +48,7 @@ async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
} }
/** /**
* POST /api/admin/auth Login * POST /api/admin/auth - Login
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -92,7 +110,7 @@ export async function POST(request: NextRequest) {
} }
/** /**
* GET /api/admin/auth Check session status * GET /api/admin/auth - Check session status
* Also checks if the user is a Stalwart admin (admin panel enabled even without password). * Also checks if the user is a Stalwart admin (admin panel enabled even without password).
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -136,7 +154,7 @@ export async function GET(request: NextRequest) {
} }
/** /**
* DELETE /api/admin/auth Logout * DELETE /api/admin/auth - Logout
*/ */
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
try { try {
+1 -1
View File
@@ -14,7 +14,7 @@ const MIME_TYPES: Record<string, string> = {
}; };
/** /**
* GET /api/admin/branding/[filename] Serve uploaded branding images * GET /api/admin/branding/[filename] - Serve uploaded branding images
* *
* This endpoint is public (no admin auth) so browsers can load images. * This endpoint is public (no admin auth) so browsers can load images.
* Only files in the branding directory are served; directory traversal is prevented. * Only files in the branding directory are served; directory traversal is prevented.
+2 -2
View File
@@ -33,7 +33,7 @@ function sanitizeFilename(name: string): string {
} }
/** /**
* POST /api/admin/branding Upload a branding image file * POST /api/admin/branding - Upload a branding image file
* *
* Expects multipart/form-data with: * Expects multipart/form-data with:
* - file: the image file * - file: the image file
@@ -105,7 +105,7 @@ export async function POST(request: NextRequest) {
} }
/** /**
* DELETE /api/admin/branding Remove an uploaded branding file * DELETE /api/admin/branding - Remove an uploaded branding file
* *
* Expects JSON body: { slot: string } * Expects JSON body: { slot: string }
*/ */
+1 -1
View File
@@ -5,7 +5,7 @@ import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
/** /**
* POST /api/admin/change-password Change admin password * POST /api/admin/change-password - Change admin password
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
+3 -3
View File
@@ -6,7 +6,7 @@ import { CONFIG_ENV_MAP } from '@/lib/admin/types';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
/** /**
* GET /api/admin/config Get full config with sources (admin-protected) * GET /api/admin/config - Get full config with sources (admin-protected)
*/ */
export async function GET() { export async function GET() {
try { try {
@@ -26,7 +26,7 @@ export async function GET() {
} }
/** /**
* PATCH /api/admin/config Update config overrides (admin-protected) * PATCH /api/admin/config - Update config overrides (admin-protected)
*/ */
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
try { try {
@@ -64,7 +64,7 @@ export async function PATCH(request: NextRequest) {
} }
/** /**
* DELETE /api/admin/config Remove admin override for a key (revert to env/default) * DELETE /api/admin/config - Remove admin override for a key (revert to env/default)
*/ */
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
try { try {
+2 -2
View File
@@ -17,7 +17,7 @@ import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001'; const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001';
/** /**
* GET /api/admin/marketplace Search/browse the extension directory * GET /api/admin/marketplace - Search/browse the extension directory
* Proxies to the extension directory API * Proxies to the extension directory API
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -75,7 +75,7 @@ export async function GET(request: NextRequest) {
} }
/** /**
* POST /api/admin/marketplace Install an extension from the directory * POST /api/admin/marketplace - Install an extension from the directory
* Body: { slug: string, version: string, type: 'plugin' | 'theme' } * Body: { slug: string, version: string, type: 'plugin' | 'theme' }
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
+1 -1
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry'; import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
/** /**
* GET /api/admin/plugins/[id]/bundle Serve plugin JS bundle * GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
* *
* Public endpoint so the client-side plugin loader can fetch bundles. * Public endpoint so the client-side plugin loader can fetch bundles.
* Only serves plugins that exist in the registry and are enabled. * Only serves plugins that exist in the registry and are enabled.
+3 -3
View File
@@ -4,7 +4,7 @@ import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/a
import { requireAdminAuth } from '@/lib/admin/session'; import { requireAdminAuth } from '@/lib/admin/session';
/** /**
* GET /api/admin/plugins/[id]/config Read all config for a plugin * GET /api/admin/plugins/[id]/config - Read all config for a plugin
* *
* Returns the full config object for admin-configured plugin settings. * Returns the full config object for admin-configured plugin settings.
* This endpoint is accessible from the client-side plugin API. * This endpoint is accessible from the client-side plugin API.
@@ -35,7 +35,7 @@ export async function GET(
} }
/** /**
* PUT /api/admin/plugins/[id]/config Set a config key * PUT /api/admin/plugins/[id]/config - Set a config key
* *
* Body: { key: string, value: unknown } * Body: { key: string, value: unknown }
* Requires admin authentication (checked via admin session). * Requires admin authentication (checked via admin session).
@@ -83,7 +83,7 @@ export async function PUT(
} }
/** /**
* DELETE /api/admin/plugins/[id]/config Delete a config key * DELETE /api/admin/plugins/[id]/config - Delete a config key
* *
* Body: { key: string } * Body: { key: string }
*/ */
+4 -4
View File
@@ -23,7 +23,7 @@ const SUSPICIOUS_JS_PATTERNS = [
]; ];
/** /**
* GET /api/admin/plugins List all admin-managed plugins * GET /api/admin/plugins - List all admin-managed plugins
*/ */
export async function GET() { export async function GET() {
try { try {
@@ -41,7 +41,7 @@ export async function GET() {
} }
/** /**
* POST /api/admin/plugins Upload and install a plugin ZIP * POST /api/admin/plugins - Upload and install a plugin ZIP
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -181,7 +181,7 @@ export async function POST(request: NextRequest) {
} }
/** /**
* PATCH /api/admin/plugins Update plugin metadata (enable/disable) * PATCH /api/admin/plugins - Update plugin metadata (enable/disable)
* Body: { id: string, enabled: boolean } * Body: { id: string, enabled: boolean }
*/ */
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
@@ -218,7 +218,7 @@ export async function PATCH(request: NextRequest) {
} }
/** /**
* DELETE /api/admin/plugins Remove a plugin * DELETE /api/admin/plugins - Remove a plugin
* Body: { id: string } * Body: { id: string }
*/ */
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
+2 -2
View File
@@ -6,7 +6,7 @@ import { logger } from '@/lib/logger';
import type { SettingsPolicy } from '@/lib/admin/types'; import type { SettingsPolicy } from '@/lib/admin/types';
/** /**
* GET /api/admin/policy Get settings policy (NOT admin-protected users read this) * GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this)
*/ */
export async function GET() { export async function GET() {
try { try {
@@ -22,7 +22,7 @@ export async function GET() {
} }
/** /**
* PUT /api/admin/policy Update settings policy (admin-protected) * PUT /api/admin/policy - Update settings policy (admin-protected)
*/ */
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
try { try {
-41
View File
@@ -1,41 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* GET /api/admin/stalwart-check
* Check if the currently logged-in user is a Stalwart admin.
* Probes the admin-only principal-list endpoint — if the user can access it, they're an admin.
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ isStalwartAdmin: false }, {
headers: { 'Cache-Control': 'no-store' },
});
}
// Probe an admin-only endpoint: listing principals requires admin privileges.
// Use limit=1 to minimize payload.
const url = `${creds.apiUrl}/api/principal?limit=1`;
const response = await fetch(url, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
const isStalwartAdmin = response.ok;
logger.info('Stalwart admin check', { username: creds.username, status: response.status, isStalwartAdmin });
return NextResponse.json({ isStalwartAdmin }, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Stalwart admin check error', {
error: error instanceof Error ? error.message : 'Unknown',
});
return NextResponse.json({ isStalwartAdmin: false }, {
headers: { 'Cache-Control': 'no-store' },
});
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
/** /**
* GET /api/admin/themes/[id]/css Serve theme CSS to clients * GET /api/admin/themes/[id]/css - Serve theme CSS to clients
*/ */
export async function GET( export async function GET(
_request: NextRequest, _request: NextRequest,
+4 -4
View File
@@ -14,7 +14,7 @@ import { MAX_THEME_SIZE } from '@/lib/plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader'; import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
/** /**
* GET /api/admin/themes List all admin-managed themes * GET /api/admin/themes - List all admin-managed themes
*/ */
export async function GET() { export async function GET() {
try { try {
@@ -32,7 +32,7 @@ export async function GET() {
} }
/** /**
* POST /api/admin/themes Upload and install a theme ZIP * POST /api/admin/themes - Upload and install a theme ZIP
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -151,7 +151,7 @@ export async function POST(request: NextRequest) {
} }
/** /**
* PATCH /api/admin/themes Update theme metadata (enable/disable) * PATCH /api/admin/themes - Update theme metadata (enable/disable)
* Body: { id: string, enabled: boolean } * Body: { id: string, enabled: boolean }
*/ */
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
@@ -188,7 +188,7 @@ export async function PATCH(request: NextRequest) {
} }
/** /**
* DELETE /api/admin/themes Remove a theme * DELETE /api/admin/themes - Remove a theme
* Body: { id: string } * Body: { id: string }
*/ */
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
+1 -1
View File
@@ -95,7 +95,7 @@ export async function GET(request: NextRequest) {
} }
/** /**
* PUT retrieve full credentials (including password) for session restoration. * PUT - retrieve full credentials (including password) for session restoration.
* Protected by multiple Sec-Fetch-* headers to ensure only same-origin * Protected by multiple Sec-Fetch-* headers to ensure only same-origin
* browser fetch() requests succeed. Non-browser clients cannot forge these. * browser fetch() requests succeed. Non-browser clients cannot forge these.
*/ */
+1 -1
View File
@@ -66,7 +66,7 @@ async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
return url; return url;
} }
} catch { } catch {
// Network error endpoint not reachable // Network error - endpoint not reachable
} }
} }
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -3,10 +3,10 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
/** /**
* GET /api/plugins Public endpoint for clients to discover server-managed plugins & themes * GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
* *
* Returns all enabled plugins and themes so the client can sync them to IndexedDB. * Returns all enabled plugins and themes so the client can sync them to IndexedDB.
* No admin auth required this is how regular users receive plugins/themes. * No admin auth required - this is how regular users receive plugins/themes.
*/ */
export async function GET() { export async function GET() {
try { try {
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import sharp from 'sharp';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
const VALID_SIZES = new Set([192, 512]);
// Cache resized images in memory to avoid reprocessing on every request
const cache = new Map<number, Blob>();
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
// Absolute URL (http/https)
if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) {
const res = await fetch(iconUrl);
if (!res.ok) throw new Error(`Failed to fetch PWA icon: ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
// Path relative to public/ directory
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
return readFile(publicPath);
}
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ size: string }> }
) {
const { size: sizeParam } = await params;
const size = parseInt(sizeParam, 10);
if (!VALID_SIZES.has(size)) {
return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 });
}
const iconUrl = process.env.PWA_ICON_URL || process.env.FAVICON_URL;
if (!iconUrl) {
return new NextResponse('No PWA icon configured', { status: 404 });
}
const pngHeaders = {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400',
};
try {
if (cache.has(size)) {
return new NextResponse(cache.get(size)!, { headers: pngHeaders });
}
const sourceBuffer = await fetchSourceImage(iconUrl);
const resized = await sharp(sourceBuffer)
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toBuffer();
const ab = new ArrayBuffer(resized.byteLength);
new Uint8Array(ab).set(resized);
const blob = new Blob([ab], { type: 'image/png' });
cache.set(size, blob);
return new NextResponse(blob, { headers: pngHeaders });
} catch (err) {
console.error('Failed to generate PWA icon:', err);
return new NextResponse('Failed to generate icon', { status: 500 });
}
}
+1 -1
View File
@@ -135,7 +135,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 }); return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
} }
// Enforce admin policy strip locked settings so users can't override them // Enforce admin policy - strip locked settings so users can't override them
await configManager.ensureLoaded(); await configManager.ensureLoaded();
const policy = configManager.getPolicy(); const policy = configManager.getPolicy();
const filteredSettings = { ...settings }; const filteredSettings = { ...settings };
+1 -1
View File
@@ -55,7 +55,7 @@ export async function POST(request: NextRequest) {
} }
const davPath = request.headers.get('X-WebDAV-Path') || '/'; const davPath = request.headers.get('X-WebDAV-Path') || '/';
const baseUrl = creds.apiUrl.replace(/\/$/, ''); const baseUrl = creds.serverUrl.replace(/\/$/, '');
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath); const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
// Build headers for the upstream request // Build headers for the upstream request
+5 -1
View File
@@ -209,10 +209,14 @@ body {
} }
.email-content-text a { .email-content-text a {
color: var(--color-primary); color: #2563eb;
text-decoration: underline; text-decoration: underline;
} }
.dark .email-content-text a {
color: #60a5fa;
}
.email-content { .email-content {
font-family: font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
-1
View File
@@ -22,7 +22,6 @@ 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: { appleWebApp: {
capable: true, capable: true,
statusBarStyle: "black-translucent", statusBarStyle: "black-translucent",
+55
View File
@@ -0,0 +1,55 @@
import type { MetadataRoute } from "next";
export const dynamic = "force-dynamic";
export default function manifest(): MetadataRoute.Manifest {
const appName =
process.env.APP_NAME ||
process.env.NEXT_PUBLIC_APP_NAME ||
"Bulwark Webmail";
const shortName = process.env.APP_SHORT_NAME || appName;
const description =
process.env.APP_DESCRIPTION ||
"A modern webmail client built for Stalwart Mail Server";
const themeColor = process.env.PWA_THEME_COLOR || "#ffffff";
const backgroundColor = process.env.PWA_BACKGROUND_COLOR || "#ffffff";
// If PWA_ICON_URL or FAVICON_URL is configured, serve dynamically resized PNGs
// via /api/pwa-icon/[size]. Otherwise fall back to the default Bulwark PNGs.
const hasCustomIcon = !!(process.env.PWA_ICON_URL || process.env.FAVICON_URL);
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
? [
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "any" },
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "maskable" },
]
: [
{ src: "/icon-192x192.png", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/icon-512x512.png", sizes: "512x512", type: "image/png", purpose: "any" },
{ src: "/icon-maskable-light-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: "/icon-maskable-light-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
{ src: "/icon-maskable-dark-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: "/icon-maskable-dark-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
];
return {
name: appName,
short_name: shortName,
description,
start_url: "/",
scope: "/",
display: "standalone",
orientation: "portrait-primary",
theme_color: themeColor,
background_color: backgroundColor,
icons,
categories: ["productivity"],
screenshots: [
{ src: "/screenshot-540x720.png", sizes: "540x720", type: "image/png" },
{ src: "/screenshot-1280x720.png", sizes: "1280x720", type: "image/png" },
],
};
}
@@ -17,6 +17,7 @@ interface CalendarAgendaViewProps {
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void; onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void; onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
timeFormat?: "12h" | "24h"; timeFormat?: "12h" | "24h";
} }
@@ -33,6 +34,7 @@ export function CalendarAgendaView({
onSelectEvent, onSelectEvent,
onHoverEvent, onHoverEvent,
onHoverLeave, onHoverLeave,
onContextMenuEvent,
timeFormat = "24h", timeFormat = "24h",
}: CalendarAgendaViewProps) { }: CalendarAgendaViewProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
@@ -157,6 +159,7 @@ export function CalendarAgendaView({
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())} onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())} onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onHoverLeave?.()} onMouseLeave={() => onHoverLeave?.()}
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left" className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left"
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }} style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
> >
@@ -19,6 +19,7 @@ interface CalendarDayViewProps {
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void; onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void; onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onCreateAtTime: (date: Date, endDate?: Date) => void; onCreateAtTime: (date: Date, endDate?: Date) => void;
timeFormat?: "12h" | "24h"; timeFormat?: "12h" | "24h";
isMobile?: boolean; isMobile?: boolean;
@@ -37,6 +38,7 @@ export function CalendarDayView({
onSelectEvent, onSelectEvent,
onHoverEvent, onHoverEvent,
onHoverLeave, onHoverLeave,
onContextMenuEvent,
onCreateAtTime, onCreateAtTime,
timeFormat = "24h", timeFormat = "24h",
isMobile, isMobile,
@@ -157,6 +159,7 @@ export function CalendarDayView({
onClick={(rect) => onSelectEvent(ev, rect)} onClick={(rect) => onSelectEvent(ev, rect)}
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave} onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
/> />
); );
})} })}
@@ -264,6 +267,7 @@ export function CalendarDayView({
onClick={(rect) => onSelectEvent(ev, rect)} onClick={(rect) => onSelectEvent(ev, rect)}
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave} onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
draggable draggable
/> />
<div <div
@@ -23,6 +23,7 @@ interface CalendarMonthViewProps {
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void; onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void; onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onCreateAtTime?: (date: Date) => void; onCreateAtTime?: (date: Date) => void;
firstDayOfWeek?: number; firstDayOfWeek?: number;
isMobile?: boolean; isMobile?: boolean;
@@ -37,6 +38,7 @@ export function CalendarMonthView({
onSelectEvent, onSelectEvent,
onHoverEvent, onHoverEvent,
onHoverLeave, onHoverLeave,
onContextMenuEvent,
onCreateAtTime, onCreateAtTime,
firstDayOfWeek = 1, firstDayOfWeek = 1,
isMobile, isMobile,
@@ -279,6 +281,7 @@ export function CalendarMonthView({
onClick={(rect) => onSelectEvent(segment.event, rect)} onClick={(rect) => onSelectEvent(segment.event, rect)}
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)} onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
onMouseLeave={onHoverLeave} onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
draggable draggable
/> />
</div> </div>
@@ -22,6 +22,7 @@ interface CalendarWeekViewProps {
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void; onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void; onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onCreateAtTime: (date: Date, endDate?: Date) => void; onCreateAtTime: (date: Date, endDate?: Date) => void;
firstDayOfWeek?: number; firstDayOfWeek?: number;
timeFormat?: "12h" | "24h"; timeFormat?: "12h" | "24h";
@@ -42,6 +43,7 @@ export function CalendarWeekView({
onSelectEvent, onSelectEvent,
onHoverEvent, onHoverEvent,
onHoverLeave, onHoverLeave,
onContextMenuEvent,
onCreateAtTime, onCreateAtTime,
firstDayOfWeek = 1, firstDayOfWeek = 1,
timeFormat = "24h", timeFormat = "24h",
@@ -242,6 +244,7 @@ export function CalendarWeekView({
onClick={(rect) => onSelectEvent(segment.event, rect)} onClick={(rect) => onSelectEvent(segment.event, rect)}
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)} onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
onMouseLeave={onHoverLeave} onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
/> />
</div> </div>
); );
@@ -402,6 +405,7 @@ export function CalendarWeekView({
onClick={(rect) => onSelectEvent(ev, rect)} onClick={(rect) => onSelectEvent(ev, rect)}
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave} onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
draggable draggable
/> />
<div <div
+7 -1
View File
@@ -17,6 +17,7 @@ interface EventCardProps {
onClick?: (anchorRect: DOMRect) => void; onClick?: (anchorRect: DOMRect) => void;
onMouseEnter?: (anchorRect: DOMRect) => void; onMouseEnter?: (anchorRect: DOMRect) => void;
onMouseLeave?: () => void; onMouseLeave?: () => void;
onContextMenu?: (e: React.MouseEvent, event: CalendarEvent) => void;
isSelected?: boolean; isSelected?: boolean;
draggable?: boolean; draggable?: boolean;
continuesBefore?: boolean; continuesBefore?: boolean;
@@ -65,7 +66,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
return el; return el;
} }
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) { export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const [isBeingDragged, setIsBeingDragged] = useState(false); const [isBeingDragged, setIsBeingDragged] = useState(false);
const color = getEventColor(event, calendar); const color = getEventColor(event, calendar);
@@ -109,12 +110,15 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
"aria-roledescription": "draggable event", "aria-roledescription": "draggable event",
} : {}; } : {};
const handleContextMenu = onContextMenu ? (e: React.MouseEvent) => onContextMenu(e, event) : undefined;
if (variant === "chip") { if (variant === "chip") {
return ( return (
<button <button
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }} onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())} onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()} onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
aria-label={ariaLabel} aria-label={ariaLabel}
{...dragProps} {...dragProps}
className={cn( className={cn(
@@ -142,6 +146,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }} onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())} onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()} onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
aria-label={ariaLabel} aria-label={ariaLabel}
{...dragProps} {...dragProps}
className={cn( className={cn(
@@ -172,6 +177,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }} onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())} onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()} onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
aria-label={ariaLabel} aria-label={ariaLabel}
{...dragProps} {...dragProps}
data-calendar-event data-calendar-event
@@ -0,0 +1,93 @@
"use client";
import { useTranslations } from "next-intl";
import {
ContextMenu,
ContextMenuItem,
ContextMenuSeparator,
} from "@/components/ui/context-menu";
import {
Pencil,
Copy,
Download,
ClipboardCopy,
Link as LinkIcon,
Trash2,
} from "lucide-react";
import type { CalendarEvent } from "@/lib/jmap/types";
interface Position {
x: number;
y: number;
}
interface EventContextMenuProps {
event: CalendarEvent;
position: Position;
isOpen: boolean;
onClose: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
onEdit: () => void;
onDuplicate: () => void;
onExportICS: () => void;
onCopyTitle: () => void;
onCopyMeetingLink?: () => void;
onDelete: () => void;
}
export function EventContextMenu({
event,
position,
isOpen,
onClose,
menuRef,
onEdit,
onDuplicate,
onExportICS,
onCopyTitle,
onCopyMeetingLink,
onDelete,
}: EventContextMenuProps) {
const t = useTranslations("calendar");
const handle = (fn: () => void) => () => {
fn();
onClose();
};
const hasMeetingLink = !!(
event.virtualLocations && Object.values(event.virtualLocations).some((v) => v.uri)
);
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem icon={Pencil} label={t("events.edit")} onClick={handle(onEdit)} />
<ContextMenuItem icon={Copy} label={t("events.duplicate")} onClick={handle(onDuplicate)} />
<ContextMenuSeparator />
<ContextMenuItem
icon={Download}
label={t("events.export_ics")}
onClick={handle(onExportICS)}
/>
<ContextMenuItem
icon={ClipboardCopy}
label={t("events.copy_title")}
onClick={handle(onCopyTitle)}
/>
{hasMeetingLink && onCopyMeetingLink && (
<ContextMenuItem
icon={LinkIcon}
label={t("events.copy_link")}
onClick={handle(onCopyMeetingLink)}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("events.delete")}
onClick={handle(onDelete)}
destructive
/>
</ContextMenu>
);
}
+16 -5
View File
@@ -9,7 +9,7 @@ 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, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { ParticipantInput } from "./participant-input"; import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
import { import {
isOrganizer, isOrganizer,
getUserParticipantId, getUserParticipantId,
@@ -233,6 +233,7 @@ export function EventModal({
.map(p => ({ name: p.name, email: p.email })); .map(p => ({ name: p.name, email: p.email }));
}); });
const [sendInvitations, setSendInvitations] = useState(true); const [sendInvitations, setSendInvitations] = useState(true);
const participantInputRef = useRef<ParticipantInputHandle>(null);
// Report live preview to parent for grid outline // Report live preview to parent for grid outline
useEffect(() => { useEffect(() => {
@@ -264,6 +265,9 @@ export function EventModal({
if (!trimmedTitle || isSaving) return; if (!trimmedTitle || isSaving) return;
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return; if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return;
const pendingAttendee = participantInputRef.current?.flush() ?? null;
const effectiveAttendees = pendingAttendee ? [...attendees, pendingAttendee] : attendees;
const startStr = allDay const startStr = allDay
? `${startDate}T00:00:00` ? `${startDate}T00:00:00`
: `${startDate}T${startTime}:00`; : `${startDate}T${startTime}:00`;
@@ -301,6 +305,10 @@ export function EventModal({
privacy: "public", privacy: "public",
}; };
if (!event) {
data.uid = generateUUID();
}
if (location.trim()) { if (location.trim()) {
data.locations = { data.locations = {
loc1: { loc1: {
@@ -373,18 +381,20 @@ export function EventModal({
data.alerts = null; data.alerts = null;
} }
if (attendees.length > 0 && currentUserEmails.length > 0) { if (effectiveAttendees.length > 0 && currentUserEmails.length > 0) {
const organizerEmail = currentUserEmails[0]; const organizerEmail = currentUserEmails[0];
const organizerName = existingParticipants.find(p => p.isOrganizer)?.name || ""; const organizerName = existingParticipants.find(p => p.isOrganizer)?.name || "";
data.participants = buildParticipantMap( data.participants = buildParticipantMap(
{ name: organizerName, email: organizerEmail }, { name: organizerName, email: organizerEmail },
attendees effectiveAttendees
) as Record<string, CalendarParticipant>; ) as Record<string, CalendarParticipant>;
} else if (attendees.length === 0 && event?.participants) { data.replyTo = { imip: `mailto:${organizerEmail}` };
} else if (effectiveAttendees.length === 0 && event?.participants) {
data.participants = null; data.participants = null;
data.replyTo = null;
} }
const shouldSendScheduling = attendees.length > 0 && sendInvitations; const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
setIsSaving(true); setIsSaving(true);
try { try {
await onSave(data, shouldSendScheduling); await onSave(data, shouldSendScheduling);
@@ -837,6 +847,7 @@ export function EventModal({
</span> </span>
</label> </label>
<ParticipantInput <ParticipantInput
ref={participantInputRef}
participants={attendees} participants={attendees}
onAdd={handleAddAttendee} onAdd={handleAddAttendee}
onRemove={handleRemoveAttendee} onRemove={handleRemoveAttendee}
+2 -1
View File
@@ -11,6 +11,7 @@ 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";
import { apiFetch } from "@/lib/browser-navigation";
interface ICalImportModalProps { interface ICalImportModalProps {
calendars: Calendar[]; calendars: Calendar[];
@@ -126,7 +127,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
setIsParsing(true); setIsParsing(true);
try { try {
const response = await fetch("/api/fetch-ical", { const response = await apiFetch("/api/fetch-ical", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: trimmed }), body: JSON.stringify({ url: trimmed }),
+29 -5
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useRef, useCallback, useEffect } from "react"; import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -18,9 +18,13 @@ interface ParticipantInputProps {
disabled?: boolean; disabled?: boolean;
} }
export interface ParticipantInputHandle {
flush: () => Participant | null;
}
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function ParticipantInput({ participants, onAdd, onRemove, disabled }: ParticipantInputProps) { export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantInputProps>(function ParticipantInput({ participants, onAdd, onRemove, disabled }, ref) {
const t = useTranslations("calendar.participants"); const t = useTranslations("calendar.participants");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<Participant[]>([]); const [suggestions, setSuggestions] = useState<Participant[]>([]);
@@ -86,8 +90,28 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]); }, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
const handleBlur = useCallback(() => { const handleBlur = useCallback(() => {
setTimeout(() => setShowSuggestions(false), 200); setTimeout(() => {
}, []); setShowSuggestions(false);
const trimmed = query.trim();
if (trimmed && EMAIL_REGEX.test(trimmed)) {
addParticipant({ name: "", email: trimmed });
}
}, 200);
}, [query, addParticipant]);
useImperativeHandle(ref, () => ({
flush: () => {
const trimmed = query.trim();
if (!trimmed || !EMAIL_REGEX.test(trimmed)) return null;
if (participants.some(e => e.email.toLowerCase() === trimmed.toLowerCase())) return null;
const p = { name: "", email: trimmed };
onAdd(p);
setQuery("");
setSuggestions([]);
setShowSuggestions(false);
return p;
},
}), [query, participants, onAdd]);
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@@ -160,4 +184,4 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
)} )}
</div> </div>
); );
} });
@@ -45,6 +45,9 @@ const defaultProps = {
onBulkDelete: vi.fn(), onBulkDelete: vi.fn(),
onBulkAddToGroup: vi.fn(), onBulkAddToGroup: vi.fn(),
onBulkExport: vi.fn(), onBulkExport: vi.fn(),
onEditContact: vi.fn(),
onDeleteContact: vi.fn(),
onAddContactToGroup: vi.fn(),
}; };
describe('ContactList', () => { describe('ContactList', () => {
+280
View File
@@ -0,0 +1,280 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { Mail, CalendarDays, Loader2 } from "lucide-react";
import { useRouter } from "@/i18n/navigation";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { cn } from "@/lib/utils";
import type { ContactCard, Email, CalendarEvent } from "@/lib/jmap/types";
const EMAIL_LIMIT = 5;
const EVENT_LIMIT = 5;
const EVENT_LOOKAHEAD_DAYS = 365;
interface ContactActivityProps {
contact: ContactCard;
}
function getContactEmails(contact: ContactCard): string[] {
if (!contact.emails) return [];
const seen = new Set<string>();
const result: string[] = [];
for (const e of Object.values(contact.emails)) {
const addr = e.address?.trim().toLowerCase();
if (addr && !seen.has(addr)) {
seen.add(addr);
result.push(addr);
}
}
return result;
}
function buildEmailFilter(addresses: string[]): Record<string, unknown> {
const conditions: Record<string, unknown>[] = [];
for (const addr of addresses) {
conditions.push({ from: addr });
conditions.push({ to: addr });
}
if (conditions.length === 1) return conditions[0];
return { operator: "OR", conditions };
}
function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boolean {
if (!event.participants) return false;
for (const p of Object.values(event.participants)) {
const email = p.email?.trim().toLowerCase();
if (email && addresses.has(email)) return true;
if (p.sendTo) {
for (const target of Object.values(p.sendTo)) {
const m = typeof target === "string" ? target.match(/mailto:(.+)/i) : null;
if (m && addresses.has(m[1].trim().toLowerCase())) return true;
}
}
}
if (event.organizerCalendarAddress) {
const m = event.organizerCalendarAddress.match(/mailto:(.+)/i);
const org = m ? m[1].trim().toLowerCase() : event.organizerCalendarAddress.trim().toLowerCase();
if (addresses.has(org)) return true;
}
return false;
}
export function ContactActivity({ contact }: ContactActivityProps) {
const t = useTranslations("contacts.activity");
const format = useFormatter();
const router = useRouter();
const client = useAuthStore((s) => s.client);
const selectEmail = useEmailStore((s) => s.selectEmail);
const setSelectedEventId = useCalendarStore((s) => s.setSelectedEventId);
const setSelectedDate = useCalendarStore((s) => s.setSelectedDate);
const [emails, setEmails] = useState<Email[] | null>(null);
const [events, setEvents] = useState<CalendarEvent[] | null>(null);
const [emailsLoading, setEmailsLoading] = useState(false);
const [eventsLoading, setEventsLoading] = useState(false);
const [emailsError, setEmailsError] = useState(false);
const [eventsError, setEventsError] = useState(false);
const addresses = getContactEmails(contact);
const addressKey = addresses.join(",");
useEffect(() => {
if (!client || addresses.length === 0) {
setEmails([]);
return;
}
let cancelled = false;
setEmailsLoading(true);
setEmailsError(false);
client
.advancedSearchEmails(buildEmailFilter(addresses), undefined, EMAIL_LIMIT, 0)
.then((res) => {
if (cancelled) return;
setEmails(res.emails);
})
.catch(() => {
if (cancelled) return;
setEmailsError(true);
setEmails([]);
})
.finally(() => {
if (!cancelled) setEmailsLoading(false);
});
return () => {
cancelled = true;
};
}, [client, addressKey]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!client || addresses.length === 0) {
setEvents([]);
return;
}
let cancelled = false;
setEventsLoading(true);
setEventsError(false);
const now = new Date();
const before = new Date(now.getTime() + EVENT_LOOKAHEAD_DAYS * 24 * 60 * 60 * 1000);
const addrSet = new Set(addresses);
client
.queryAllCalendarEvents(
{ after: now.toISOString(), before: before.toISOString() },
[{ property: "start", isAscending: true }],
500,
)
.then((all) => {
if (cancelled) return;
const matching = all
.filter((e) => eventInvolvesContact(e, addrSet))
.sort((a, b) => a.start.localeCompare(b.start))
.slice(0, EVENT_LIMIT);
setEvents(matching);
})
.catch(() => {
if (cancelled) return;
setEventsError(true);
setEvents([]);
})
.finally(() => {
if (!cancelled) setEventsLoading(false);
});
return () => {
cancelled = true;
};
}, [client, addressKey]); // eslint-disable-line react-hooks/exhaustive-deps
if (addresses.length === 0) return null;
const handleOpenEmail = (email: Email) => {
selectEmail(email);
router.push("/mail");
};
const handleOpenEvent = (event: CalendarEvent) => {
const start = new Date(event.start);
if (!isNaN(start.getTime())) setSelectedDate(start);
setSelectedEventId(event.id);
router.push("/calendar");
};
const formatEmailDate = (dateStr: string) => {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
const now = new Date();
const sameYear = d.getFullYear() === now.getFullYear();
return format.dateTime(d, sameYear
? { month: "short", day: "numeric" }
: { year: "numeric", month: "short", day: "numeric" });
};
const formatEventDate = (event: CalendarEvent) => {
const d = new Date(event.start);
if (isNaN(d.getTime())) return event.start;
if (event.showWithoutTime) {
return format.dateTime(d, { weekday: "short", month: "short", day: "numeric" });
}
return format.dateTime(d, {
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
};
return (
<>
<ActivitySection icon={Mail} title={t("recent_emails")}>
{emailsLoading ? (
<LoadingRow />
) : emailsError ? (
<p className="text-xs text-muted-foreground">{t("load_failed")}</p>
) : !emails || emails.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("no_emails")}</p>
) : (
emails.map((email) => (
<button
key={email.id}
type="button"
onClick={() => handleOpenEmail(email)}
className="w-full text-left p-2 -mx-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
>
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium truncate">
{email.subject || t("no_subject")}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatEmailDate(email.receivedAt)}
</span>
</div>
{email.preview && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{email.preview}
</p>
)}
</button>
))
)}
</ActivitySection>
<ActivitySection icon={CalendarDays} title={t("upcoming_events")}>
{eventsLoading ? (
<LoadingRow />
) : eventsError ? (
<p className="text-xs text-muted-foreground">{t("load_failed")}</p>
) : !events || events.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("no_events")}</p>
) : (
events.map((event) => (
<button
key={event.id}
type="button"
onClick={() => handleOpenEvent(event)}
className="w-full text-left p-2 -mx-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
>
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium truncate">
{event.title || t("no_title")}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatEventDate(event)}
</span>
</div>
</button>
))
)}
</ActivitySection>
</>
);
}
function ActivitySection({
icon: Icon,
title,
children,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
children: React.ReactNode;
}) {
return (
<div className={cn("rounded-lg border border-border bg-card p-4 border-l-[3px]", "border-l-rose-400 dark:border-l-rose-500")}>
<div className="flex items-center gap-2 mb-2.5">
<Icon className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
</div>
<div className="space-y-1 pl-6">{children}</div>
</div>
);
}
function LoadingRow() {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
</div>
);
}
@@ -0,0 +1,160 @@
"use client";
import { useTranslations } from "next-intl";
import {
ContextMenu,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuHeader,
} from "@/components/ui/context-menu";
import {
Eye,
Pencil,
Mail,
ClipboardCopy,
Download,
Users,
Trash2,
} from "lucide-react";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactPrimaryEmail } from "@/stores/contact-store";
import { exportContact } from "./contact-export";
import { toast } from "@/stores/toast-store";
interface Position {
x: number;
y: number;
}
interface ContactContextMenuProps {
contact: ContactCard;
position: Position;
isOpen: boolean;
onClose: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
isMultiSelect?: boolean;
selectedCount?: number;
onOpen: () => void;
onEdit: () => void;
onDelete: () => void;
onAddToGroup: () => void;
onBatchExport?: () => void;
onBatchAddToGroup?: () => void;
onBatchDelete?: () => void;
}
export function ContactContextMenu({
contact,
position,
isOpen,
onClose,
menuRef,
isMultiSelect = false,
selectedCount = 1,
onOpen,
onEdit,
onDelete,
onAddToGroup,
onBatchExport,
onBatchAddToGroup,
onBatchDelete,
}: ContactContextMenuProps) {
const t = useTranslations("contacts");
const email = getContactPrimaryEmail(contact);
const showBatchActions = isMultiSelect && selectedCount > 1;
const handle = (fn: () => void) => () => {
fn();
onClose();
};
const handleSendEmail = () => {
if (!email) return;
window.location.href = `mailto:${email}`;
};
const handleCopyEmail = async () => {
if (!email) return;
try {
await navigator.clipboard.writeText(email);
toast.success(t("detail.copied"));
} catch {
toast.error(t("detail.copy_failed"));
}
};
const handleExport = () => {
exportContact(contact);
toast.success(t("export.success", { count: 1 }));
};
if (showBatchActions) {
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuHeader>
{t("bulk.selected", { count: selectedCount })}
</ContextMenuHeader>
<ContextMenuItem
icon={Users}
label={t("bulk.add_to_group")}
onClick={handle(() => onBatchAddToGroup?.())}
disabled={!onBatchAddToGroup}
/>
<ContextMenuItem
icon={Download}
label={t("bulk.export")}
onClick={handle(() => onBatchExport?.())}
disabled={!onBatchExport}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("bulk.delete")}
onClick={handle(() => onBatchDelete?.())}
disabled={!onBatchDelete}
destructive
/>
</ContextMenu>
);
}
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />
<ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />
{email && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={Mail}
label={t("context_menu.send_email")}
onClick={handle(handleSendEmail)}
/>
<ContextMenuItem
icon={ClipboardCopy}
label={t("detail.copy_email")}
onClick={handle(handleCopyEmail)}
/>
</>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={Users}
label={t("context_menu.add_to_group")}
onClick={handle(onAddToGroup)}
/>
<ContextMenuItem
icon={Download}
label={t("context_menu.export_vcard")}
onClick={handle(handleExport)}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("context_menu.delete")}
onClick={handle(onDelete)}
destructive
/>
</ContextMenu>
);
}
+7 -4
View File
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types"; import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { ContactActivity } from "./contact-activity";
import { useSmimeStore } from "@/stores/smime-store"; import { useSmimeStore } from "@/stores/smime-store";
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils"; import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
import type { CertificateInfo } from "@/lib/smime/types"; import type { CertificateInfo } from "@/lib/smime/types";
@@ -87,7 +88,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
try { try {
let derBytes: ArrayBuffer | string | null = null; let derBytes: ArrayBuffer | string | null = null;
if (key.uri.startsWith('data:')) { if (key.uri.startsWith('data:')) {
// data URI extract base64 content // data URI - extract base64 content
const commaIdx = key.uri.indexOf(','); const commaIdx = key.uri.indexOf(',');
if (commaIdx === -1) continue; if (commaIdx === -1) continue;
const b64 = key.uri.substring(commaIdx + 1); const b64 = key.uri.substring(commaIdx + 1);
@@ -178,7 +179,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} /> <Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || ""}</h2> <h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
{hasNickname && ( {hasNickname && (
<p className="text-sm text-muted-foreground truncate">&ldquo;{nicknames.map(n => n.name).join(", ")}&rdquo;</p> <p className="text-sm text-muted-foreground truncate">&ldquo;{nicknames.map(n => n.name).join(", ")}&rdquo;</p>
)} )}
@@ -204,6 +205,8 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
<div className="px-6 py-6"> <div className="px-6 py-6">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<ContactActivity contact={contact} />
{/* Contact info */} {/* Contact info */}
{emails.length > 0 && ( {emails.length > 0 && (
<Section icon={Mail} title={t("detail.emails")} category="contact"> <Section icon={Mail} title={t("detail.emails")} category="contact">
@@ -278,7 +281,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
<div key={i} className="text-sm"> <div key={i} className="text-sm">
{o.name} {o.name}
{o.units && o.units.length > 0 && ( {o.units && o.units.length > 0 && (
<span className="text-muted-foreground"> {o.units.map(u => u.name).join(", ")}</span> <span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
)} )}
</div> </div>
))} ))}
@@ -371,7 +374,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
{contact.speakToAs.pronouns && (() => { {contact.speakToAs.pronouns && (() => {
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns; const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
return firstPronoun ? ( return firstPronoun ? (
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " " : ""}{firstPronoun}</span> <span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " - " : ""}{firstPronoun}</span>
) : null; ) : null;
})()} })()}
</div> </div>
+7 -7
View File
@@ -507,7 +507,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div> </div>
)} )}
{/* Name & Identity full width */} {/* Name & Identity - full width */}
<div className="md:col-span-2 xl:col-span-3"> <div className="md:col-span-2 xl:col-span-3">
<FormSection icon={User} title={t("section_identity")} category="contact"> <FormSection icon={User} title={t("section_identity")} category="contact">
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2"> <div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
@@ -575,7 +575,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
setEmails(next); setEmails(next);
}} }}
> >
<option value=""></option> <option value="">-</option>
<option value="work">{t("context_work")}</option> <option value="work">{t("context_work")}</option>
<option value="private">{t("context_private")}</option> <option value="private">{t("context_private")}</option>
</Select> </Select>
@@ -639,7 +639,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
setPhones(next); setPhones(next);
}} }}
> >
<option value=""></option> <option value="">-</option>
<option value="work">{t("context_work")}</option> <option value="work">{t("context_work")}</option>
<option value="private">{t("context_private")}</option> <option value="private">{t("context_private")}</option>
</Select> </Select>
@@ -677,7 +677,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div> </div>
</FormSection> </FormSection>
{/* Addresses full width */} {/* Addresses - full width */}
<div className="md:col-span-2 xl:col-span-3"> <div className="md:col-span-2 xl:col-span-3">
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location"> <FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
<div className="space-y-3"> <div className="space-y-3">
@@ -698,7 +698,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
value={addr.context} value={addr.context}
onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }}
> >
<option value=""></option> <option value="">-</option>
<option value="work">{t("context_work")}</option> <option value="work">{t("context_work")}</option>
<option value="private">{t("context_private")}</option> <option value="private">{t("context_private")}</option>
</Select> </Select>
@@ -833,7 +833,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
<div> <div>
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label> <label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
<Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full"> <Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
<option value=""></option> <option value="">-</option>
<option value="masculine">{t("gender_male")}</option> <option value="masculine">{t("gender_male")}</option>
<option value="feminine">{t("gender_female")}</option> <option value="feminine">{t("gender_female")}</option>
<option value="other">{t("gender_other")}</option> <option value="other">{t("gender_other")}</option>
@@ -866,7 +866,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div> </div>
</FormSection> </FormSection>
{/* Notes full width */} {/* Notes - full width */}
<div className="md:col-span-2 xl:col-span-3"> <div className="md:col-span-2 xl:col-span-3">
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes"> <FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
<textarea <textarea
@@ -199,7 +199,7 @@ export function ContactImportDialog({
</div> </div>
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" /> <FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{cName || cEmail || ""}</div> <div className="text-sm font-medium truncate">{cName || cEmail || "-"}</div>
{cEmail && cName && ( {cEmail && cName && (
<div className="text-xs text-muted-foreground truncate">{cEmail}</div> <div className="text-xs text-muted-foreground truncate">{cEmail}</div>
)} )}
+4 -2
View File
@@ -17,9 +17,10 @@ interface ContactListItemProps {
selectedContactIds: Set<string>; selectedContactIds: Set<string>;
onClick: (e: React.MouseEvent) => void; onClick: (e: React.MouseEvent) => void;
onCheckboxClick: (e: React.MouseEvent) => void; onCheckboxClick: (e: React.MouseEvent) => void;
onContextMenu?: (e: React.MouseEvent, contact: ContactCard) => void;
} }
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick }: ContactListItemProps) { export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick, onContextMenu }: ContactListItemProps) {
const name = getContactDisplayName(contact); const name = getContactDisplayName(contact);
const email = getContactPrimaryEmail(contact); const email = getContactPrimaryEmail(contact);
const org = contact.organizations const org = contact.organizations
@@ -56,6 +57,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
draggable draggable
onDragStart={handleDragStart} onDragStart={handleDragStart}
onClick={onClick} onClick={onClick}
onContextMenu={onContextMenu ? (e) => onContextMenu(e, contact) : undefined}
className={cn( className={cn(
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border", "w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
isSelected isSelected
@@ -90,7 +92,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate"> <div className="text-sm font-medium truncate">
{name || email || ""} {name || email || "-"}
</div> </div>
{density !== 'extra-compact' && email && name && ( {density !== 'extra-compact' && email && name && (
<div className="text-xs text-muted-foreground truncate">{email}</div> <div className="text-xs text-muted-foreground truncate">{email}</div>
+29
View File
@@ -6,6 +6,8 @@ import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Sq
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item"; import { ContactListItem } from "./contact-list-item";
import { ContactContextMenu } from "./contact-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types"; import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store"; import { getContactDisplayName } from "@/stores/contact-store";
@@ -28,6 +30,9 @@ interface ContactListProps {
onBulkDelete: () => void; onBulkDelete: () => void;
onBulkAddToGroup: () => void; onBulkAddToGroup: () => void;
onBulkExport: () => void; onBulkExport: () => void;
onEditContact: (id: string) => void;
onDeleteContact: (contact: ContactCard) => void;
onAddContactToGroup: (id: string) => void;
} }
export function ContactList({ export function ContactList({
@@ -47,9 +52,13 @@ export function ContactList({
onBulkDelete, onBulkDelete,
onBulkAddToGroup, onBulkAddToGroup,
onBulkExport, onBulkExport,
onEditContact,
onDeleteContact,
onAddContactToGroup,
}: ContactListProps) { }: ContactListProps) {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!searchQuery) return contacts; if (!searchQuery) return contacts;
@@ -210,11 +219,31 @@ export function ContactList({
e.stopPropagation(); e.stopPropagation();
onToggleSelection(contact.id); onToggleSelection(contact.id);
}} }}
onContextMenu={(e, c) => openContextMenu(e, c)}
/> />
))} ))}
</div> </div>
)} )}
</div> </div>
{contextMenu.data && (
<ContactContextMenu
contact={contextMenu.data}
position={contextMenu.position}
isOpen={contextMenu.isOpen}
onClose={closeContextMenu}
menuRef={menuRef}
isMultiSelect={selectedContactIds.has(contextMenu.data.id)}
selectedCount={selectedContactIds.size}
onOpen={() => onSelectContact(contextMenu.data!.id)}
onEdit={() => onEditContact(contextMenu.data!.id)}
onDelete={() => onDeleteContact(contextMenu.data!)}
onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)}
onBatchExport={onBulkExport}
onBatchAddToGroup={onBulkAddToGroup}
onBatchDelete={onBulkDelete}
/>
)}
</div> </div>
); );
} }
@@ -527,7 +527,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|| null; || null;
// Send the iMIP REPLY email to the organizer (client-side scheduling). // Send the iMIP REPLY email to the organizer (client-side scheduling).
// Called after updating the local calendar event. Best-effort if it // Called after updating the local calendar event. Best-effort - if it
// fails we still report the RSVP as sent since the calendar was updated. // fails we still report the RSVP as sent since the calendar was updated.
const sendImipReply = async () => { const sendImipReply = async () => {
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) { if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
+150 -30
View File
@@ -6,12 +6,13 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react"; import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react";
import { cn, formatFileSize, formatDateTime } from "@/lib/utils"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { sanitizeEmailHtml } from "@/lib/email-sanitization"; import { sanitizeEmailHtml } from "@/lib/email-sanitization";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store"; import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store";
import { useSmimeStore } from "@/stores/smime-store"; import { useSmimeStore } from "@/stores/smime-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
@@ -65,7 +66,7 @@ interface EmailComposerProps {
fromEmail?: string; fromEmail?: string;
fromName?: string; fromName?: string;
identityId?: string; identityId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
}) => void | Promise<void>; }) => void | Promise<void>;
onClose?: () => void; onClose?: () => void;
onDiscardDraft?: (draftId: string) => void; onDiscardDraft?: (draftId: string) => void;
@@ -84,9 +85,22 @@ interface EmailComposerProps {
body?: string; body?: string;
htmlBody?: string; htmlBody?: string;
receivedAt?: string; receivedAt?: string;
accountId?: string;
attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string }>;
}; };
} }
type ComposerAttachment = {
file?: File;
name: string;
type: string;
size: number;
blobId?: string;
uploading?: boolean;
error?: boolean;
abortController?: AbortController;
};
export function EmailComposer({ export function EmailComposer({
onSend, onSend,
onClose, onClose,
@@ -199,7 +213,22 @@ export function EmailComposer({
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null); const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedDataRef = useRef<string>(""); const lastSavedDataRef = useRef<string>("");
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController }>>([]); const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
if (mode === 'forward' && replyTo?.attachments?.length) {
return replyTo.attachments
// Skip inline cid-referenced images - they're embedded in the forwarded HTML body
// (matches the viewer's hideInlineImageAttachments logic).
.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
.map(att => ({
name: att.name || 'attachment',
type: att.type || 'application/octet-stream',
size: att.size,
blobId: att.blobId,
}));
}
return [];
});
const inlineImagesRef = useRef<Array<{ cid: string; blobId: string; type: string; name: string; size: number; dataUrl: string }>>([]);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({}); const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
const [shakeField, setShakeField] = useState<string | null>(null); const [shakeField, setShakeField] = useState<string | null>(null);
@@ -254,12 +283,28 @@ export function EmailComposer({
if (matchedIdentityId) { if (matchedIdentityId) {
setSelectedIdentityId(matchedIdentityId); setSelectedIdentityId(matchedIdentityId);
return;
}
// Fallback: match identity by the account's email when replying from unified view
if (replyTo?.accountId) {
const account = useAccountStore.getState().getAccountById(replyTo.accountId);
if (account?.email) {
const accountEmail = account.email.trim().toLowerCase();
const accountIdentity = identities.find(
(identity) => identity.email.trim().toLowerCase() === accountEmail
);
if (accountIdentity) {
setSelectedIdentityId(accountIdentity.id);
}
}
} }
}, [ }, [
autoSelectReplyIdentity, autoSelectReplyIdentity,
identities, identities,
initialData?.selectedIdentityId, initialData?.selectedIdentityId,
mode, mode,
replyTo?.accountId,
replyTo?.bcc, replyTo?.bcc,
replyTo?.cc, replyTo?.cc,
replyTo?.to, replyTo?.to,
@@ -306,7 +351,7 @@ export function EmailComposer({
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId }; stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId };
// Track initial values for dirty detection (captured once on first render) // Track initial values for dirty detection (captured once on first render)
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: 0 }); const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: attachments.length });
const isDirtyRef = useRef(false); const isDirtyRef = useRef(false);
isDirtyRef.current = to !== initialValuesRef.current.to || cc !== initialValuesRef.current.cc || isDirtyRef.current = to !== initialValuesRef.current.to || cc !== initialValuesRef.current.cc ||
bcc !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject || bcc !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject ||
@@ -505,9 +550,16 @@ export function EmailComposer({
const addFiles = useCallback(async (files: File[]) => { const addFiles = useCallback(async (files: File[]) => {
if (!client || files.length === 0) return; if (!client || files.length === 0) return;
const newAttachments = files.map(file => { const newAttachments: ComposerAttachment[] = files.map(file => {
const controller = new AbortController(); const controller = new AbortController();
return { file, uploading: true, abortController: controller }; return {
file,
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
uploading: true,
abortController: controller,
};
}); });
setAttachments(prev => [...prev, ...newAttachments]); setAttachments(prev => [...prev, ...newAttachments]);
@@ -542,18 +594,38 @@ export function EmailComposer({
} }
}, [client, t]); }, [client, t]);
const handleImageUpload = useCallback((file: File): Promise<string | null> => { const handleImageUpload = useCallback(async (
return new Promise((resolve) => { file: File,
const reader = new FileReader(); ): Promise<{ src: string; cid: string } | null> => {
reader.onload = (e) => resolve((e.target?.result as string) ?? null); if (!client) return null;
reader.onerror = () => { try {
debug.error(`Failed to read inline image ${file.name}`); const readAsDataUrl = new Promise<string | null>((resolve) => {
toast.error(t('upload_failed', { filename: file.name })); const reader = new FileReader();
resolve(null); reader.onload = (e) => resolve((e.target?.result as string) ?? null);
}; reader.onerror = () => resolve(null);
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
}, [t]); const [{ blobId }, dataUrl] = await Promise.all([
client.uploadBlob(file),
readAsDataUrl,
]);
if (!dataUrl) throw new Error('Failed to read image as data URL');
const cid = `${generateUUID()}@webmail`;
inlineImagesRef.current.push({
cid,
blobId,
type: file.type || 'application/octet-stream',
name: file.name,
size: file.size,
dataUrl,
});
return { src: dataUrl, cid };
} catch (error) {
debug.error(`Failed to upload inline image ${file.name}:`, error);
toast.error(t('upload_failed', { filename: file.name }));
return null;
}
}, [client, t]);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files) return; if (!event.target.files) return;
@@ -630,9 +702,9 @@ export function EmailComposer({
.filter(att => att.blobId && !att.uploading) .filter(att => att.blobId && !att.uploading)
.map(att => ({ .map(att => ({
blobId: att.blobId!, blobId: att.blobId!,
name: att.file.name, name: att.name,
type: att.file.type, type: att.type,
size: att.file.size, size: att.size,
})); }));
// Create a hash of current data to compare with last saved // Create a hash of current data to compare with last saved
@@ -733,6 +805,41 @@ export function EmailComposer({
return undefined; return undefined;
}; };
// Rewrite data: URLs of dropped images (tagged with data-cid) into cid:
// references so recipient clients that strip data URIs can still render them.
const rewriteInlineImages = (html: string): {
html: string;
attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>;
} => {
const known = inlineImagesRef.current;
if (known.length === 0) return { html, attachments: [] };
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
const used = new Map<string, typeof known[number]>();
doc.querySelectorAll('img[data-cid]').forEach((img) => {
const cid = img.getAttribute('data-cid');
if (!cid) return;
const entry = known.find((e) => e.cid === cid);
if (!entry) return;
img.setAttribute('src', `cid:${cid}`);
img.removeAttribute('data-cid');
used.set(cid, entry);
});
return {
html: doc.body.innerHTML,
attachments: Array.from(used.values()).map((e) => ({
blobId: e.blobId,
name: e.name,
type: e.type,
size: e.size,
disposition: 'inline' as const,
cid: e.cid,
})),
};
};
const handleSend = async (skipAttachmentCheck = false) => { 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);
@@ -803,9 +910,11 @@ export function EmailComposer({
? appendPlainTextSignature(body, currentIdentity) ? appendPlainTextSignature(body, currentIdentity)
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity); : appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
const finalHtmlBody = plainTextMode const finalHtmlBody = plainTextMode
? undefined ? undefined
: `<div>${body}</div>${buildSignatureHtml()}`; : `<div>${rewritten!.html}</div>${buildSignatureHtml()}`;
const inlineAttachments = rewritten?.attachments ?? [];
try { try {
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail // S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
@@ -834,19 +943,29 @@ export function EmailComposer({
for (const att of attachments) { for (const att of attachments) {
if (att.error || att.uploading) continue; if (att.error || att.uploading) continue;
let content: ArrayBuffer; let content: ArrayBuffer;
if (att.file.size > 0) { if (att.file && att.file.size > 0) {
content = await att.file.arrayBuffer(); content = await att.file.arrayBuffer();
} else if (att.blobId && client) { } else if (att.blobId && client) {
content = await client.fetchBlobArrayBuffer(att.blobId, att.file.name, att.file.type); content = await client.fetchBlobArrayBuffer(att.blobId, att.name, att.type);
} else { } else {
continue; continue;
} }
mimeAttachments.push({ mimeAttachments.push({
filename: att.file.name, filename: att.name,
contentType: att.file.type || 'application/octet-stream', contentType: att.type || 'application/octet-stream',
content, content,
}); });
} }
for (const inline of inlineAttachments) {
if (!client) break;
const content = await client.fetchBlobArrayBuffer(inline.blobId, inline.name, inline.type);
mimeAttachments.push({
filename: inline.name,
contentType: inline.type,
content,
cid: inline.cid,
});
}
// 4. Build canonical MIME // 4. Build canonical MIME
const mimeBytes = buildMimeMessage({ const mimeBytes = buildMimeMessage({
@@ -906,9 +1025,10 @@ export function EmailComposer({
} else { } else {
// Standard JMAP send path // Standard JMAP send path
// Collect uploaded attachment blobIds for the send request // Collect uploaded attachment blobIds for the send request
const uploadedAttachments = attachments const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachments
.filter(att => att.blobId && !att.uploading && !att.error) .filter(att => att.blobId && !att.uploading && !att.error)
.map(att => ({ blobId: att.blobId!, name: att.file.name, type: att.file.type || 'application/octet-stream', size: att.file.size })); .map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
uploadedAttachments.push(...inlineAttachments);
await onSend?.({ await onSend?.({
to: toAddresses, to: toAddresses,
@@ -1288,9 +1408,9 @@ export function EmailComposer({
) : ( ) : (
<Paperclip className="w-3 h-3 flex-shrink-0" /> <Paperclip className="w-3 h-3 flex-shrink-0" />
)} )}
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.file.name}</span> <span className="max-w-[150px] md:max-w-[200px] truncate">{att.name}</span>
<span className="text-xs text-muted-foreground whitespace-nowrap"> <span className="text-xs text-muted-foreground whitespace-nowrap">
({formatFileSize(att.file.size)}) ({formatFileSize(att.size)})
</span> </span>
<button <button
onClick={() => removeAttachment(index)} onClick={() => removeAttachment(index)}
+6 -2
View File
@@ -66,6 +66,7 @@ interface EmailContextMenuProps {
// Batch actions // Batch actions
onBatchMarkAsRead?: (read: boolean) => void; onBatchMarkAsRead?: (read: boolean) => void;
onBatchDelete?: () => void; onBatchDelete?: () => void;
onBatchArchive?: () => void;
onBatchMoveToMailbox?: (mailboxId: string) => void; onBatchMoveToMailbox?: (mailboxId: string) => void;
onBatchMarkAsSpam?: () => void; onBatchMarkAsSpam?: () => void;
onBatchUndoSpam?: () => void; onBatchUndoSpam?: () => void;
@@ -127,6 +128,7 @@ export function EmailContextMenu({
onUndoSpam, onUndoSpam,
onBatchMarkAsRead, onBatchMarkAsRead,
onBatchDelete, onBatchDelete,
onBatchArchive,
onBatchMoveToMailbox, onBatchMoveToMailbox,
onBatchMarkAsSpam, onBatchMarkAsSpam,
onBatchUndoSpam, onBatchUndoSpam,
@@ -235,8 +237,10 @@ export function EmailContextMenu({
<ContextMenuItem <ContextMenuItem
icon={Archive} icon={Archive}
label={t("archive")} label={t("archive")}
onClick={() => handleAction(onArchive!)} onClick={() =>
disabled={!onArchive} handleAction(showBatchActions ? onBatchArchive! : onArchive!)
}
disabled={showBatchActions ? !onBatchArchive : !onArchive}
/> />
{/* Delete */} {/* Delete */}
+2 -2
View File
@@ -51,9 +51,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tags using keyword definitions from settings // Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
const colorTagIds = getEmailColorTags(email.keywords); const colorTagIds = getEmailColorTags(email.keywords);
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords; const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
// Use first tag for background coloring // Use first tag for background coloring
const keywordDef = keywordDefs[0] ?? null; 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;
+9 -1
View File
@@ -176,7 +176,7 @@ export function EmailList({
setIsProcessing(true); setIsProcessing(true);
try { try {
await batchDelete(client); await batchDelete(client, isInTrash);
} finally { } finally {
setTimeout(() => setIsProcessing(false), 500); setTimeout(() => setIsProcessing(false), 500);
} }
@@ -496,6 +496,14 @@ export function EmailList({
onEditDraft={() => onEditDraft?.(contextMenu.data!)} onEditDraft={() => onEditDraft?.(contextMenu.data!)}
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)} onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)} onBatchDelete={() => client && batchDelete(client)}
onBatchArchive={async () => {
if (!onArchive) return;
const selected = emails.filter((e) => selectedEmailIds.has(e.id));
for (const email of selected) {
await onArchive(email);
}
clearSelection();
}}
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)} onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
onBatchMarkAsSpam={async () => { onBatchMarkAsSpam={async () => {
if (client) { if (client) {
+62 -75
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } fr
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization"; import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
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";
@@ -85,7 +85,7 @@ import { UnsubscribeBanner } from "./unsubscribe-banner";
import { CalendarInvitationBanner } from "./calendar-invitation-banner"; import { CalendarInvitationBanner } from "./calendar-invitation-banner";
import { useTour } from "@/components/tour/tour-provider"; import { useTour } from "@/components/tour/tour-provider";
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog"; import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
import { findCalendarAttachment } from "@/lib/calendar-invitation"; import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
import { RecipientPopover } from "./recipient-popover"; import { RecipientPopover } from "./recipient-popover";
import { isFilePreviewable } from "@/lib/file-preview"; import { isFilePreviewable } from "@/lib/file-preview";
import { SmimeStatusBanner } from "./smime-status-banner"; import { SmimeStatusBanner } from "./smime-status-banner";
@@ -740,7 +740,7 @@ function ContactSidebarPanel({
<div key={i} className="text-sm"> <div key={i} className="text-sm">
{o.name} {o.name}
{o.units && o.units.length > 0 && ( {o.units && o.units.length > 0 && (
<span className="text-muted-foreground"> {o.units.map(u => u.name).join(", ")}</span> <span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
)} )}
</div> </div>
))} ))}
@@ -896,6 +896,7 @@ export function EmailViewer({
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled); const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
const timeFormat = useSettingsStore((state) => state.timeFormat); const timeFormat = useSettingsStore((state) => state.timeFormat);
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
@@ -1170,7 +1171,7 @@ export function EmailViewer({
return; return;
} }
// Already read record that so manual unread toggle won't re-trigger auto-mark // Already read - record that so manual unread toggle won't re-trigger auto-mark
if (email.keywords?.$seen) { if (email.keywords?.$seen) {
autoMarkedEmailRef.current = email.id; autoMarkedEmailRef.current = email.id;
return; return;
@@ -1644,7 +1645,7 @@ export function EmailViewer({
// Check if inner content is also signed // Check if inner content is also signed
const nestedSignedData = extractNestedSignedDataCandidate(parsed, result.mimeBytes); const nestedSignedData = extractNestedSignedDataCandidate(parsed, result.mimeBytes);
if (nestedSignedData) { if (nestedSignedData) {
// Nested sign-then-encrypt verify inner signature // Nested sign-then-encrypt - verify inner signature
const innerBytes = normalizeCmsBytes(nestedSignedData.bytes); const innerBytes = normalizeCmsBytes(nestedSignedData.bytes);
smimeDebug('[S/MIME] nested signed-data candidate:', { smimeDebug('[S/MIME] nested signed-data candidate:', {
source: nestedSignedData.source, source: nestedSignedData.source,
@@ -1853,7 +1854,7 @@ export function EmailViewer({
// 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
// boilerplate (CSS + &nbsp;) treat these as effectively empty. // boilerplate (CSS + &nbsp;) - treat these as effectively empty.
const htmlPartId = email.htmlBody?.[0]?.partId; const htmlPartId = email.htmlBody?.[0]?.partId;
const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : ''; const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : '';
let hasRealHtmlBody = !!htmlValue; let hasRealHtmlBody = !!htmlValue;
@@ -1898,7 +1899,7 @@ export function EmailViewer({
return; return;
} }
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); 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);
@@ -1912,7 +1913,7 @@ export function EmailViewer({
} }
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) { if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
debug.warn('email', '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();
@@ -1975,7 +1976,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('email', '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);
@@ -2074,21 +2075,30 @@ export function EmailViewer({
const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => { const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
if (smimeDecryptedAttachments.length > 0) { if (smimeDecryptedAttachments.length > 0) {
return smimeDecryptedAttachments.map((attachment, index) => ({ return smimeDecryptedAttachments
id: `smime-${index}-${attachment.filename || attachment.mimeType}`, .filter(att => !(hideInlineImageAttachments && att.contentId && (att.mimeType || '').startsWith('image/')))
name: attachment.filename, .map((attachment, index) => ({
type: attachment.mimeType || 'application/octet-stream', id: `smime-${index}-${attachment.filename || attachment.mimeType}`,
size: getPostalMimeAttachmentSize(attachment), name: attachment.filename,
cid: attachment.contentId, type: attachment.mimeType || 'application/octet-stream',
decryptedAttachment: attachment, size: getPostalMimeAttachmentSize(attachment),
})); cid: attachment.contentId,
decryptedAttachment: attachment,
}));
} }
const hasCalInvitation = calendarInvitationParsingEnabled && !!email && !!findCalendarAttachment(email);
const jmapAttachments = (email?.attachments ?? []) const jmapAttachments = (email?.attachments ?? [])
// Hide winmail.dat when we have successfully extracted TNEF content or attachments // Hide winmail.dat when we have successfully extracted TNEF content or attachments
.filter(att => !(tnefHtml || tnefText || tnefAttachments.length > 0) || !isTnefAttachment(att.name, att.type)) .filter(att => !(tnefHtml || tnefText || tnefAttachments.length > 0) || !isTnefAttachment(att.name, att.type))
// Hide message/rfc822 when we have unwrapped the embedded email // Hide message/rfc822 when we have unwrapped the embedded email
.filter(att => !embeddedEmailUnwrapped || att.type !== 'message/rfc822') .filter(att => !embeddedEmailUnwrapped || att.type !== 'message/rfc822')
// Hide calendar MIME parts (text/calendar, application/ics) when the invitation
// banner is shown - prevents raw ICS files appearing as spurious attachments.
.filter(att => !hasCalInvitation || !isCalendarMimeType(att.type))
// Hide inline cid-referenced images when the user has opted to keep them
// out of the attachment list (default on): these are embedded in the body.
.filter(att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
.map((attachment, index) => ({ .map((attachment, index) => ({
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`, id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
name: attachment.name || null, name: attachment.name || null,
@@ -2119,7 +2129,7 @@ export function EmailViewer({
})); }));
return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted]; return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted];
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments]); }, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments, calendarInvitationParsingEnabled, hideInlineImageAttachments]);
// Generate email source for viewing // Generate email source for viewing
const generateEmailSource = (email: Email): string => { const generateEmailSource = (email: Email): string => {
@@ -2393,16 +2403,8 @@ export function EmailViewer({
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) { if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
const textContent = email.bodyValues[email.textBody[0].partId].value; const textContent = email.bodyValues[email.textBody[0].partId].value;
// Convert plain text to HTML with proper formatting
// Uses white-space: pre-wrap on the container to preserve newlines/whitespace
const htmlFromText = textContent
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
return { return {
html: htmlFromText, html: plainTextToSafeHtml(textContent),
isHtml: false isHtml: false
}; };
} }
@@ -2440,12 +2442,7 @@ export function EmailViewer({
return { html: cleanHtml, isHtml: true }; return { html: cleanHtml, isHtml: true };
} }
if (smimeDecryptedText) { if (smimeDecryptedText) {
const htmlFromText = smimeDecryptedText return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false };
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
return { html: htmlFromText, isHtml: false };
} }
// TNEF (winmail.dat) extracted content // TNEF (winmail.dat) extracted content
if (tnefHtml) { if (tnefHtml) {
@@ -2453,12 +2450,7 @@ export function EmailViewer({
return { html: cleanHtml, isHtml: true }; return { html: cleanHtml, isHtml: true };
} }
if (tnefText) { if (tnefText) {
const htmlFromText = tnefText return { html: plainTextToSafeHtml(tnefText), isHtml: false };
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
return { html: htmlFromText, isHtml: false };
} }
// Embedded message/rfc822 unwrapped content // Embedded message/rfc822 unwrapped content
if (embeddedEmailHtml) { if (embeddedEmailHtml) {
@@ -2466,12 +2458,7 @@ export function EmailViewer({
return { html: cleanHtml, isHtml: true }; return { html: cleanHtml, isHtml: true };
} }
if (embeddedEmailText) { if (embeddedEmailText) {
const htmlFromText = embeddedEmailText return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false };
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
return { html: htmlFromText, isHtml: false };
} }
return emailContent; return emailContent;
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]); }, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
@@ -2957,7 +2944,7 @@ export function EmailViewer({
<PluginSlot name="toolbar-actions" /> <PluginSlot name="toolbar-actions" />
</div> </div>
{/* Right: Organize actions order: archive, delete, move, star, tag, spam, read state, print, view source */} {/* Right: Organize actions - order: archive, delete, move, star, tag, spam, read state, print, view source */}
<div className="flex items-center gap-0 sm:gap-0.5"> <div className="flex items-center gap-0 sm:gap-0.5">
{isLoading && ( {isLoading && (
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground"> <div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
@@ -3054,7 +3041,7 @@ export function EmailViewer({
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span> <span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
</Button> </Button>
{/* Tag Picker hidden on mobile, overflows to More menu */} {/* Tag Picker - hidden on mobile, overflows to More menu */}
<div data-overflow-item data-overflow-priority="6" className="hidden sm:flex items-center"> <div data-overflow-item data-overflow-priority="6" className="hidden sm:flex items-center">
<div className="w-px h-5 bg-border mx-0.5" /> <div className="w-px h-5 bg-border mx-0.5" />
<div ref={tagMenuRef} className="relative"> <div ref={tagMenuRef} className="relative">
@@ -3070,13 +3057,13 @@ export function EmailViewer({
<> <>
<span className="flex items-center gap-0.5"> <span className="flex items-center gap-0.5">
{currentColors.slice(0, 3).map((tagId) => { {currentColors.slice(0, 3).map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId); const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
return kw ? <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot)} /> : null; return <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500')} />;
})} })}
</span> </span>
{showToolbarLabels && currentColors.length === 1 && ( {showToolbarLabels && currentColors.length === 1 && (
<span className="text-xs font-medium text-foreground"> <span className="text-xs font-medium text-foreground">
{emailKeywords.find(k => k.id === currentColors[0])?.label} {emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
</span> </span>
)} )}
</> </>
@@ -3123,7 +3110,7 @@ export function EmailViewer({
</div> </div>
</div> </div>
{/* Spam hidden on mobile, overflows to More menu */} {/* Spam - hidden on mobile, overflows to More menu */}
{(onMarkAsSpam || onUndoSpam) && ( {(onMarkAsSpam || onUndoSpam) && (
<Button <Button
variant="ghost" variant="ghost"
@@ -3145,7 +3132,7 @@ export function EmailViewer({
</Button> </Button>
)} )}
{/* Toggle read state hidden on mobile, overflows to More menu */} {/* Toggle read state - hidden on mobile, overflows to More menu */}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -3158,7 +3145,7 @@ export function EmailViewer({
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />} {isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
</Button> </Button>
{/* Print hidden on mobile, overflows to More menu */} {/* Print - hidden on mobile, overflows to More menu */}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -3172,7 +3159,7 @@ export function EmailViewer({
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('print')}</span>} {showToolbarLabels && <span className="hidden sm:inline text-sm">{t('print')}</span>}
</Button> </Button>
{/* View source hidden on mobile, overflows to More menu */} {/* View source - hidden on mobile, overflows to More menu */}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -3200,7 +3187,7 @@ export function EmailViewer({
</Button> </Button>
)} )}
{/* More menu click-based */} {/* More menu - click-based */}
<div ref={moreMenuRef} className="relative"> <div ref={moreMenuRef} className="relative">
<Button <Button
variant="ghost" variant="ghost"
@@ -3246,7 +3233,7 @@ export function EmailViewer({
<Archive className="w-4 h-4" /> <Archive className="w-4 h-4" />
{t('archive')} {t('archive')}
</button> </button>
{/* Overflow: move to folder submenu */} {/* Overflow: move to folder - submenu */}
{moveTree.length > 0 && onMoveToMailbox && ( {moveTree.length > 0 && onMoveToMailbox && (
<div className={cn("relative", hiddenPriorities.has(5) ? "" : "sm:hidden")} <div className={cn("relative", hiddenPriorities.has(5) ? "" : "sm:hidden")}
onMouseEnter={() => setMoreMenuSub('move')} onMouseEnter={() => setMoreMenuSub('move')}
@@ -3298,7 +3285,7 @@ export function EmailViewer({
)} )}
</div> </div>
)} )}
{/* Overflow: tag submenu */} {/* Overflow: tag - submenu */}
{colorOptions.length > 0 && ( {colorOptions.length > 0 && (
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")} <div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
onMouseEnter={() => setMoreMenuSub('tag')} onMouseEnter={() => setMoreMenuSub('tag')}
@@ -3674,11 +3661,11 @@ export function EmailViewer({
{currentColors.length > 0 && ( {currentColors.length > 0 && (
<span className="flex items-center gap-0.5"> <span className="flex items-center gap-0.5">
{currentColors.map((tagId) => { {currentColors.map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId); const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
return dotClass ? ( return (
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} /> <span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw.label} />
) : null; );
})} })}
</span> </span>
)} )}
@@ -4035,7 +4022,7 @@ export function EmailViewer({
<div className="flex-1"> <div className="flex-1">
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1"> <div className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
Spam Score Spam Score
<InfoTooltip text="A score assigned by the server based on spam analysis. Lower is better scores above 5 are likely spam" /> <InfoTooltip text="A score assigned by the server based on spam analysis. Lower is better - scores above 5 are likely spam" />
</div> </div>
<div className={cn( <div className={cn(
"text-xs", "text-xs",
@@ -4787,61 +4774,61 @@ export function EmailViewer({
onClick={onNavigatePrev} onClick={onNavigatePrev}
disabled={!onNavigatePrev} disabled={!onNavigatePrev}
className={cn( className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150", "flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150",
onNavigatePrev ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30" onNavigatePrev ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
)} )}
aria-label={t('tooltips.previous')} aria-label={t('tooltips.previous')}
> >
<ChevronLeft className="w-5 h-5" /> <ChevronLeft className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('previous')}</span> <span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('previous')}</span>
</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-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary active:text-primary/80 transition-colors duration-150"
aria-label={t('tooltips.edit_draft')} aria-label={t('tooltips.edit_draft')}
> >
<EditIcon className="w-5 h-5" /> <EditIcon className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('edit_draft')}</span> <span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('edit_draft')}</span>
</button> </button>
) : ( ) : (
<> <>
<button <button
onClick={() => onReply?.()} onClick={() => onReply?.()}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150" className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.reply')} aria-label={t('tooltips.reply')}
> >
<Reply className="w-5 h-5" /> <Reply className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('reply')}</span> <span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('reply')}</span>
</button> </button>
<button <button
onClick={onReplyAll} onClick={onReplyAll}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150" className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.reply_all')} aria-label={t('tooltips.reply_all')}
> >
<ReplyAll className="w-5 h-5" /> <ReplyAll className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('reply_all')}</span> <span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('reply_all')}</span>
</button> </button>
<button <button
onClick={onForward} onClick={onForward}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150" className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.forward')} aria-label={t('tooltips.forward')}
> >
<Forward className="w-5 h-5" /> <Forward className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('forward')}</span> <span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('forward')}</span>
</button> </button>
</>)} </>)}
<button <button
onClick={onNavigateNext} onClick={onNavigateNext}
disabled={!onNavigateNext} disabled={!onNavigateNext}
className={cn( className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150", "flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150",
onNavigateNext ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30" onNavigateNext ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
)} )}
aria-label={t('tooltips.next')} aria-label={t('tooltips.next')}
> >
<ChevronRight className="w-5 h-5" /> <ChevronRight className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('next')}</span> <span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('next')}</span>
</button> </button>
</div> </div>
</nav> </nav>
+1 -1
View File
@@ -14,7 +14,7 @@ interface RecipientPopoverProps {
email: string; email: string;
/** Display label override (e.g. "me") */ /** Display label override (e.g. "me") */
displayLabel?: string; displayLabel?: string;
/** Called when user clicks "View contact" receives the contact and email */ /** Called when user clicks "View contact" - receives the contact and email */
onViewContact?: (contact: ContactCard | null, email: string) => void; onViewContact?: (contact: ContactCard | null, email: string) => void;
className?: string; className?: string;
} }
+5
View File
@@ -113,6 +113,11 @@ export const ResizableImage = Node.create({
alt: { default: null }, alt: { default: null },
title: { default: null }, title: { default: null },
width: { default: null }, width: { default: null },
cid: {
default: null,
parseHTML: (el) => el.getAttribute("data-cid"),
renderHTML: (attrs) => (attrs.cid ? { "data-cid": attrs.cid } : {}),
},
}; };
}, },
+12 -7
View File
@@ -31,10 +31,15 @@ import {
Heading2, Heading2,
} from "lucide-react"; } from "lucide-react";
export interface InlineImageUpload {
src: string;
cid?: string;
}
interface RichTextEditorProps { interface RichTextEditorProps {
content: string; content: string;
onChange: (html: string) => void; onChange: (html: string) => void;
onImageUpload?: (file: File) => Promise<string | null>; onImageUpload?: (file: File) => Promise<InlineImageUpload | null>;
placeholder?: string; placeholder?: string;
className?: string; className?: string;
hasError?: boolean; hasError?: boolean;
@@ -120,11 +125,11 @@ export function RichTextEditor({
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
for (const file of imageFiles) { for (const file of imageFiles) {
upload(file).then((url) => { upload(file).then((result) => {
if (url) { if (result) {
const { state } = view; const { state } = view;
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY }); const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
const node = state.schema.nodes.image.create({ src: url, alt: file.name }); const node = state.schema.nodes.image.create({ src: result.src, alt: file.name, cid: result.cid });
const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node); const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node);
view.dispatch(tr); view.dispatch(tr);
} }
@@ -141,10 +146,10 @@ export function RichTextEditor({
if (imageFiles.length === 0) return false; if (imageFiles.length === 0) return false;
event.preventDefault(); event.preventDefault();
for (const file of imageFiles) { for (const file of imageFiles) {
upload(file).then((url) => { upload(file).then((result) => {
if (url) { if (result) {
const { state } = view; const { state } = view;
const node = state.schema.nodes.image.create({ src: url, alt: file.name }); const node = state.schema.nodes.image.create({ src: result.src, alt: file.name, cid: result.cid });
const tr = state.tr.replaceSelectionWith(node); const tr = state.tr.replaceSelectionWith(node);
view.dispatch(tr); view.dispatch(tr);
} }
+11 -10
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types"; import { Email, ThreadGroup } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization"; import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform"; import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
import { useThemeStore } from "@/stores/theme-store"; import { useThemeStore } from "@/stores/theme-store";
@@ -237,6 +237,7 @@ function EmailCard({
const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction); const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode); const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode);
const sender = email.from?.[0]; const sender = email.from?.[0];
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
@@ -419,12 +420,7 @@ function EmailCard({
// Plain text fallback // Plain text fallback
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) { if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
const text = email.bodyValues[email.textBody[0].partId].value; const text = email.bodyValues[email.textBody[0].partId].value;
const htmlEscaped = text return { html: plainTextToSafeHtml(text, 'text-primary hover:underline'), isHtml: false };
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">$1</a>');
return { html: htmlEscaped, isHtml: false };
} }
} }
@@ -549,10 +545,14 @@ function EmailCard({
</div> </div>
{/* Attachments */} {/* Attachments */}
{email.attachments && email.attachments.length > 0 && ( {(() => {
const visibleAttachments = (email.attachments ?? []).filter(
att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/'))
);
return visibleAttachments.length > 0 && (
<div className="px-4 pb-4"> <div className="px-4 pb-4">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{email.attachments.map((attachment, idx) => { {visibleAttachments.map((attachment, idx) => {
const Icon = getFileIcon(attachment.name, attachment.type); const Icon = getFileIcon(attachment.name, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name, attachment.type); const isPreviewable = isFilePreviewable(attachment.name, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
@@ -581,7 +581,8 @@ function EmailCard({
})} })}
</div> </div>
</div> </div>
)} );
})()}
{/* Action Buttons */} {/* Action Buttons */}
<div className="px-4 pb-4 flex gap-2"> <div className="px-4 pb-4 flex gap-2">
+38 -4
View File
@@ -9,6 +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 { useAccountStore } from "@/stores/account-store";
import { getThreadColorTag, getEmailColorTags } 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";
@@ -63,13 +64,16 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
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 mailLayout = useSettingsStore((state) => state.mailLayout);
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
const getAccountById = useAccountStore((state) => state.getAccountById);
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
const isChecked = selectedEmailIds.has(email.id); const isChecked = selectedEmailIds.has(email.id);
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tags using keyword definitions // Resolve color tags using keyword definitions; unknown tags fall back to gray
const tagIds = getEmailColorTags(email.keywords); const tagIds = getEmailColorTags(email.keywords);
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords; const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null; const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
const resolvedColorTag = (() => { const resolvedColorTag = (() => {
if (colorTag) return colorTag; if (colorTag) return colorTag;
@@ -184,6 +188,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{isFocusedMailLayout ? ( {isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3"> <div className="flex min-w-0 flex-1 items-center gap-3">
{isUnifiedView && email.accountId && accountColor && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: accountColor }}
title={email.accountLabel}
/>
)}
<span className={cn( <span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-40', 'w-32 shrink-0 truncate text-sm lg:w-40',
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80' isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
@@ -228,6 +239,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
<> <>
<div className="flex items-center justify-between gap-2 mb-1"> <div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
{isUnifiedView && email.accountId && accountColor && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: accountColor }}
title={email.accountLabel}
/>
)}
<span className={cn( <span className={cn(
"truncate text-sm", "truncate text-sm",
isUnread isUnread
@@ -345,7 +363,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : ''; const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
const getAccountById = useAccountStore((state) => state.getAccountById);
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
// In Sent/Drafts folders, show recipient instead of sender (which is always "me") // In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role; const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
@@ -375,7 +395,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const threadColor = getThreadColorTag(thread.emails); const threadColor = getThreadColorTag(thread.emails);
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords); const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
const keywordDef = threadColor ? emailKeywordDefs.find(k => k.id === threadColor) : null; const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
const isSelected = selectedEmailId === latestEmail.id || const isSelected = selectedEmailId === latestEmail.id ||
@@ -548,6 +568,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{isFocusedMailLayout ? ( {isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3"> <div className="flex min-w-0 flex-1 items-center gap-3">
{isUnifiedView && latestEmail.accountId && threadAccountColor && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: threadAccountColor }}
title={latestEmail.accountLabel}
/>
)}
<span className={cn( <span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-44', 'w-32 shrink-0 truncate text-sm lg:w-44',
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80' hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
@@ -602,6 +629,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
<> <>
<div className="flex items-center justify-between gap-2 mb-1"> <div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
{isUnifiedView && latestEmail.accountId && threadAccountColor && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: threadAccountColor }}
title={latestEmail.accountLabel}
/>
)}
<span className={cn( <span className={cn(
"truncate text-sm", "truncate text-sm",
hasUnread hasUnread
+2 -2
View File
@@ -412,7 +412,7 @@ export function FileBrowser({
// Filter and sort resources // Filter and sort resources
const displayResources = useMemo(() => { const displayResources = useMemo(() => {
let filtered = resources; let filtered = resources;
// In sidebar mode, folders are shown in the sidebar tree hide them from the main list // In sidebar mode, folders are shown in the sidebar tree - hide them from the main list
if (folderLayout === "sidebar") { if (folderLayout === "sidebar") {
filtered = filtered.filter(r => !r.isDirectory); filtered = filtered.filter(r => !r.isDirectory);
} }
@@ -1466,7 +1466,7 @@ export function FileBrowser({
</div> </div>
</td> </td>
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell tabular-nums"> <td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell tabular-nums">
{resource.isDirectory ? "" : formatFileSize(resource.contentLength)} {resource.isDirectory ? "-" : formatFileSize(resource.contentLength)}
</td> </td>
<td className="px-4 py-2.5 text-muted-foreground hidden lg:table-cell tabular-nums"> <td className="px-4 py-2.5 text-muted-foreground hidden lg:table-cell tabular-nums">
{formatDate(resource.lastModified)} {formatDate(resource.lastModified)}
+1 -1
View File
@@ -223,7 +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" sandbox="allow-scripts"
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}
/> />
+1 -1
View File
@@ -58,7 +58,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
}; };
}); });
// Don't cache empty root results empty root likely means client wasn't ready yet // Don't cache empty root results - empty root likely means client wasn't ready yet
if (folders.length > 0 || parentId !== null) { if (folders.length > 0 || parentId !== null) {
setChildrenCache(prev => new Map(prev).set(cacheKey, folders)); setChildrenCache(prev => new Map(prev).set(cacheKey, folders));
} }
+11 -4
View File
@@ -17,6 +17,7 @@ import type {
} 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, generateUUID } from "@/lib/utils"; import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
interface FilterRuleModalProps { interface FilterRuleModalProps {
rule?: FilterRule; rule?: FilterRule;
@@ -58,6 +59,7 @@ export function FilterRuleModal({
}: FilterRuleModalProps) { }: FilterRuleModalProps) {
const t = useTranslations("settings.filters"); const t = useTranslations("settings.filters");
const isEdit = !!rule; const isEdit = !!rule;
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const [name, setName] = useState(rule?.name || ""); const [name, setName] = useState(rule?.name || "");
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all"); const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
@@ -375,12 +377,17 @@ export function FilterRuleModal({
)} )}
{action.type === "add_label" && ( {action.type === "add_label" && (
<Input <select
value={action.value || ""} value={action.value || ""}
onChange={(e) => updateAction(index, { value: e.target.value })} onChange={(e) => updateAction(index, { value: e.target.value })}
placeholder={t("label_placeholder")} className={`${selectClass} flex-1 min-w-[140px]`}
className="flex-1 min-w-[140px]" aria-label={t("label_placeholder")}
/> >
<option value="">{t("label_placeholder")}</option>
{emailKeywords.map((kw) => (
<option key={kw.id} value={kw.id}>{kw.label}</option>
))}
</select>
)} )}
<button <button
+27 -14
View File
@@ -7,7 +7,7 @@ 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";
import { useThemeStore } from "@/stores/theme-store"; import { useThemeStore } from "@/stores/theme-store";
import { usePathname, Link } from "@/i18n/navigation"; import { usePathname, Link, useRouter } from "@/i18n/navigation";
import NextLink from "next/link"; import NextLink from "next/link";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
@@ -18,10 +18,11 @@ 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 { useAccountStore } from "@/stores/account-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot"; import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getInitials } from "@/lib/account-utils"; import { getInitials, MAX_ACCOUNTS } 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"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { apiFetch } from "@/lib/browser-navigation";
interface NavItem { interface NavItem {
id: string; id: string;
@@ -162,6 +163,7 @@ export function NavigationRail({
}: NavigationRailProps) { }: NavigationRailProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter();
const { appLogoLightUrl, appLogoDarkUrl } = useConfig(); const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const { supportsCalendar } = useCalendarStore(); const { supportsCalendar } = useCalendarStore();
@@ -222,13 +224,14 @@ export function NavigationRail({
let cancelled = false; let cancelled = false;
const headers = getActiveAccountSlotHeaders(); const headers = getActiveAccountSlotHeaders();
if (!headers['X-JMAP-Cookie-Slot']) return; if (!headers['X-JMAP-Cookie-Slot']) return;
fetch('/api/admin/stalwart-check', { headers }) apiFetch('/api/admin/auth', { headers })
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
if (!cancelled && data.isStalwartAdmin) { if (cancelled || !data.stalwartAdmin) return;
setIsStalwartAdmin(true); setIsStalwartAdmin(true);
if (!data.authenticated) {
// Pre-create admin session so /admin works even after full page navigation // Pre-create admin session so /admin works even after full page navigation
fetch('/api/admin/auth', { apiFetch('/api/admin/auth', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers }, headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ stalwartAuth: true }), body: JSON.stringify({ stalwartAuth: true }),
@@ -274,7 +277,7 @@ export function NavigationRail({
href={item.href} href={item.href}
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined} onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
className={cn( className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0", "flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150", "transition-colors duration-150",
isActive isActive
? "text-primary" ? "text-primary"
@@ -293,7 +296,7 @@ export function NavigationRail({
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" /> <span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
)} )}
</div> </div>
<span className="text-[10px] font-medium leading-tight">{t(item.labelKey)}</span> <span className="text-[10px] font-medium leading-tight truncate max-w-full">{t(item.labelKey)}</span>
</Link> </Link>
); );
})} })}
@@ -315,7 +318,7 @@ export function NavigationRail({
} }
}} }}
className={cn( className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0", "flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150", "transition-colors duration-150",
isActive isActive
? "text-primary" ? "text-primary"
@@ -328,7 +331,7 @@ export function NavigationRail({
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" /> <span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
)} )}
</div> </div>
<span className="text-[10px] font-medium leading-tight truncate max-w-[64px]">{app.name}</span> <span className="text-[10px] font-medium leading-tight truncate max-w-full">{app.name}</span>
</button> </button>
); );
})} })}
@@ -338,13 +341,13 @@ export function NavigationRail({
<NextLink <NextLink
href="/admin" href="/admin"
className={cn( className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0", "flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150", "transition-colors duration-150",
"text-muted-foreground hover:text-foreground" "text-muted-foreground hover:text-foreground"
)} )}
> >
<Shield className="w-5 h-5" /> <Shield className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t("admin") || "Admin"}</span> <span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
</NextLink> </NextLink>
)} )}
@@ -353,7 +356,7 @@ export function NavigationRail({
href="/settings" href="/settings"
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined} onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
className={cn( className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0", "flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150", "transition-colors duration-150",
isSettingsActive isSettingsActive
? "text-primary" ? "text-primary"
@@ -367,7 +370,7 @@ export function NavigationRail({
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" /> <span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
)} )}
</div> </div>
<span className="text-[10px] font-medium leading-tight">{t("settings")}</span> <span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("settings")}</span>
</Link> </Link>
</nav> </nav>
); );
@@ -604,6 +607,16 @@ export function NavigationRail({
</button> </button>
); );
})} })}
{accounts.length < MAX_ACCOUNTS && (
<button
onClick={() => router.push(`/login?mode=add-account` as never)}
className="flex items-center justify-center w-8 h-8 rounded-full border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-foreground hover:text-foreground hover:bg-muted transition-colors flex-shrink-0"
title={t("add_account")}
aria-label={t("add_account")}
>
<Plus className="w-3.5 h-3.5" />
</button>
)}
</div> </div>
{/* Logout button with popover */} {/* Logout button with popover */}
+1 -1
View File
@@ -155,7 +155,7 @@ function SidebarAppForm({
{t('icon_label')} <span className="text-destructive">*</span> {t('icon_label')} <span className="text-destructive">*</span>
{SelectedIcon && ( {SelectedIcon && (
<span className="inline-flex items-center gap-1.5 ml-2 text-muted-foreground font-normal"> <span className="inline-flex items-center gap-1.5 ml-2 text-muted-foreground font-normal">
<SelectedIcon className="w-4 h-4" /> {formData.icon} - <SelectedIcon className="w-4 h-4" /> {formData.icon}
</span> </span>
)} )}
</label> </label>
File diff suppressed because it is too large Load Diff
+2
View File
@@ -15,6 +15,7 @@ import nlMessages from '@/locales/nl/common.json';
import plMessages from '@/locales/pl/common.json'; import plMessages from '@/locales/pl/common.json';
import ptMessages from '@/locales/pt/common.json'; import ptMessages from '@/locales/pt/common.json';
import ruMessages from '@/locales/ru/common.json'; import ruMessages from '@/locales/ru/common.json';
import ukMessages from '@/locales/uk/common.json';
import zhMessages from '@/locales/zh/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)
@@ -31,6 +32,7 @@ const ALL_MESSAGES = {
pl: plMessages, pl: plMessages,
pt: ptMessages, pt: ptMessages,
ru: ruMessages, ru: ruMessages,
uk: ukMessages,
zh: zhMessages, zh: zhMessages,
}; };
+21 -2
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { X, Download } from "lucide-react"; import { X, Download } from "lucide-react";
import { useConfig } from "@/hooks/use-config";
interface BeforeInstallPromptEvent extends Event { interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>; prompt: () => Promise<void>;
@@ -14,6 +15,7 @@ export function PWAInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null); useState<BeforeInstallPromptEvent | null>(null);
const [showPrompt, setShowPrompt] = useState(false); const [showPrompt, setShowPrompt] = useState(false);
const { appName, faviconUrl, appLogoLightUrl, appLogoDarkUrl } = useConfig();
useEffect(() => { useEffect(() => {
if (localStorage.getItem(DISMISSED_KEY)) return; if (localStorage.getItem(DISMISSED_KEY)) return;
@@ -56,14 +58,31 @@ export function PWAInstallPrompt() {
return null; return null;
} }
const logoSrc = appLogoLightUrl || faviconUrl;
return ( 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="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 justify-between mb-3">
<div className="flex items-start gap-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" /> {logoSrc ? (
<img
src={logoSrc}
alt={appName}
className="w-8 h-8 shrink-0 object-contain dark:hidden"
/>
) : (
<Download className="w-5 h-5 mt-0.5 text-blue-600 dark:text-blue-400 shrink-0" />
)}
{logoSrc && (
<img
src={appLogoDarkUrl || faviconUrl}
alt={appName}
className="w-8 h-8 shrink-0 object-contain hidden dark:block"
/>
)}
<div> <div>
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white"> <h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
Install Bulwark Install {appName}
</h3> </h3>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1"> <p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
Install our app for quick access and offline support. Install our app for quick access and offline support.
+300 -150
View File
@@ -1,12 +1,14 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor } from 'lucide-react'; import QRCode from 'qrcode';
import * as OTPAuth from 'otpauth';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor, Terminal } from 'lucide-react';
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 { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { useAccountSecurityStore } from '@/stores/account-security-store'; import { useAccountSecurityStore, type AppPasswordInfo, type ApiKeyInfo, type AppCredentialInput } from '@/stores/account-security-store';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { toast } from '@/stores/toast-store'; import { toast } from '@/stores/toast-store';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -172,37 +174,95 @@ function DisplayNameSection() {
); );
} }
function generateTotp(accountLabel: string): { totp: OTPAuth.TOTP; url: string } {
const totp = new OTPAuth.TOTP({
issuer: 'Stalwart',
label: accountLabel || 'account',
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: new OTPAuth.Secret({ size: 20 }),
});
return { totp, url: totp.toString() };
}
function TotpSection() { function TotpSection() {
const t = useTranslations('settings.security'); const t = useTranslations('settings.security');
const { otpEnabled, enableTotp, disableTotp, isSaving, isLoadingAuth } = useAccountSecurityStore(); const { otpEnabled, enableTotp, disableTotp, isSaving, isLoadingAuth } = useAccountSecurityStore();
const [totpUrl, setTotpUrl] = useState<string | null>(null); const { client } = useAuthStore();
const [copied, setCopied] = useState(false);
const [setupUrl, setSetupUrl] = useState<string | null>(null);
const [setupTotp, setSetupTotp] = useState<OTPAuth.TOTP | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [password, setPassword] = useState('');
const [otpCode, setOtpCode] = useState('');
const [setupError, setSetupError] = useState<string | null>(null);
const [disableOpen, setDisableOpen] = useState(false);
useEffect(() => {
if (!setupUrl) { setQrDataUrl(null); return; }
let cancelled = false;
QRCode.toDataURL(setupUrl, { width: 220, margin: 1 })
.then((url) => { if (!cancelled) setQrDataUrl(url); })
.catch(() => { /* ignore */ });
return () => { cancelled = true; };
}, [setupUrl]);
const startSetup = () => {
const { totp, url } = generateTotp(client?.getUsername() ?? 'account');
setSetupTotp(totp);
setSetupUrl(url);
setPassword('');
setOtpCode('');
setSetupError(null);
};
const cancelSetup = () => {
setSetupTotp(null);
setSetupUrl(null);
setPassword('');
setOtpCode('');
setSetupError(null);
};
const confirmSetup = async () => {
if (!setupTotp || !setupUrl) return;
if (!password) { setSetupError(t('totp.password_required')); return; }
if (!otpCode.trim()) { setSetupError(t('totp.code_required')); return; }
if (setupTotp.validate({ token: otpCode.trim(), window: 1 }) === null) {
setSetupError(t('totp.code_invalid'));
return;
}
const handleToggle = async (enable: boolean) => {
try { try {
if (enable) { await enableTotp(password, setupUrl, otpCode.trim());
const url = await enableTotp(); cancelSetup();
setTotpUrl(url); toast.success(t('totp.enabled'));
toast.success(t('totp.enabled'));
} else {
await disableTotp();
setTotpUrl(null);
toast.success(t('totp.disabled'));
}
} catch (err) { } catch (err) {
toast.error( setSetupError(err instanceof Error ? err.message : t('totp.enable_error'));
enable ? t('totp.enable_error') : t('totp.disable_error'),
err instanceof Error ? err.message : undefined
);
} }
}; };
const handleCopyUrl = () => { const handleDisable = async () => {
if (totpUrl) { if (!password) { setSetupError(t('totp.password_required')); return; }
navigator.clipboard.writeText(totpUrl).then(() => { try {
setCopied(true); await disableTotp(password);
setTimeout(() => setCopied(false), 2000); setDisableOpen(false);
}); setPassword('');
setSetupError(null);
toast.success(t('totp.disabled'));
} catch (err) {
setSetupError(err instanceof Error ? err.message : t('totp.disable_error'));
}
};
const handleToggle = (enable: boolean) => {
setSetupError(null);
if (enable) {
startSetup();
} else {
setDisableOpen(true);
setPassword('');
} }
}; };
@@ -218,30 +278,65 @@ function TotpSection() {
<div className="space-y-3"> <div className="space-y-3">
<SettingItem label={t('totp.label')} description={t('totp.description')}> <SettingItem label={t('totp.label')} description={t('totp.description')}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isSaving ? ( <ToggleSwitch
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" /> checked={otpEnabled || !!setupUrl}
) : ( onChange={handleToggle}
<ToggleSwitch disabled={isSaving}
checked={otpEnabled} />
onChange={handleToggle}
disabled={isSaving}
/>
)}
<span className={cn('text-xs font-medium', otpEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}> <span className={cn('text-xs font-medium', otpEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
{otpEnabled ? t('totp.active') : t('totp.inactive')} {otpEnabled ? t('totp.active') : t('totp.inactive')}
</span> </span>
</div> </div>
</SettingItem> </SettingItem>
{totpUrl && ( {setupUrl && (
<div className="ml-4 p-3 bg-muted rounded-md space-y-2"> <div className="ml-4 p-3 bg-muted rounded-md space-y-3">
<p className="text-xs text-muted-foreground">{t('totp.setup_instructions')}</p> <p className="text-xs text-muted-foreground">{t('totp.setup_instructions')}</p>
{qrDataUrl && (
<div className="flex justify-center">
<img src={qrDataUrl} alt="TOTP QR code" className="rounded bg-white p-2" />
</div>
)}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 truncate"> <code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 truncate">{setupUrl}</code>
{totpUrl} </div>
</code> <div>
<Button variant="outline" size="sm" onClick={handleCopyUrl}> <label className="text-xs text-muted-foreground mb-1 block">{t('password.current')}</label>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />} <Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('totp.verification_code')}</label>
<Input value={otpCode} onChange={(e) => setOtpCode(e.target.value)} inputMode="numeric" maxLength={6} />
</div>
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
<div className="flex gap-2">
<Button size="sm" onClick={confirmSetup} disabled={isSaving || !password || !otpCode}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t('totp.confirm')}
</Button>
<Button size="sm" variant="ghost" onClick={cancelSetup}>{t('app_passwords.cancel')}</Button>
</div>
</div>
)}
{disableOpen && (
<div className="ml-4 p-3 bg-muted rounded-md space-y-2">
<p className="text-xs text-muted-foreground">{t('totp.disable_confirm_prompt')}</p>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('password.current')}
autoComplete="current-password"
/>
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
<div className="flex gap-2">
<Button size="sm" variant="destructive" onClick={handleDisable} disabled={isSaving || !password}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t('totp.disable')}
</Button>
<Button size="sm" variant="ghost" onClick={() => { setDisableOpen(false); setPassword(''); setSetupError(null); }}>
{t('app_passwords.cancel')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -250,58 +345,113 @@ function TotpSection() {
); );
} }
function AppPasswordsSection() { function parseIpList(raw: string): string[] {
const t = useTranslations('settings.security'); return raw
const { appPasswords, addAppPassword, removeAppPassword, isSaving, isLoadingAuth } = useAccountSecurityStore(); .split(/[\s,]+/)
const [showAdd, setShowAdd] = useState(false); .map((s) => s.trim())
const [newName, setNewName] = useState(''); .filter(Boolean);
const [newPassword, setNewPassword] = useState(''); }
const [showPassword, setShowPassword] = useState(false);
const generatePassword = useCallback(() => { function CredentialRow({ entry, onRemove, isSaving }: { entry: AppPasswordInfo | ApiKeyInfo; onRemove: (id: string) => void; isSaving: boolean }) {
const chars = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'; return (
let result = ''; <div className="flex items-start justify-between py-2 px-3 bg-muted/50 rounded-md gap-2">
const array = new Uint8Array(24); <div className="flex flex-col min-w-0 flex-1">
crypto.getRandomValues(array); <span className="text-sm text-foreground truncate">{entry.description || entry.id}</span>
for (const byte of array) { {entry.createdAt && (
result += chars[byte % chars.length]; <span className="text-xs text-muted-foreground">
} {new Date(entry.createdAt).toLocaleDateString()}
// Format as xxxx-xxxx-xxxx-xxxx-xxxx-xxxx {entry.expiresAt ? ` · expires ${new Date(entry.expiresAt).toLocaleDateString()}` : ''}
return result.match(/.{1,4}/g)?.join('-') ?? result; </span>
}, []); )}
{entry.allowedIps.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{entry.allowedIps.map((ip) => (
<span
key={ip}
className="text-[10px] font-mono bg-background border border-border rounded px-1.5 py-0.5 text-muted-foreground"
>
{ip}
</span>
))}
</div>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onRemove(entry.id)}
disabled={isSaving}
className="text-destructive hover:text-destructive shrink-0"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
);
}
interface CredentialSectionProps {
icon: typeof Smartphone;
i18nNamespace: 'app_passwords' | 'api_keys';
entries: Array<AppPasswordInfo | ApiKeyInfo>;
onCreate: (input: AppCredentialInput) => Promise<{ id: string; secret: string }>;
onRemove: (id: string) => Promise<void>;
}
function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRemove }: CredentialSectionProps) {
const t = useTranslations('settings.security');
const tk = (key: string) => t(`${i18nNamespace}.${key}`);
const { isSaving, isLoadingAuth } = useAccountSecurityStore();
const [showAdd, setShowAdd] = useState(false);
const [newDescription, setNewDescription] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const [allowedIpsRaw, setAllowedIpsRaw] = useState('');
const [createdSecret, setCreatedSecret] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const handleAdd = async (e: React.FormEvent) => { const handleAdd = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newName.trim()) return; if (!newDescription.trim()) return;
const password = newPassword || generatePassword();
try { try {
await addAppPassword(newName.trim(), password); const result = await onCreate({
setNewName(''); description: newDescription.trim(),
setNewPassword(''); expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
allowedIps: parseIpList(allowedIpsRaw),
});
setCreatedSecret(result.secret);
setNewDescription('');
setExpiresAt('');
setAllowedIpsRaw('');
setShowAdd(false); setShowAdd(false);
toast.success(t('app_passwords.added')); toast.success(tk('added'));
} catch (err) { } catch (err) {
toast.error(t('app_passwords.add_error'), err instanceof Error ? err.message : undefined); toast.error(tk('add_error'), err instanceof Error ? err.message : undefined);
} }
}; };
const handleRemove = async (name: string) => { const handleRemove = async (id: string) => {
try { try {
await removeAppPassword(name); await onRemove(id);
toast.success(t('app_passwords.removed')); toast.success(tk('removed'));
} catch (err) { } catch (err) {
toast.error(t('app_passwords.remove_error'), err instanceof Error ? err.message : undefined); toast.error(tk('remove_error'), err instanceof Error ? err.message : undefined);
} }
}; };
const handleCopySecret = () => {
if (!createdSecret) return;
navigator.clipboard.writeText(createdSecret).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
if (isLoadingAuth) { if (isLoadingAuth) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Smartphone className="w-4 h-4 text-muted-foreground" /> <Icon className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('app_passwords.title')}</h4> <h4 className="text-sm font-medium text-foreground">{tk('title')}</h4>
</div> </div>
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" /> <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div> </div>
@@ -312,53 +462,61 @@ function AppPasswordsSection() {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Smartphone className="w-4 h-4 text-muted-foreground" /> <Icon className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('app_passwords.title')}</h4> <h4 className="text-sm font-medium text-foreground">{tk('title')}</h4>
</div> </div>
<Button variant="outline" size="sm" onClick={() => setShowAdd(!showAdd)}> <Button variant="outline" size="sm" onClick={() => setShowAdd(!showAdd)}>
<Plus className="w-3 h-3 mr-1" /> <Plus className="w-3 h-3 mr-1" />
{t('app_passwords.add')} {t('app_passwords.add')}
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground">{t('app_passwords.description')}</p> <p className="text-xs text-muted-foreground">{tk('description')}</p>
{createdSecret && (
<div className="p-3 bg-muted rounded-md space-y-2">
<p className="text-xs text-muted-foreground">{tk('copy_now_warning')}</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 font-mono break-all">
{createdSecret}
</code>
<Button variant="outline" size="sm" onClick={handleCopySecret}>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</Button>
</div>
<Button variant="ghost" size="sm" onClick={() => setCreatedSecret(null)}>
{t('app_passwords.done')}
</Button>
</div>
)}
{showAdd && ( {showAdd && (
<form onSubmit={handleAdd} className="p-3 bg-muted rounded-md space-y-2"> <form onSubmit={handleAdd} className="p-3 bg-muted rounded-md space-y-2">
<div> <div>
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.name_label')}</label> <label className="text-xs text-muted-foreground mb-1 block">{tk('name_label')}</label>
<Input <Input
value={newName} value={newDescription}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewDescription(e.target.value)}
placeholder={t('app_passwords.name_placeholder')} placeholder={tk('name_placeholder')}
required required
/> />
</div> </div>
<div> <div>
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.password_label')}</label> <label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.expires_label')}</label>
<div className="flex gap-2"> <Input type="date" value={expiresAt} onChange={(e) => setExpiresAt(e.target.value)} />
<div className="relative flex-1"> </div>
<Input <div>
type={showPassword ? 'text' : 'password'} <label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.allowed_ips_label')}</label>
value={newPassword} <textarea
onChange={(e) => setNewPassword(e.target.value)} value={allowedIpsRaw}
placeholder={t('app_passwords.password_placeholder')} onChange={(e) => setAllowedIpsRaw(e.target.value)}
className="pr-10" placeholder={t('app_passwords.allowed_ips_placeholder')}
/> rows={2}
<button className="w-full text-xs font-mono px-3 py-2 rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
type="button" />
onClick={() => setShowPassword(!showPassword)} <p className="text-[10px] text-muted-foreground mt-1">{t('app_passwords.allowed_ips_hint')}</p>
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => setNewPassword(generatePassword())}>
{t('app_passwords.generate')}
</Button>
</div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button type="submit" size="sm" disabled={isSaving || !newName.trim()}> <Button type="submit" size="sm" disabled={isSaving || !newDescription.trim()}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null} {isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t('app_passwords.create')} {t('app_passwords.create')}
</Button> </Button>
@@ -369,47 +527,48 @@ function AppPasswordsSection() {
</form> </form>
)} )}
{appPasswords.length > 0 ? ( {entries.length > 0 ? (
<div className="space-y-1"> <div className="space-y-1">
{appPasswords.map((name) => ( {entries.map((entry) => (
<div key={name} className="flex items-center justify-between py-2 px-3 bg-muted/50 rounded-md"> <CredentialRow key={entry.id} entry={entry} onRemove={handleRemove} isSaving={isSaving} />
<span className="text-sm text-foreground">{name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(name)}
disabled={isSaving}
className="text-destructive hover:text-destructive"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))} ))}
</div> </div>
) : ( ) : (
<p className="text-xs text-muted-foreground italic">{t('app_passwords.none')}</p> <p className="text-xs text-muted-foreground italic">{tk('none')}</p>
)} )}
</div> </div>
); );
} }
function AppPasswordsSection() {
const { appPasswords, createAppPassword, removeAppPassword } = useAccountSecurityStore();
return (
<CredentialSection
icon={Smartphone}
i18nNamespace="app_passwords"
entries={appPasswords}
onCreate={createAppPassword}
onRemove={removeAppPassword}
/>
);
}
function ApiKeysSection() {
const { apiKeys, createApiKey, removeApiKey } = useAccountSecurityStore();
return (
<CredentialSection
icon={Terminal}
i18nNamespace="api_keys"
entries={apiKeys}
onCreate={createApiKey}
onRemove={removeApiKey}
/>
);
}
function EncryptionSection() { function EncryptionSection() {
const t = useTranslations('settings.security'); const t = useTranslations('settings.security');
const { encryptionType, updateEncryption, isSaving, isLoadingCrypto } = useAccountSecurityStore(); const { encryptionType, isLoadingCrypto } = useAccountSecurityStore();
const handleToggle = async (enabled: boolean) => {
try {
if (enabled) {
await updateEncryption({ type: 'pgp', algo: 'Aes256' });
toast.success(t('encryption.enabled'));
} else {
await updateEncryption({ type: 'disabled' });
toast.success(t('encryption.disabled_success'));
}
} catch (err) {
toast.error(t('encryption.error'), err instanceof Error ? err.message : undefined);
}
};
if (isLoadingCrypto) { if (isLoadingCrypto) {
return ( return (
@@ -419,24 +578,12 @@ function EncryptionSection() {
); );
} }
const isEnabled = encryptionType !== 'disabled'; const isEnabled = encryptionType !== 'Disabled';
return ( return (
<SettingItem label={t('encryption.label')} description={t('encryption.description')}> <SettingItem label={t('encryption.label')} description={t('encryption.description')}>
<div className="flex items-center gap-2"> <span className={cn('text-xs font-medium', isEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
{isSaving ? ( {isEnabled ? t('encryption.active', { type: encryptionType }) : t('encryption.inactive')}
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" /> </span>
) : (
<ToggleSwitch
checked={isEnabled}
onChange={handleToggle}
disabled={isSaving}
/>
)}
<span className={cn('text-xs font-medium', isEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
{isEnabled ? t('encryption.active', { type: encryptionType.toUpperCase() }) : t('encryption.inactive')}
</span>
</div>
</SettingItem> </SettingItem>
); );
} }
@@ -446,7 +593,7 @@ function EmailClientSection() {
const { client } = useAuthStore(); const { client } = useAuthStore();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const jmapUsername = client?.getUsername() || ''; const jmapUsername = useMemo(() => client?.getUsername() || '', [client]);
const handleCopy = () => { const handleCopy = () => {
navigator.clipboard.writeText(jmapUsername).then(() => { navigator.clipboard.writeText(jmapUsername).then(() => {
@@ -551,6 +698,9 @@ export function AccountSecuritySettings() {
<AppPasswordsSection /> <AppPasswordsSection />
<div className="border-t border-border" />
<ApiKeysSection />
{isOAuth && ( {isOAuth && (
<> <>
<div className="border-t border-border" /> <div className="border-t border-border" />
+25 -2
View File
@@ -10,6 +10,7 @@ import { useTour } from '@/components/tour/tour-provider';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PlayCircle } from 'lucide-react'; import { PlayCircle } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
import { useAccountStore } from '@/stores/account-store';
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = { const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false }, 'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
@@ -21,7 +22,7 @@ const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: bo
function DensityPreview({ density }: { density: Density }) { function DensityPreview({ density }: { density: Density }) {
const cfg = DENSITY_PREVIEW[density]; const cfg = DENSITY_PREVIEW[density];
const rows = [ const rows = [
{ unread: true, sender: 'Alice Johnson', subject: 'Project update Q1 roadmap', preview: 'Here are the latest numbers from…' }, { unread: true, sender: 'Alice Johnson', subject: 'Project update - Q1 roadmap', preview: 'Here are the latest numbers from…' },
{ unread: false, sender: 'Bob Smith', subject: 'Re: Meeting notes', preview: 'Thanks, will review and get back...' }, { unread: false, sender: 'Bob Smith', subject: 'Re: Meeting notes', preview: 'Thanks, will review and get back...' },
{ unread: true, sender: 'Carol Lee', subject: 'Invoice #4092', preview: 'Please find attached the invoice…' }, { unread: true, sender: 'Carol Lee', subject: 'Invoice #4092', preview: 'Please find attached the invoice…' },
]; ];
@@ -67,9 +68,10 @@ 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, hideAccountSwitcher, showRailAccountList, updateSetting } = useSettingsStore(); const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, updateSetting } = useSettingsStore();
const { startTour, resetTourCompletion } = useTour(); const { startTour, resetTourCompletion } = useTour();
const { isSettingLocked, isSettingHidden } = usePolicyStore(); const { isSettingLocked, isSettingHidden } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
return ( return (
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
@@ -161,6 +163,27 @@ export function AppearanceSettings() {
/> />
</SettingItem> </SettingItem>
{/* Colorful Sidebar Icons */}
<SettingItem label={t('colorful_sidebar_icons.label')} description={t('colorful_sidebar_icons.description')}>
<ToggleSwitch
checked={colorfulSidebarIcons}
onChange={(checked) => updateSetting('colorfulSidebarIcons', checked)}
/>
</SettingItem>
{/* Unified Mailbox */}
{accounts.length > 1 && (
<SettingItem
label={t('unified_mailbox.label')}
description={t('unified_mailbox.description')}
>
<ToggleSwitch
checked={enableUnifiedMailbox}
onChange={(v) => updateSetting('enableUnifiedMailbox', v)}
/>
</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')}>
@@ -12,6 +12,7 @@ import { cn, formatDateTime } from '@/lib/utils';
import { ICalImportModal } from '@/components/calendar/ical-import-modal'; import { ICalImportModal } from '@/components/calendar/ical-import-modal';
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal'; import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { apiFetch } from '@/lib/browser-navigation';
const CALENDAR_COLORS = [ const CALENDAR_COLORS = [
"#3b82f6", // blue "#3b82f6", // blue
@@ -202,7 +203,7 @@ export function CalendarManagementSettings() {
const controller = new AbortController(); const controller = new AbortController();
fetch('/api/caldav/discover', { apiFetch('/api/caldav/discover', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
+10 -1
View File
@@ -135,6 +135,7 @@ export function EmailSettings() {
trustedSendersAddressBook, trustedSendersAddressBook,
attachmentReminderEnabled, attachmentReminderEnabled,
attachmentReminderKeywords, attachmentReminderKeywords,
hideInlineImageAttachments,
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
const { trustedSenderEmails } = useContactStore(); const { trustedSenderEmails } = useContactStore();
@@ -401,6 +402,14 @@ export function EmailSettings() {
</div> </div>
)} )}
{/* Hide inline images from attachment list */}
<SettingItem label={t('hide_inline_image_attachments.label')} description={t('hide_inline_image_attachments.description')}>
<ToggleSwitch
checked={hideInlineImageAttachments}
onChange={(checked) => updateSetting('hideInlineImageAttachments', checked)}
/>
</SettingItem>
{/* 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">
@@ -575,7 +584,7 @@ export function EmailSettings() {
</button> </button>
</SettingItem> </SettingItem>
{/* Trusted Senders address book storage */} {/* Trusted Senders - address book storage */}
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}> <SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
<ToggleSwitch <ToggleSwitch
checked={trustedSendersAddressBook} checked={trustedSendersAddressBook}
+1 -1
View File
@@ -49,7 +49,7 @@ function getPreviewIcon(file: SampleFile, colored: boolean, size: "sm" | "lg") {
} }
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes === 0) return ""; if (bytes === 0) return "-";
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+127 -75
View File
@@ -23,8 +23,13 @@ import {
Filter, Filter,
RotateCcw, RotateCcw,
PalmtreeIcon, PalmtreeIcon,
Lock,
} from "lucide-react"; } from "lucide-react";
function isReadonlyRule(r: FilterRule): boolean {
return r.origin === "external" || r.origin === "opaque";
}
function RuleSummary({ rule }: { rule: FilterRule }) { function RuleSummary({ rule }: { rule: FilterRule }) {
const t = useTranslations("settings.filters"); const t = useTranslations("settings.filters");
@@ -429,90 +434,137 @@ export function FilterSettings() {
{!isOpaque && rules.length > 0 && ( {!isOpaque && rules.length > 0 && (
<div className="space-y-1" role="list" aria-label={t("rule_list")}> <div className="space-y-1" role="list" aria-label={t("rule_list")}>
{rules.map((rule, index) => ( {rules.map((rule, index) => {
<div const readonly = isReadonlyRule(rule);
key={rule.id}
role="listitem" if (readonly) {
draggable const label = rule.originLabel || t("origin_external");
onDragStart={(e) => handleDragStart(e, index)} const tooltip = t("managed_by_tooltip", { source: label });
onDragOver={(e) => handleDragOver(e, index)} const hasStructuredSummary =
onDrop={(e) => handleDrop(e, index)} rule.origin === "external" &&
onDragEnd={handleDragEnd} rule.conditions.length > 0 &&
className={`flex items-start gap-3 p-3 rounded-md border transition-colors ${ rule.actions.length > 0;
dragOverIndex === index return (
? "border-primary bg-primary/5" <div
: "border-border hover:bg-muted/50" key={rule.id}
} ${!rule.enabled ? "opacity-60" : ""}`} role="listitem"
> className="flex items-start gap-3 p-3 rounded-md border border-border"
title={tooltip}
>
<div className="pt-0.5 text-muted-foreground" aria-label={tooltip}>
<Lock className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium text-foreground truncate">
{rule.name}
</p>
<span className="inline-flex items-baseline px-1.5 py-px rounded-sm bg-muted/60 text-muted-foreground text-[10px]">
{label}
</span>
</div>
{hasStructuredSummary ? (
expandedFilterView ? (
<VisualRuleSummary rule={rule} />
) : (
<RuleSummary rule={rule} />
)
) : rule.rawBlock ? (
<pre className="mt-1.5 text-xs font-mono whitespace-pre-wrap break-all text-muted-foreground bg-muted rounded p-2 max-h-32 overflow-y-auto">
{rule.rawBlock.trim()}
</pre>
) : null}
</div>
</div>
);
}
return (
<div <div
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground pt-0.5" key={rule.id}
aria-label={t("drag_to_reorder")} role="listitem"
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={(e) => handleDrop(e, index)}
onDragEnd={handleDragEnd}
className={`flex items-start gap-3 p-3 rounded-md border transition-colors ${
dragOverIndex === index
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/50"
} ${!rule.enabled ? "opacity-60" : ""}`}
> >
<GripVertical className="w-4 h-4" /> <div
</div> className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground pt-0.5"
aria-label={t("drag_to_reorder")}
>
<GripVertical className="w-4 h-4" />
</div>
<div className="pt-0.5"> <div className="pt-0.5">
<ToggleSwitch <ToggleSwitch
checked={rule.enabled} checked={rule.enabled}
onChange={() => handleToggle(rule.id)} onChange={() => handleToggle(rule.id)}
/> />
</div> </div>
<div <div
className="flex-1 min-w-0 cursor-pointer" className="flex-1 min-w-0 cursor-pointer"
onClick={() => { onClick={() => {
setEditingRule(rule);
setShowRuleModal(true);
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setEditingRule(rule); setEditingRule(rule);
setShowRuleModal(true); setShowRuleModal(true);
} }}
}} role="button"
> tabIndex={0}
<p className="text-sm font-medium text-foreground truncate"> onKeyDown={(e) => {
{rule.name} if (e.key === "Enter" || e.key === " ") {
</p> e.preventDefault();
{expandedFilterView ? ( setEditingRule(rule);
<VisualRuleSummary rule={rule} /> setShowRuleModal(true);
}
}}
>
<p className="text-sm font-medium text-foreground truncate">
{rule.name}
</p>
{expandedFilterView ? (
<VisualRuleSummary rule={rule} />
) : (
<RuleSummary rule={rule} />
)}
</div>
{deleteConfirmId === rule.id ? (
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(rule.id)}
>
{t("confirm_delete")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmId(null)}
>
{t("cancel")}
</Button>
</div>
) : ( ) : (
<RuleSummary rule={rule} /> <button
type="button"
onClick={() => setDeleteConfirmId(rule.id)}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-red-600 dark:hover:text-red-400 transition-colors"
aria-label={t("delete_rule")}
>
<X className="w-4 h-4" />
</button>
)} )}
</div> </div>
);
{deleteConfirmId === rule.id ? ( })}
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(rule.id)}
>
{t("confirm_delete")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmId(null)}
>
{t("cancel")}
</Button>
</div>
) : (
<button
type="button"
onClick={() => setDeleteConfirmId(rule.id)}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-red-600 dark:hover:text-red-400 transition-colors"
aria-label={t("delete_rule")}
>
<X className="w-4 h-4" />
</button>
)}
</div>
))}
</div> </div>
)} )}
</SettingsSection> </SettingsSection>
+2 -2
View File
@@ -490,7 +490,7 @@ export function FolderSettings() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Folder List primary section */} {/* Folder List - primary section */}
<SettingsSection title={t('folder_list')} description={t('folder_list_description')}> <SettingsSection title={t('folder_list')} description={t('folder_list_description')}>
<div className="space-y-0.5"> <div className="space-y-0.5">
{folderTree.length === 0 ? ( {folderTree.length === 0 ? (
@@ -516,7 +516,7 @@ export function FolderSettings() {
)} )}
</SettingsSection> </SettingsSection>
{/* Standard Folder Roles advanced section */} {/* Standard Folder Roles - advanced section */}
<SettingsSection title={t('standard_roles')} description={t('standard_roles_description')}> <SettingsSection title={t('standard_roles')} description={t('standard_roles_description')}>
{STANDARD_ROLES.map((role) => { {STANDARD_ROLES.map((role) => {
// Disambiguate duplicate folder names by appending parent path // Disambiguate duplicate folder names by appending parent path
@@ -40,7 +40,7 @@ export function SmimeCertificateModal({
{ label: t("cert_email"), value: record.email }, { label: t("cert_email"), value: record.email },
{ {
label: t("cert_validity"), label: t("cert_validity"),
value: `${new Date(record.notBefore).toLocaleDateString()} ${new Date(record.notAfter).toLocaleDateString()}`, value: `${new Date(record.notBefore).toLocaleDateString()} - ${new Date(record.notAfter).toLocaleDateString()}`,
}, },
{ label: t("cert_fingerprint"), value: record.fingerprint }, { label: t("cert_fingerprint"), value: record.fingerprint },
]; ];
+178 -248
View File
@@ -1,17 +1,19 @@
"use client"; "use client";
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { Shield, Mail, X, AlertTriangle, Trophy, RotateCcw, Inbox, MailCheck } from "lucide-react"; import { Shield, Mail, X, AlertTriangle, MailCheck, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const GAME_WIDTH = 400; const GAME_WIDTH = 400;
const GAME_HEIGHT = 520; const GAME_HEIGHT = 520;
const FORTRESS_Y = GAME_HEIGHT - 48; const INBOX_Y = GAME_HEIGHT - 40;
const SPAWN_INTERVAL_START = 850; const SPAWN_INTERVAL_START = 900;
const SPAWN_INTERVAL_MIN = 320; const SPAWN_INTERVAL_MIN = 340;
const GAME_DURATION = 30; const GAME_DURATION = 30;
const ENEMY_SPEED_START = 1.2; const ENEMY_SPEED_START = 1.2;
const ENEMY_SPEED_INCREASE = 0.04; const ENEMY_SPEED_INCREASE = 0.04;
const MAX_MISSES = 3;
interface Enemy { interface Enemy {
id: number; id: number;
@@ -21,81 +23,85 @@ interface Enemy {
type: "spam" | "phishing" | "legit"; type: "spam" | "phishing" | "legit";
} }
type GameState = "idle" | "playing" | "won" | "lost"; type GameState = "idle" | "playing" | "over";
export function SpamSiegeGame({ onClose }: { onClose: () => void }) { export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
const [gameState, setGameState] = useState<GameState>("idle"); const [gameState, setGameState] = useState<GameState>("idle");
const [enemies, setEnemies] = useState<Enemy[]>([]); const [enemies, setEnemies] = useState<Enemy[]>([]);
const [score, setScore] = useState(0); const [score, setScore] = useState(0);
const [timeLeft, setTimeLeft] = useState(GAME_DURATION); const [timeLeft, setTimeLeft] = useState(GAME_DURATION);
const [shieldHealth, setShieldHealth] = useState(3); const [misses, setMisses] = useState(0);
const [hitEffects, setHitEffects] = useState<{ id: number; x: number; y: number; color: string }[]>([]); const [survived, setSurvived] = useState(false);
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 nextId = useRef(0);
const animFrameRef = useRef<number>(0); const animFrameRef = useRef<number>(0);
const lastTimeRef = useRef<number>(0); const lastTimeRef = useRef<number>(0);
const spawnTimerRef = useRef<number>(0); const spawnTimerRef = useRef<number>(0);
const gameStateRef = useRef<GameState>("idle"); const gameStateRef = useRef<GameState>("idle");
const elapsedRef = useRef(0); const elapsedRef = useRef(0);
const destroyedRef = useRef(new Set<number>()); const clickedRef = useRef(new Set<number>());
const enemiesRef = useRef<Enemy[]>([]);
const missesRef = useRef(0);
const scoreRef = useRef(0);
useEffect(() => { useEffect(() => {
gameStateRef.current = gameState; gameStateRef.current = gameState;
}, [gameState]); }, [gameState]);
const endGame = useCallback((didSurvive: boolean) => {
setSurvived(didSurvive);
setGameState("over");
}, []);
const startGame = useCallback(() => { const startGame = useCallback(() => {
setGameState("playing"); setGameState("playing");
setEnemies([]); setEnemies([]);
setScore(0); setScore(0);
setTimeLeft(GAME_DURATION); setTimeLeft(GAME_DURATION);
setShieldHealth(3); setMisses(0);
setHitEffects([]); setSurvived(false);
setDestroyEffects([]);
setDeliverEffects([]);
nextId.current = 0; nextId.current = 0;
spawnTimerRef.current = 0; spawnTimerRef.current = 0;
elapsedRef.current = 0; elapsedRef.current = 0;
destroyedRef.current = new Set(); clickedRef.current = new Set();
enemiesRef.current = [];
missesRef.current = 0;
scoreRef.current = 0;
lastTimeRef.current = performance.now(); lastTimeRef.current = performance.now();
}, []); }, []);
const spawnEnemy = useCallback(() => { const spawnEnemy = useCallback(() => {
const id = nextId.current++; const id = nextId.current++;
const rand = Math.random(); const rand = Math.random();
const type = rand > 0.7 ? "legit" : rand > 0.45 ? "phishing" : "spam"; const type = rand > 0.75 ? "legit" : rand > 0.45 ? "phishing" : "spam";
const x = 20 + Math.random() * (GAME_WIDTH - 60); const x = 20 + Math.random() * (GAME_WIDTH - 60);
const elapsed = elapsedRef.current; const speed = ENEMY_SPEED_START + (elapsedRef.current / 1000) * ENEMY_SPEED_INCREASE;
const speed = ENEMY_SPEED_START + (elapsed / 1000) * ENEMY_SPEED_INCREASE; enemiesRef.current = [...enemiesRef.current, { id, x, y: -32, speed, type }];
setEnemies((prev) => [...prev, { id, x, y: -30, speed, type }]); setEnemies(enemiesRef.current);
}, []); }, []);
const handleHover = useCallback((enemy: Enemy) => { const handleClick = useCallback(
if (destroyedRef.current.has(enemy.id)) return; (ev: React.MouseEvent, enemy: Enemy) => {
destroyedRef.current.add(enemy.id); ev.stopPropagation();
if (clickedRef.current.has(enemy.id)) return;
clickedRef.current.add(enemy.id);
if (enemy.type === "legit") { enemiesRef.current = enemiesRef.current.filter((e) => e.id !== enemy.id);
// Penalty for blocking legit mail setEnemies(enemiesRef.current);
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)); if (enemy.type === "legit") {
}, []); missesRef.current += 1;
setMisses(missesRef.current);
scoreRef.current = Math.max(0, scoreRef.current - 15);
setScore(scoreRef.current);
if (missesRef.current >= MAX_MISSES) endGame(false);
} else {
scoreRef.current += enemy.type === "phishing" ? 15 : 10;
setScore(scoreRef.current);
}
},
[endGame]
);
// Game loop
useEffect(() => { useEffect(() => {
if (gameState !== "playing") return; if (gameState !== "playing") return;
@@ -106,15 +112,13 @@ export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
lastTimeRef.current = now; lastTimeRef.current = now;
elapsedRef.current += dt; elapsedRef.current += dt;
// Timer
const newTimeLeft = GAME_DURATION - Math.floor(elapsedRef.current / 1000); const newTimeLeft = GAME_DURATION - Math.floor(elapsedRef.current / 1000);
setTimeLeft(Math.max(0, newTimeLeft)); setTimeLeft(Math.max(0, newTimeLeft));
if (newTimeLeft <= 0) { if (newTimeLeft <= 0) {
setGameState("won"); endGame(true);
return; return;
} }
// Spawn
spawnTimerRef.current += dt; spawnTimerRef.current += dt;
const spawnInterval = Math.max( const spawnInterval = Math.max(
SPAWN_INTERVAL_MIN, SPAWN_INTERVAL_MIN,
@@ -125,261 +129,187 @@ export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
spawnEnemy(); spawnEnemy();
} }
// Move enemies const nextEnemies: Enemy[] = [];
setEnemies((prev) => { let missed = 0;
const next: Enemy[] = []; let scoreDelta = 0;
let spamBreached = false; for (const e of enemiesRef.current) {
for (const e of prev) { const ny = e.y + e.speed * (dt / 16);
const ny = e.y + e.speed * (dt / 16); if (ny >= INBOX_Y) {
if (ny >= FORTRESS_Y) { if (e.type === "legit") scoreDelta += 5;
if (e.type === "legit") { else missed++;
// Legit mail delivered — bonus } else {
setScore((s) => s + 5); nextEnemies.push({ ...e, y: ny });
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) => { enemiesRef.current = nextEnemies;
const nh = prev - 1; setEnemies(nextEnemies);
if (nh <= 0) setGameState("lost");
return Math.max(0, nh); if (scoreDelta > 0) {
}); scoreRef.current += scoreDelta;
setScore(scoreRef.current);
}
if (missed > 0) {
missesRef.current += missed;
setMisses(missesRef.current);
if (missesRef.current >= MAX_MISSES) {
endGame(false);
return;
} }
return next; }
});
animFrameRef.current = requestAnimationFrame(tick); animFrameRef.current = requestAnimationFrame(tick);
}; };
animFrameRef.current = requestAnimationFrame(tick); animFrameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(animFrameRef.current); return () => cancelAnimationFrame(animFrameRef.current);
}, [gameState, spawnEnemy]); }, [gameState, spawnEnemy, endGame]);
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 ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"> <div
<div className="relative rounded-xl border border-border bg-card shadow-2xl overflow-hidden select-none" className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className="relative rounded-lg border border-border bg-card shadow-xl overflow-hidden select-none"
style={{ width: GAME_WIDTH, maxWidth: "95vw" }} style={{ width: GAME_WIDTH, maxWidth: "95vw" }}
onClick={(e) => e.stopPropagation()}
> >
{/* Header */} <div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-card">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Shield className="w-4 h-4" style={{ color: "rgb(219, 45, 84)" }} /> <Shield className="w-4 h-4 text-primary" />
<span className="text-sm font-semibold text-foreground">Spam Siege</span> <span className="text-sm font-medium text-foreground">Spam Siege</span>
</div> </div>
<button onClick={onClose} className="p-1 rounded hover:bg-muted transition-colors"> <button
onClick={onClose}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label="Close"
>
<X className="w-4 h-4 text-muted-foreground" /> <X className="w-4 h-4 text-muted-foreground" />
</button> </button>
</div> </div>
{/* HUD */} <div className="flex items-center justify-between px-4 py-2 bg-muted/40 border-b border-border text-xs text-muted-foreground">
<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-4">
<div className="flex items-center gap-3"> <span>
<span className="text-muted-foreground">Score: <span className="font-semibold text-foreground">{score}</span></span> Score <span className="font-medium text-foreground tabular-nums">{score}</span>
<span className="text-muted-foreground">Time: <span className="font-semibold text-foreground">{timeLeft}s</span></span> </span>
</div> <span>
<div className="flex items-center gap-1"> Time <span className="font-medium text-foreground tabular-nums">{timeLeft}s</span>
{[...Array(3)].map((_, i) => ( </span>
<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>
<span>
Misses{" "}
<span
className={cn(
"font-medium tabular-nums",
misses >= MAX_MISSES - 1 ? "text-destructive" : "text-foreground"
)}
>
{misses}/{MAX_MISSES}
</span>
</span>
</div> </div>
{/* Game area */}
<div <div
className="relative bg-background overflow-hidden" className="relative bg-background overflow-hidden"
style={{ height: GAME_HEIGHT }} style={{ height: GAME_HEIGHT }}
> >
{/* Grid lines for depth */} <div
<div className="absolute inset-0 opacity-[0.03]" style={{ className="absolute left-0 right-0 flex items-center gap-2 px-4"
backgroundImage: "linear-gradient(to bottom, currentColor 1px, transparent 1px), linear-gradient(to right, currentColor 1px, transparent 1px)", style={{ top: INBOX_Y }}
backgroundSize: "40px 40px", >
}} /> <div className="h-px flex-1 bg-border" />
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
{/* Fortress wall */} Inbox
<div className="absolute left-0 right-0 bottom-0 flex flex-col items-center" style={{ height: GAME_HEIGHT - FORTRESS_Y }}> </span>
<div className="relative w-full"> <div className="h-px flex-1 bg-border" />
{/* 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> </div>
{/* Enemies */}
{enemies.map((e) => { {enemies.map((e) => {
const style = getEnemyStyle(e.type); const variant =
e.type === "phishing"
? "text-warning border-warning/40 bg-warning/10 hover:bg-warning/20"
: e.type === "legit"
? "text-success border-success/40 bg-success/10 hover:bg-success/20"
: "text-destructive border-destructive/40 bg-destructive/10 hover:bg-destructive/20";
const Icon =
e.type === "phishing" ? AlertTriangle : e.type === "legit" ? MailCheck : Mail;
return ( return (
<div <button
key={e.id} key={e.id}
className="absolute flex items-center justify-center w-8 h-8 rounded-md transition-transform" type="button"
style={{ className={cn(
left: e.x, "absolute flex items-center justify-center w-8 h-8 rounded-md border cursor-pointer",
top: e.y, "active:scale-95 transition-transform",
backgroundColor: style.bg, variant
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> style={{ left: e.x, top: e.y }}
onMouseEnter={(ev) => handleClick(ev, e)}
onClick={(ev) => handleClick(ev, e)}
>
<Icon className="w-4 h-4" />
</button>
); );
})} })}
{/* 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" && ( {gameState === "idle" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80"> <div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/95 px-8 text-center">
<Shield className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} fill="rgba(219, 45, 84, 0.1)" /> <Shield className="w-10 h-10 text-primary" />
<div className="text-center"> <div className="space-y-1.5">
<p className="text-base font-semibold text-foreground">Spam Siege</p> <p className="text-base font-medium text-foreground">Spam Siege</p>
<p className="text-xs text-muted-foreground mt-1.5 max-w-[280px] leading-relaxed"> <p className="text-xs text-muted-foreground leading-relaxed">
Hover over threats to block them. Let legitimate mail through. Survive {GAME_DURATION} seconds. Click spam and phishing before they hit your inbox. Don&apos;t block legitimate
mail. Three misses and it&apos;s over.
</p> </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> </div>
<Button size="sm" onClick={startGame} className="mt-1 text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}> <div className="flex items-center gap-4 text-[11px] text-muted-foreground">
<Shield className="w-3.5 h-3.5 mr-1.5" /> <span className="inline-flex items-center gap-1.5">
Defend <Mail className="w-3 h-3 text-destructive" />
Spam
</span>
<span className="inline-flex items-center gap-1.5">
<AlertTriangle className="w-3 h-3 text-warning" />
Phishing
</span>
<span className="inline-flex items-center gap-1.5">
<MailCheck className="w-3 h-3 text-success" />
Legit
</span>
</div>
<Button size="sm" onClick={startGame}>
Start
</Button> </Button>
</div> </div>
)} )}
{/* Won overlay */} {gameState === "over" && (
{gameState === "won" && ( <div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/95 px-8 text-center">
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80"> <Shield
<Trophy className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} /> className={cn(
<div className="text-center"> "w-10 h-10",
<p className="text-base font-semibold text-foreground">Fortress Secured</p> survived ? "text-success" : "text-muted-foreground/40"
<p className="text-xs text-muted-foreground mt-1"> )}
Score: <span className="font-semibold text-foreground">{score}</span> />
<div className="space-y-1">
<p className="text-base font-medium text-foreground">
{survived ? "Inbox held" : "Inbox overrun"}
</p>
<p className="text-xs text-muted-foreground">
Final score{" "}
<span className="font-medium text-foreground tabular-nums">{score}</span>
</p> </p>
</div> </div>
<div className="flex gap-2 mt-1"> <div className="flex gap-2">
<Button size="sm" variant="outline" onClick={onClose}> <Button size="sm" variant="outline" onClick={onClose}>
Close Close
</Button> </Button>
<Button size="sm" onClick={startGame} className="text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}> <Button size="sm" onClick={startGame}>
<RotateCcw className="w-3.5 h-3.5 mr-1.5" /> <RotateCcw className="w-3.5 h-3.5 mr-1.5" />
Again Again
</Button> </Button>
</div> </div>
</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> </div>
</div> </div>

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