Compare commits

...
110 Commits
Author SHA1 Message Date
Linus Rath f303478850 chore: update version to 1.4.14 2026-04-16 19:08:42 +02:00
Linus Rath 8bdadc7ba3 fix: standardize punctuation 2026-04-16 19:07:42 +02:00
Linus Rath 6b57118add chore: update version to 1.4.14 2026-04-16 19:05:20 +02:00
Linus Rath ffb645671c chore: update README 2026-04-16 19:04:18 +02:00
Linus Rath e63ce25f5f chore: update version to 1.4.14 2026-04-16 18:59:23 +02:00
Linus Rath d31b30ba4a Merge branch 'dev' 2026-04-16 18:51:01 +02:00
Linus Rath 31eff96614 feat: Enhance external rule handling in Sieve parser and store #201 2026-04-16 17:22:52 +02:00
nesgarboandLinus Rath 2ea8054240 fix: improve CalDAV task detection for external clients (Thunderbird) #84
Two issues prevented tasks created in Thunderbird (or other CalDAV
clients) from appearing in the task view:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Usage:
  NEXT_PUBLIC_LOCALE_PREFIX=always npm run build

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

### Problem

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

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

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

### Fix

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

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

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

### Migration

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

### Compat

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

### Testing

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

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

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

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

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

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

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

All three are backwards-compatible: unset = identical to current behavior.
2026-04-06 23:22:06 +02:00
p.kwiatkowskiandLinus Rath 523711cca3 feat: add Polish language support 2026-04-06 23:20:25 +02:00
Linus Rath bcb487810a feat: add Korean language support 2026-04-05 16:46:17 +02:00
rldn12134andLinus Rath 7e1d644f7d feat: Add Korean translation file 2026-04-05 13:51:22 +02:00
Linus Rath 4aac65d7d2 feat: add birthday calendar feature with settings and localization 2026-04-03 22:45:07 +02:00
Linus Rath 14ecae61dc feat: enhance error handling in Stalwart API responses 2026-04-03 15:10:11 +02:00
Linus Rath c53ff5a30a fix: file deletion logic to update recent files and handle errors #146 2026-04-03 14:20:06 +02:00
Linus Rath 5aad97d64e feat: add PWA support with service worker and install prompt 2026-04-03 04:39:19 +02:00
Linus Rath 081c865018 fix: guard against undefined trigger in calendar event alert popover #143 2026-04-03 03:15:55 +02:00
Linus Rath 050f38b1fa fix: use dynamic batch size for calendar event retrieval 2026-04-02 23:29:37 +02:00
Linus Rath d657bdfa75 fix: improve calendar event retrieval by batching requests to avoid server limits #141 2026-04-02 22:55:07 +02:00
Linus Rath bbf724d9e1 fix: correct logic for marking email as read in EmailViewer component 2026-04-02 22:50:04 +02:00
Linus Rath 9d8c6044e3 fix: optimize htmlToPlainText function using DOMParser 2026-04-02 17:32:53 +02:00
Linus Rath 2d17ca71e3 fix: replace random ID generation with generateUUID() 2026-04-02 17:29:00 +02:00
Linus Rath d77dd1e3e1 fix: replace non-UUID fallback 2026-04-02 17:25:10 +02:00
Linus Rath 9d97b74684 fix: adjust max width of calendar invitation banner for better layout 2026-04-02 14:56:04 +02:00
Linus Rath c4673acb65 fix: replace unguarded crypto.randomUUID() with safe generateUUID() utility 2026-04-02 14:51:21 +02:00
Linus Rath 1bdc51dcc7 feat: implement version badge and easter egg in settings 2026-04-02 14:31:25 +02:00
Linus Rath 6ee0f6a40a feat: disable plugins by default, require admin approval 2026-04-02 13:50:02 +02:00
Linus Rath 9ee25c930e fix: compute per-occurrence utcStart/utcEnd in recurrence expansion #116 2026-04-02 13:35:26 +02:00
Linus Rath 1b2c70a90b fix: de-namespace addressBookIds for shared contacts in create and update operations #133 2026-04-02 11:34:26 +02:00
Linus Rath cdea876992 fix: fetch mailboxes on mount in FolderSettings when store is empty 2026-04-02 11:26:21 +02:00
Linus Rath 16557830ae fix: mailbox deletion error handling 2026-04-02 11:24:11 +02:00
Linus Rath 8836f9cdca fix: wrap handleArchive call to prevent MouseEvent leaking as argument 2026-04-02 11:19:36 +02:00
Linus Rath 26bfe9cb6c fix: extend file drop zone to fill remaining viewport height 2026-04-02 11:14:38 +02:00
Linus Rath 05eaaad61f fix: fallback to application/octet-stream for long MIME types in createFileNode 2026-04-02 11:04:59 +02:00
Linus Rath 2e0852b272 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-02 10:57:03 +02:00
Linus Rath 6173a9ad13 fix: resolve settings sync Identity mismatch for OAuth/SSO sessions #127 2026-04-02 10:54:35 +02:00
Linus RathandGitHub e3560d9cb4 Add funding configuration for Bulwark Webmail 2026-04-02 00:37:58 +02:00
Niklas VossandLinus Rath 52326326e2 fix: check plugin http.post url against origin and add regression tests 2026-04-01 11:26:12 +02:00
Niklas VossandLinus Rath 2734fa08b7 feat: instead of exposing auth headers, offer a http proxy api for plugins 2026-04-01 11:26:12 +02:00
Niklas VossandLinus Rath 67a0d622bc feat: add auth:read permission and ability to retrieve auth headers 2026-04-01 11:26:12 +02:00
chrilepandGitHub 58968a1cbf updated to show latest version in badge 2026-04-01 09:49:23 +02:00
221 changed files with 21467 additions and 1951 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"WebFetch(domain:github.com)"
]
}
}
+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.
# No external mail server required great for UI development and testing.
# No external mail server required - great for UI development and testing.
#
# Usage:
# cp .env.dev.example .env.local
# 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
+61 -17
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.
# For development with the built-in mock server, see .env.dev.example instead.
@@ -6,7 +6,7 @@
# 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
# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
@@ -48,6 +48,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# OAuth client secret (server-side only, never exposed to the browser)
# OAUTH_CLIENT_SECRET=your-client-secret
# Alternatively, you can specify the path to a file containing the OAuth client secret.
# OAUTH_CLIENT_SECRET_FILE=/oauth-client-secret
# OpenID Connect issuer URL for discovery
# OAUTH_ISSUER_URL=https://your-idp.example.com
@@ -60,6 +62,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Required for both "Remember me" and settings sync features.
# Generate with: openssl rand -base64 32
# SESSION_SECRET=your-secret-key-here
# Alternatively, you can specify the path to a file containing the session secret.
# SESSION_SECRET_FILE=/session-secret
# =============================================================================
# Settings Sync
@@ -72,7 +76,7 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Directory for storing encrypted settings files (default: ./data/settings).
# 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:
# - bulwark-settings:/app/data/settings
# SETTINGS_DATA_DIR=./data/settings
@@ -102,41 +106,81 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# 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.
# Recommended size: 32×32px minimum, 512×512px maximum (or SVG for best scaling).
# Can be an absolute URL or a path relative to the public/ directory.
# Can be an absolute URL (https://...) or a path relative to the public/ directory.
# Defaults to the Bulwark favicon if not set.
# 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.
# 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.
# If not set, no logo is shown in the sidebar.
# APP_LOGO_LIGHT_URL=/branding/my-logo-color.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.
# 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.
# 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
#
# Dark mode logo (shown on dark backgrounds), defaults to Bulwark white logo.
# Dark mode logo (shown on dark backgrounds). Defaults to the Bulwark white logo.
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
# 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
# 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
# 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
# =============================================================================
+4
View File
@@ -0,0 +1,4 @@
# Bulwark Webmail Funding configuration
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
github: [bulwarkmail]
+23 -11
View File
@@ -21,11 +21,23 @@ on:
- ".github/workflows/docker-publish.yml"
workflow_dispatch:
env:
IMAGE_NAME: ghcr.io/${{ github.repository }}
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
image_name: ${{ steps.set.outputs.image_name }}
steps:
- name: Set image name
id: set
run: |
if [ "${{ github.ref_name }}" = "main" ]; then
echo "image_name=ghcr.io/${{ github.repository }}-beta" >> $GITHUB_OUTPUT
else
echo "image_name=ghcr.io/${{ github.repository }}-${{ github.ref_name }}" >> $GITHUB_OUTPUT
fi
build:
needs: prepare
strategy:
fail-fast: false
matrix:
@@ -57,7 +69,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
images: ${{ needs.prepare.outputs.image_name }}
- name: Build and push by digest
id: build
@@ -66,7 +78,7 @@ jobs:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
@@ -86,7 +98,7 @@ jobs:
merge:
runs-on: ubuntu-latest
needs: build
needs: [prepare, build]
permissions:
contents: read
packages: write
@@ -113,17 +125,17 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
images: ${{ needs.prepare.outputs.image_name }}
tags: |
type=raw,value={{branch}}
type=sha,prefix={{branch}}-
type=raw,value=latest
type=sha
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
$(printf '${{ needs.prepare.outputs.image_name }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
docker buildx imagetools inspect ${{ needs.prepare.outputs.image_name }}:${{ steps.meta.outputs.version }}
+68
View File
@@ -0,0 +1,68 @@
name: Publish Standalone Tarball on Release
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
arch: amd64
- os: ubuntu-24.04-arm
arch: arm64
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build standalone
run: npm run build
- name: Package tarball
env:
REF_NAME: ${{ github.ref_name }}
ARCH: ${{ matrix.arch }}
run: |
VERSION="${REF_NAME#v}"
TARBALL="bulwark-standalone-${VERSION}-linux-${ARCH}.tar.gz"
mkdir -p bulwark-standalone
cp -r .next/standalone/. bulwark-standalone/
cp -r .next/static bulwark-standalone/.next/static
cp -r public bulwark-standalone/public
tar -czf "$TARBALL" bulwark-standalone/
echo "TARBALL=$TARBALL" >> "$GITHUB_ENV"
- name: Upload release asset
if: github.event_name == 'release'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG_NAME" "$TARBALL" --clobber
- name: Upload artifact (workflow_dispatch)
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v4
with:
name: standalone-${{ matrix.arch }}
path: ${{ env.TARBALL }}
retention-days: 7
+133 -2
View File
@@ -1,5 +1,136 @@
# 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)
Thank you for your donations:
**One-time**
- [@boris22100](https://github.com/boris22100)
- [@mkorthaus-private](https://github.com/mkorthaus-private)
**Monthly**
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
### Features
- **Contacts**: Store trusted senders in a dedicated JMAP address book (#176)
- **Email**: Warn on send when attachment keyword found but no file attached (#172)
- **Email**: Enable keyword reordering (#174) and multi-tag support per email (#173)
- **PWA**: Add "don't remind me again" option to install prompt
- **Auth**: Add `SESSION_SECRET_FILE` and `OAUTH_CLIENT_SECRET_FILE` environment variable support
- **Plugins**: Add `onAvatarResolve` plugin hook
- **Docker**: Publish main and dev branches as separate GHCR packages
### Fixes
- **Email**: Style links in plain text emails
- **Email**: Seed list history entry when app initializes on an email view
- **Email**: Remount composer on draft edit and preserve identity (#60)
- **Contacts**: Display contact names stored in `name.full` (#179)
- **Contacts**: Fix category dropdown blocking Save button in contact form (#177)
- **Contacts**: Resolve TS error from optional `name.components` in vCard parser
- **Search**: Search all folders when filtering emails by tag (#175)
- **Auth**: Include mount prefix in SSO redirect URI when app is served under a subpath
- **PWA**: Correct PWA icons with proper sizing, transparency, and dark/light mode support
## 1.4.12 (2026-04-09)
Thank you for your donations:
**One-time**
- [@mkorthaus-private](https://github.com/mkorthaus-private)
**Monthly**
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
### Features
- **PWA**: Add PWA support with service worker and install prompt
- **Calendar**: Add birthday calendar feature with settings and localization
- **Calendar**: Clamp February 29 birthdays in non-leap years
- **Identity**: Add automatic identity synchronization (#167)
- **Plugins**: Disable plugins by default and require admin approval
- **Plugins**: Replace auth header exposure with a secure HTTP proxy API for plugins
- **Auth**: Add configurable OAuth scopes and cookie security via environment variables
- **Email**: Sync mail view to browser history for back/forward navigation
- **Contacts**: Add ability to rename address books (#152)
- **UI**: Add version badge in settings
- **i18n**: Add Latvian (lv) locale support
- **i18n**: Add Polish language support
- **i18n**: Add Korean language support
- **i18n**: Add Simplified Chinese (zh_CN) locale support
### Fixes
- **Email**: Show recipient instead of sender in Sent and Drafts folder lists
- **Email**: Embed dropped images as data URLs and prevent duplicate attachments (#163)
- **Email**: Fix logic for marking email as read in EmailViewer
- **Email**: Fix archive action passing MouseEvent as argument
- **Mailbox**: Preserve search filters on push-triggered mailbox refresh (#164)
- **Mailbox**: Align shared account folders with primary folders (#151)
- **Mailbox**: Fetch mailboxes on mount in FolderSettings when store is empty
- **Mailbox**: Improve mailbox deletion error handling
- **Calendar**: Improve calendar event retrieval by batching requests to avoid server limits (#141)
- **Calendar**: Compute per-occurrence UTC start/end in recurrence expansion (#116)
- **Calendar**: Guard against undefined trigger in calendar event alert popover (#143)
- **Files**: Stream WebDAV PUT uploads to avoid buffering in memory (#162)
- **Files**: Prune recent files against server nodes on refresh (#146)
- **Files**: Fix file deletion logic to update recent files and handle errors (#146)
- **Files**: Extend file drop zone to fill remaining viewport height
- **Files**: Fallback to application/octet-stream for long MIME types
- **Security**: Replace unguarded crypto.randomUUID() with safe generateUUID() utility
- **Security**: Validate plugin HTTP post URL against origin with regression tests
- **Security**: Allow blob images in CSP for inline drag-and-drop (#163)
- **Auth**: Resolve settings sync identity mismatch for OAuth/SSO sessions (#127)
- **Contacts**: Fix address book ID namespacing for shared contacts in create and update operations (#133)
- **UI**: Fix focused mode expanding beyond screen bounds (#156)
- **API**: Handle 403 on principal fetch without console error
- **API**: Enhance error handling in Stalwart API responses
## 1.4.11 (2026-03-31)
### Features
@@ -349,7 +480,7 @@
### Fixes
- **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
## 1.1.1 (2026-02-28)
@@ -359,7 +490,7 @@
- **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)
- **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)
+14 -3
View File
@@ -1,9 +1,9 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20White%20and%20Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" />
<img src="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" alt="Bulwark Webmail" width="220" />
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
<img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
</picture>
</div>
@@ -12,6 +12,17 @@
Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors.
## Join our Community
**New to the project or looking for a place to start?** You don't need to be an expert to contribute! Whether you need help setting up your environment, want to report a bug, or are interested in helping with translations, our Discord is the best place to connect.
* **Get Support:** Get real-time help with development hurdles.
* **Contribute:** Share ideas, suggest features, or help us improve documentation.
* **Collaborate:** Meet the team and other contributors working to make Bulwark better.
[**Join the Bulwark Discord Server**](https://discord.gg/tYCujymGrT)
---
## Getting Started
### Development Setup
+167 -62
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)
[![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.10-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)
</div>
@@ -26,16 +26,16 @@ Built with Next.js and the JMAP protocol.
<tr>
<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 width="50%">
<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>
</tr>
@@ -44,14 +44,14 @@ Built with Next.js and the JMAP protocol.
<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 width="50%">
<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>
</tr>
@@ -63,16 +63,16 @@ Built with Next.js and the JMAP protocol.
<tr>
<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 width="50%">
<img src="screenshots/settings.png" width="100%" alt="Settings">
**Settings** Appearance, identities, filters, templates, and more
**Settings** - Appearance, identities, filters, templates, and more
</td>
</tr>
@@ -81,7 +81,7 @@ Built with Next.js and the JMAP protocol.
<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 width="50%">
@@ -94,34 +94,46 @@ Built with Next.js and the JMAP protocol.
### Mail
- **Read, compose, reply, reply-all, forward** with rich HTML rendering
- **Threading** Gmail-style inline expansion with thread navigation
- **Draft auto-save** with discard confirmation
- **Attachments** — upload, download, and inline preview
- **Search** — full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
- **Batch operations** — multi-select with checkboxes, archive, delete, move, tag
- **Archive modes** — archive directly or organize archived mail by year or month
- **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; optional conversation threading toggle
- **Unified mailbox** - view emails across all accounts in a single list
- **Draft auto-save** with discard confirmation; full draft editing with identity preservation
- **Attachments** - upload, download, and inline preview; attachment-keyword warning if a file is forgotten
- **Search** - full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
- **Batch operations** - multi-select with checkboxes, archive, delete, move, tag
- **Archive modes** - archive directly or organize archived mail by year or month
- **Print** emails directly from the viewer
- **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
- **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
- **TNEF support** — extract Outlook `winmail.dat` message bodies and attachments automatically
- **Folder management** — create, rename, delete folders with icon picker and subfolder support
- **Tag counts** — unread and total counts displayed in sidebar
- **Plain text composer mode** and auto-select reply identity
- **Reply-to addresses** support in the composer
- **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
- **Month, week, day, and agenda views** with mini-calendar sidebar
- **Event hover preview** popover with details
- **Drag-and-drop rescheduling**, click-drag creation, edge-resize (15-min snap)
- **Recurring events** with edit/delete scope (this / this and following / all)
- **Participant scheduling** iTIP invitations, organizer/attendee UI, RSVP
- **Inline calendar invitations** in email viewer auto-detect `.ics`, RSVP, import
- **iCalendar import** with preview and bulk create
- **Task management** — create, edit, and track tasks with due dates, priority, and completion status
- **Month, week, day, and agenda views** with mini-calendar sidebar and a dedicated task list view
- **Event hover preview** popover with configurable details
- **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) and client-side recurrence expansion
- **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
- **iCalendar import** with preview, bulk create, and UID deduplication
- **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
- **Notifications** with configurable sound, alert persistence, and sound picker with preview playback
- **Real-time sync** via JMAP push
@@ -129,77 +141,103 @@ Built with Next.js and the JMAP protocol.
### Contacts
- **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
- **vCard import/export** (RFC 6350) with duplicate detection
- **Trusted senders** stored in a dedicated JMAP address book
- **Autocomplete** in composer (To/Cc/Bcc)
- **Bulk operations** multi-select, delete, group add, export
- **Bulk operations** - multi-select, delete, group add, export
### Filters & Automation
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
- **Visual rule builder** conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
- **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
- **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
- **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
- **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
- **Favorites and recent files** for quick access
- **Bulk operations** multi-select, delete, move, download
- **Bulk operations** - multi-select, delete, move, download
### 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
- **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
- **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**
- **Account security panel** manage passwords and 2FA via Stalwart admin API
- **"Remember me"** AES-256-GCM encrypted httpOnly cookie (opt-in)
- **Security headers** CSP with per-request nonce, X-Frame-Options, Referrer-Policy
- **Account security panel** - manage passwords and 2FA via Stalwart admin API
- **"Remember me"** - AES-256-GCM encrypted httpOnly cookie (opt-in)
- **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)
### 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
- **Always-light email rendering** option for problematic HTML messages in dark theme
- **Responsive** desktop sidebar + mobile bottom tab bar with tablet support
- **Keyboard shortcuts** full navigation without a mouse
- **Responsive** - desktop sidebar + mobile bottom tab bar with tablet support
- **Keyboard shortcuts** - full navigation without a mouse
- **Drag-and-drop** email organization between mailboxes and tag assignment
- **Interactive guided tour** onboarding walkthrough for new users
- **Interactive guided tour** - onboarding walkthrough for new users
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
- **Sidebar apps** pin custom tools to the navigation rail and open them inline or in a new tab
- **Settings sync** preferences synchronized with the server (encrypted)
- **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)
- **Storage quota** display
- **Shared folders** — multi-account access
- **Accessibility** — WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
- **Version badge** in settings
- **Focused mode** with proper viewport bounds
- **Accessibility** - WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
### Internationalization
8 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português
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
- **Multiple sender identities** with per-identity signatures
- **Identity refresh** — keep the identity manager aligned with server-side changes after edits
- **Sub-addressing** `user+tag@domain.com` with contextual tag suggestions
- **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
- **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
- **Automatic update check** — server logs when a newer release is available
- **Demo mode** — try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes — no mail server required
- **Progressive Web App (PWA)** - installable with service worker, install prompt, and dynamic manifest (app name, description, icons, theme and background colors)
- **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
cp .env.example .env.local
# Edit .env.local set JMAP_SERVER_URL
# Edit .env.local - set JMAP_SERVER_URL
docker compose up -d
```
@@ -228,7 +266,7 @@ git clone https://github.com/bulwarkmail/webmail.git
cd webmail
npm install
cp .env.example .env.local
# Edit .env.local set JMAP_SERVER_URL
# Edit .env.local - set JMAP_SERVER_URL
npm run build && npm start
```
@@ -252,7 +290,7 @@ JMAP_SERVER_URL=https://mail.example.com
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>
<summary>Server Listen Address</summary>
@@ -271,6 +309,7 @@ PORT=3000 # Default listen port
OAUTH_ENABLED=true
OAUTH_CLIENT_ID=webmail
OAUTH_CLIENT_SECRET= # optional, for confidential clients
OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
```
@@ -279,13 +318,79 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
</details>
<details>
<summary>Remember Me</summary>
<summary>Remember Me & Settings Sync</summary>
```env
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
SESSION_SECRET_FILE=/session-secret # Path to a file containing the session secret
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).
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.
STALWART_API_URL=https://admin.example.com # If reverse proxy doesn't forward /api/*
LOG_FORMAT=text # "text" or "json"
LOG_LEVEL=info # "error", "warn", "info", "debug"
```
</details>
@@ -319,7 +424,7 @@ Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day exp
## 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
+1 -1
View File
@@ -1 +1 @@
1.4.11
1.4.14
+2 -2
View File
@@ -35,7 +35,7 @@ function OAuthCallbackInner() {
const savedState = sessionStorage.getItem("oauth_state");
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) {
setError("invalid_state");
return;
@@ -76,7 +76,7 @@ function OAuthCallbackInner() {
setError("token_exchange_failed");
});
} 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);
loginWithServerSso(code, state)
.then((success) => {
+52 -11
View File
@@ -41,9 +41,11 @@ import { ResizeHandle } from "@/components/layout/resize-handle";
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
import { getEventStartDate } from "@/lib/calendar-utils";
import { useTaskStore } from "@/stores/task-store";
import { useContactStore } from "@/stores/contact-store";
import { cn } from "@/lib/utils";
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
import { getUserParticipantId } from "@/lib/calendar-participants";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { debug } from "@/lib/debug";
type PendingScopeAction =
@@ -69,10 +71,11 @@ export default function CalendarPage() {
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore();
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview } = useSettingsStore();
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
const taskStore = useTaskStore();
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
const { identities } = useIdentityStore();
const contacts = useContactStore((s) => s.contacts);
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
const currentUserEmails = useMemo(() =>
@@ -109,6 +112,16 @@ export default function CalendarPage() {
// Swipe navigation ref (handlers defined after navigatePrev/navigateNext)
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
useEffect(() => {
checkAuth().finally(() => {
@@ -148,6 +161,13 @@ export default function CalendarPage() {
return () => clearInterval(interval);
}, [client, refreshAllSubscriptions]);
// Auto-add birthday calendar to selected IDs when enabled
useEffect(() => {
if (showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
toggleCalendarVisibility(BIRTHDAY_CALENDAR_ID);
}
}, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps
const dateRange = useMemo(() => {
const d = selectedDate;
switch (normalizedViewMode) {
@@ -743,14 +763,31 @@ export default function CalendarPage() {
return () => window.removeEventListener("keydown", handleKey);
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent, enableCalendarTasks]);
const visibleEvents = useMemo(() =>
events.filter((e) => {
const birthdayEvents = useMemo(() => {
if (!showBirthdayCalendar || !dateRange) return [];
return generateBirthdayEvents(contacts, dateRange.start, dateRange.end);
}, [showBirthdayCalendar, contacts, dateRange]);
const birthdayCalendarName = (() => {
try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
})();
const allCalendars = useMemo(() => {
if (!showBirthdayCalendar) return calendars;
return [...calendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
}, [calendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
const visibleEvents = useMemo(() => {
const filtered = events.filter((e) => {
if (!e.start || !e.calendarIds) return false;
const calIds = Object.keys(e.calendarIds);
return calIds.some((id) => selectedCalendarIds.includes(id));
}),
[events, selectedCalendarIds]
);
});
if (showBirthdayCalendar && selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
return [...filtered, ...birthdayEvents];
}
return filtered;
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents]);
useEffect(() => {
const hiddenEvents = events.filter((event) => {
@@ -799,7 +836,7 @@ export default function CalendarPage() {
<CalendarMonthView
selectedDate={selectedDate}
events={visibleEvents}
calendars={calendars}
calendars={allCalendars}
onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
@@ -815,7 +852,7 @@ export default function CalendarPage() {
<CalendarWeekView
selectedDate={selectedDate}
events={visibleEvents}
calendars={calendars}
calendars={allCalendars}
onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
@@ -834,7 +871,7 @@ export default function CalendarPage() {
<CalendarDayView
selectedDate={selectedDate}
events={visibleEvents}
calendars={calendars}
calendars={allCalendars}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
@@ -851,7 +888,7 @@ export default function CalendarPage() {
<CalendarAgendaView
selectedDate={selectedDate}
events={visibleEvents}
calendars={calendars}
calendars={allCalendars}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
@@ -942,10 +979,14 @@ export default function CalendarPage() {
showWeekNumbers={showWeekNumbers}
/>
<CalendarSidebarPanel
calendars={calendars}
calendars={allCalendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
onColorChange={client ? (calendarId, color) => {
if (calendarId === BIRTHDAY_CALENDAR_ID) {
updateSetting('birthdayCalendarColor', color);
return;
}
updateCalendar(client, calendarId, { color });
} : undefined}
onSubscribe={() => setShowSubscriptionModal(true)}
+49 -2
View File
@@ -13,12 +13,13 @@ import { ContactGroupForm } from "@/components/contacts/contact-group-form";
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { RenameDialog } from "@/components/files/rename-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils";
import { cn, generateUUID } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
@@ -72,12 +73,16 @@ export default function ContactsPage() {
bulkDeleteContacts,
bulkAddToGroup,
moveContactToAddressBook,
renameAddressBook,
renameKeyword,
importContacts,
} = useContactStore();
const [view, setView] = useState<View>("list");
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
const [showImportDialog, setShowImportDialog] = useState(false);
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
@@ -274,7 +279,7 @@ export default function ContactsPage() {
toast.success(t("toast.created"));
} else {
const localContact: ContactCard = {
id: `local-${crypto.randomUUID()}`,
id: `local-${generateUUID()}`,
addressBookIds: {},
...data,
};
@@ -631,6 +636,8 @@ export default function ContactsPage() {
onDeleteGroup={handleDeleteGroupFromSidebar}
onDropContacts={handleDropContacts}
onDropContactsToCategory={handleDropContactsToCategory}
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
/>
</div>
<ResizeHandle
@@ -725,6 +732,46 @@ export default function ContactsPage() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} />
{renamingKeyword !== null && (
<RenameDialog
currentName={renamingKeyword}
title={t("rename_category")}
label={t("category_name_label")}
onCancel={() => setRenamingKeyword(null)}
onConfirm={async (newName) => {
try {
await renameKeyword(supportsSync && client ? client : null, renamingKeyword, newName);
toast.success(t("category_renamed"));
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === renamingKeyword) {
setActiveCategory({ keyword: newName.trim() });
}
setRenamingKeyword(null);
} catch (err) {
console.error("Failed to rename category:", err);
toast.error(t("category_rename_failed"));
}
}}
/>
)}
{renamingAddressBook && (
<RenameDialog
currentName={renamingAddressBook.name}
title={t("address_books.rename")}
label={t("address_books.name_label")}
onCancel={() => setRenamingAddressBook(null)}
onConfirm={async (newName) => {
if (!client) return;
try {
await renameAddressBook(client, renamingAddressBook, newName);
toast.success(t("address_books.renamed"));
setRenamingAddressBook(null);
} catch (err) {
console.error("Failed to rename address book:", err);
toast.error(t("address_books.rename_failed"));
}
}}
/>
)}
{showImportDialog && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
+1 -1
View File
@@ -395,7 +395,7 @@ export default function FilesPage() {
</div>
)}
<div className="flex-1 min-h-0">
<div className="flex-1 min-h-0 flex flex-col">
{!filesEnabled ? (
<div className="flex items-center justify-center h-full">
<div className="max-w-lg text-center space-y-3 px-4">
+46 -12
View File
@@ -10,13 +10,15 @@ import { useAuthStore } from "@/stores/auth-store";
import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config";
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
import { cn } from "@/lib/utils";
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play } from "lucide-react";
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
const APP_VERSION = "1.4.10";
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
const THEME_OPTIONS = [
{ value: "light" as const, icon: Sun, label: "Light" },
@@ -24,6 +26,41 @@ const THEME_OPTIONS = [
{ value: "system" as const, icon: Monitor, label: "System" },
];
function VersionBadge() {
const [copied, setCopied] = useState(false);
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}`;
const handleCopy = () => {
navigator.clipboard.writeText(versionInfo).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div className="relative inline-flex justify-center">
<p className="peer text-center text-xs text-muted-foreground/40 cursor-default">
v{APP_VERSION}
</p>
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border opacity-0 peer-hover:opacity-100 hover:opacity-100 transition-opacity whitespace-nowrap z-10">
<div className="flex items-center gap-2">
<div className="space-y-0.5">
<p>Version: <span className="font-medium">{APP_VERSION}</span></p>
<p>Build: <span className="font-medium">{GIT_COMMIT}</span></p>
</div>
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label="Copy version info"
>
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
</div>
</div>
);
}
export default function LoginPage() {
const router = useRouter();
const t = useTranslations("login");
@@ -195,8 +232,9 @@ export default function LoginPage() {
const startServerSideSso = useCallback(async () => {
setOauthLoading(true);
try {
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
const res = await fetch('/api/auth/sso/start', {
const prefix = getPathPrefix(params.locale as string);
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
const res = await apiFetch('/api/auth/sso/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
@@ -217,7 +255,7 @@ export default function LoginPage() {
try {
window.top!.location.href = authorize_url;
} catch {
// Cross-origin restriction fall back to current frame
// Cross-origin restriction - fall back to current frame
window.location.href = authorize_url;
}
} else {
@@ -579,9 +617,7 @@ export default function LoginPage() {
)}
</div>
)}
<p className="text-center text-xs text-muted-foreground/40">
v{APP_VERSION}
</p>
<VersionBadge />
</div>
</div>
</div>
@@ -723,7 +759,7 @@ export default function LoginPage() {
)}
</Button>
<p className="text-center text-xs text-muted-foreground">
Dev mode logging in as dev@localhost
Dev mode - logging in as dev@localhost
</p>
</div>
) : oauthOnly ? (
@@ -1075,9 +1111,7 @@ export default function LoginPage() {
)}
</div>
)}
<p className="text-center text-xs text-muted-foreground/40">
v{APP_VERSION}
</p>
<VersionBadge />
</div>
</div>
</div>
+288 -43
View File
@@ -9,16 +9,20 @@ import { EmailComposer } from "@/components/email/email-composer";
import type { ComposerDraftData } from "@/components/email/email-composer";
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
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 { useEmailStore } from "@/stores/email-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useUIStore } from "@/stores/ui-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound";
import { cn } from "@/lib/utils";
@@ -38,6 +42,7 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIdentitySync } from "@/hooks/use-identity-sync";
import { Input } from "@/components/ui/input";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview";
@@ -59,6 +64,7 @@ export default function Home() {
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
const [composerDraftText, setComposerDraftText] = useState("");
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
const [composerSessionId, setComposerSessionId] = useState(0);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
@@ -76,6 +82,16 @@ export default function Home() {
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
const { identities } = useIdentityStore();
useIdentitySync();
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore();
// Load trusted senders address book when feature is enabled
useEffect(() => {
if (trustedSendersAddressBook && client && !trustedSendersLoaded) {
loadTrustedSendersBook(client);
}
}, [trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]);
useEffect(() => {
if (!isRateLimited || !rateLimitUntil) {
@@ -141,8 +157,150 @@ export default function Home() {
hasMoreEmails,
fetchTagCounts,
fetchEmailContent,
isUnifiedView,
fetchUnifiedEmails: fetchUnifiedEmailsAction,
refreshUnifiedCounts,
exitUnifiedView,
} = 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
// latest values from a ref so we don't have to recreate the callback on
// every render (and so the popstate listener is never stale).
const navRestoreStateRef = useRef({
client,
emails,
mailboxes,
selectedMailbox,
selectedEmailId: selectedEmail?.id ?? null,
conversationThreadId: null as string | null,
});
navRestoreStateRef.current.client = client;
navRestoreStateRef.current.emails = emails;
navRestoreStateRef.current.mailboxes = mailboxes;
navRestoreStateRef.current.selectedMailbox = selectedMailbox;
navRestoreStateRef.current.selectedEmailId = selectedEmail?.id ?? null;
navRestoreStateRef.current.conversationThreadId = conversationThread?.threadId ?? null;
const handleNavRestore = useCallback(async (state: NavSnapshot) => {
const ctx = navRestoreStateRef.current;
// Restore sidebar overlay state.
setSidebarOpen(state.sidebarOpen);
// Restore composer visibility.
if (!state.composerOpen) {
setShowComposer(false);
}
// Derive the mobile view from the saved snapshot. The view is a
// function of which content the user is looking at: an email, a
// thread, the composer, or the bare list.
const derivedView: "list" | "viewer" =
state.emailId || state.threadId || state.composerOpen ? "viewer" : "list";
setActiveView(derivedView);
// Restore mailbox selection. selectMailbox clears the current email,
// which is fine because we re-apply the saved email below.
if (state.mailboxId && state.mailboxId !== ctx.selectedMailbox) {
selectMailbox(state.mailboxId);
if (ctx.client) {
try {
await fetchEmails(ctx.client, state.mailboxId);
} catch (error) {
debug.error('Failed to fetch emails on history restore:', error);
}
}
}
// Restore conversation thread (mobile only). We can clear it directly,
// but reopening requires the thread group; if the user pressed forward
// to return to a thread, we silently skip - back navigation always works.
if ((state.threadId ?? null) !== ctx.conversationThreadId) {
if (state.threadId === null) {
setConversationThread(null);
setConversationEmails([]);
}
}
// Restore email selection.
if (state.emailId !== ctx.selectedEmailId) {
if (state.emailId === null) {
selectEmail(null);
} else {
// Try the in-memory list first; the existing useEffect will fetch
// body content if it's missing.
const found = ctx.emails.find(e => e.id === state.emailId);
if (found) {
selectEmail(found);
} else if (ctx.client) {
// Email isn't in the current list (e.g. mailbox just changed).
// Fetch it directly.
try {
const mailbox = ctx.mailboxes.find(mb => mb.id === state.mailboxId);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const fullEmail = await ctx.client.getEmail(state.emailId, accountId);
if (fullEmail) selectEmail(fullEmail);
} catch (error) {
debug.error('Failed to fetch email on history restore:', error);
}
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useBrowserNavigation({
mailboxId: selectedMailbox,
emailId: selectedEmail?.id ?? null,
threadId: conversationThread?.threadId ?? null,
composerOpen: showComposer,
sidebarOpen,
onRestore: handleNavRestore,
enabled: isAuthenticated && mailboxes.length > 0,
});
// Keyboard shortcuts handlers
const keyboardHandlers = useMemo(() => ({
onNextEmail: () => {
@@ -375,13 +533,30 @@ export default function Home() {
};
}, [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)
useEffect(() => {
if (!selectedEmail || !client) return;
// If the email lacks bodyValues, it was auto-selected from the list and needs full content
if (!selectedEmail.bodyValues) {
const perAccountClient = isUnifiedView && selectedEmail.accountId
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
: undefined;
const fetchClient = perAccountClient ?? client;
setLoadingEmail(true);
fetchEmailContent(client, selectedEmail.id).finally(() => {
fetchEmailContent(fetchClient, selectedEmail.id).finally(() => {
setLoadingEmail(false);
});
}
@@ -543,6 +718,16 @@ export default function Home() {
const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId]
? draft.bodyValues[draft.htmlBody[0].partId].value
: undefined;
// Try to find the identity that matches the draft's from address to preserve it
const draftFromEmail = draft.from?.[0]?.email;
const matchedIdentity = draftFromEmail
? identities.find(id => id.email === draftFromEmail)
: null;
// Increment session ID to force the composer to remount with fresh state,
// even if it was already open (e.g. right-clicking a draft while composing).
setComposerSessionId(id => id + 1);
setPendingDraft({
to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '',
cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '',
@@ -551,7 +736,7 @@ export default function Home() {
body: htmlBody || bodyText,
showCc: (draft.cc?.length || 0) > 0,
showBcc: (draft.bcc?.length || 0) > 0,
selectedIdentityId: null,
selectedIdentityId: matchedIdentity?.id ?? null,
subAddressTag: '',
mode: 'compose',
draftId: draft.id,
@@ -573,8 +758,8 @@ export default function Home() {
if (isMobile) setActiveView('viewer');
};
const handleDelete = async () => {
if (!client || !selectedEmail) return;
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
if (!client || !emailToDelete) return;
// Check if we're currently in the trash or junk folder
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
@@ -593,7 +778,7 @@ export default function Home() {
if (!confirmed) return;
try {
await deleteEmail(client, selectedEmail.id, true);
await deleteEmail(client, emailToDelete.id, true);
} catch (error) {
console.error("Failed to permanently delete email:", error);
}
@@ -602,7 +787,7 @@ export default function Home() {
const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared);
if (trashMailbox) {
try {
await moveToMailbox(client, selectedEmail.id, trashMailbox.id);
await moveToMailbox(client, emailToDelete.id, trashMailbox.id);
} catch (error) {
console.error("Failed to move email to trash:", error);
}
@@ -641,7 +826,7 @@ export default function Home() {
if (archiveMode === 'year') {
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
} 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;
let monthMailbox = mailboxes.find(
m => m.name === month && m.parentId === yearId
@@ -675,10 +860,10 @@ export default function Home() {
}
};
const handleMarkAsSpam = async () => {
if (!client || !selectedEmail) return;
const handleMarkAsSpam = async (emailToMark: Email | null = selectedEmail) => {
if (!client || !emailToMark) return;
const emailId = selectedEmail.id;
const emailId = emailToMark.id;
try {
await markAsSpam(client, emailId);
@@ -706,11 +891,11 @@ export default function Home() {
}
};
const handleUndoSpam = async () => {
if (!client || !selectedEmail) return;
const handleUndoSpam = async (emailToRestore: Email | null = selectedEmail) => {
if (!client || !emailToRestore) return;
try {
await undoSpam(client, selectedEmail.id);
await undoSpam(client, emailToRestore.id);
const toastInstance = (await import('sonner')).toast;
toastInstance.success(t('email_viewer.spam.toast_not_spam_success'));
@@ -731,16 +916,22 @@ export default function Home() {
const keywords = { ...email.keywords };
// Remove old label and legacy color tags - set to false for JMAP to remove them
Object.keys(keywords).forEach(key => {
if (key.startsWith("$label:") || key.startsWith("$color:")) {
keywords[key] = false;
if (color === null) {
// Remove all label/color tags
Object.keys(keywords).forEach(key => {
if (key.startsWith("$label:") || key.startsWith("$color:")) {
keywords[key] = false;
}
});
} else {
const jmapKey = `$label:${color}`;
if (keywords[jmapKey] === true) {
// Toggle off if already active
keywords[jmapKey] = false;
} else {
// Add the tag without disturbing others
keywords[jmapKey] = true;
}
});
// Add new label tag if specified (using new $label: prefix)
if (color) {
keywords[`$label:${color}`] = true;
}
// Update email keywords via JMAP
@@ -760,6 +951,32 @@ export default function Home() {
};
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);
selectEmail(null); // Clear selected email when switching mailboxes
@@ -843,6 +1060,7 @@ export default function Home() {
const handleSearch = async (query: string) => {
if (!client) return;
if (isUnifiedView) return;
setSearchQuery(query);
if (!isFilterEmpty(searchFilters)) {
await advancedSearch(client);
@@ -861,6 +1079,7 @@ export default function Home() {
const handleAdvancedSearch = async () => {
if (!client) return;
if (isUnifiedView) return;
await advancedSearch(client);
};
@@ -870,9 +1089,9 @@ export default function Home() {
clearTimeout(advancedSearchDebounceRef.current);
}
advancedSearchDebounceRef.current = setTimeout(() => {
if (client) advancedSearch(client);
if (client && !isUnifiedView) advancedSearch(client);
}, 300);
}, [client, advancedSearch]);
}, [client, advancedSearch, isUnifiedView]);
useEffect(() => {
return () => {
@@ -1001,13 +1220,29 @@ export default function Home() {
// Fetch the full content
try {
// Find selected mailbox to determine accountId (for shared folders)
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
// Only pass accountId for shared mailboxes
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// In unified view each email carries its own accountId. Use that
// account's client so we fetch from the server that actually owns it.
const listEmail = emails.find(e => e.id === email.id);
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 (emailAccountId) {
fullEmail.accountId = emailAccountId;
fullEmail.accountLabel = listEmail?.accountLabel;
}
selectEmail(fullEmail);
// Mark-as-read logic is now handled by useEffect
}
@@ -1018,9 +1253,16 @@ export default function Home() {
}
};
// Handle back navigation from viewer on mobile
// Handle back navigation from viewer on mobile.
// Delegate to the browser history stack so this button is equivalent to
// the OS back button / mouse back button - popstate then restores the
// previous snapshot via handleNavRestore. The viewer is only reachable
// from a state that pushed history, so back() always lands on an app entry.
const handleMobileBack = () => {
// If in conversation view, clear it
if (typeof window !== 'undefined') {
window.history.back();
return;
}
if (conversationThread) {
setConversationThread(null);
setConversationEmails([]);
@@ -1209,11 +1451,12 @@ export default function Home() {
"max-md:flex-1 max-md:border-r-0",
isMobile && activeView !== "list" && "max-md:hidden",
// Tablet/Desktop: fixed width with collapse animation
"md:flex-shrink-0 md:shadow-sm",
shouldHideViewerPane ? "md:flex-1 md:border-r-0" : "md:flex-shrink-0",
"md:shadow-sm",
!isResizing && "transition-all duration-200 ease-out",
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
)}
style={!isMobile && !shouldCollapseListPane ? { width: emailListWidth } : undefined}
style={!isMobile && !shouldCollapseListPane && !shouldHideViewerPane ? { width: emailListWidth } : undefined}
>
{/* Mobile Header for List View */}
<MobileHeader
@@ -1263,6 +1506,8 @@ export default function Home() {
className={cn("pl-9 h-9", searchQuery && "pr-8")}
data-search-input
data-tour="search-input"
disabled={isUnifiedView}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : undefined}
/>
{searchQuery && (
<button
@@ -1278,13 +1523,15 @@ export default function Home() {
<button
type="button"
onClick={toggleAdvancedSearch}
disabled={isUnifiedView}
className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors",
isUnifiedView && "opacity-50 cursor-not-allowed",
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
? "bg-primary/10 text-primary"
: "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" />
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
@@ -1468,8 +1715,7 @@ export default function Home() {
}
}}
onDelete={async (email) => {
selectEmail(email);
await handleDelete();
await handleDelete(email);
}}
onArchive={async (email) => {
await handleArchive(email);
@@ -1483,12 +1729,10 @@ export default function Home() {
}
}}
onMarkAsSpam={async (email) => {
selectEmail(email);
await handleMarkAsSpam();
await handleMarkAsSpam(email);
}}
onUndoSpam={async (email) => {
selectEmail(email);
await handleUndoSpam();
await handleUndoSpam(email);
}}
onEditDraft={(email) => {
handleEditDraft(email);
@@ -1549,8 +1793,9 @@ export default function Home() {
}}
>
<EmailComposer
key={composerSessionId}
mode={pendingDraft?.mode ?? composerMode}
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
replyTo={pendingDraft !== null ? pendingDraft.replyTo : (selectedEmail ? {
from: selectedEmail.from,
replyToAddresses: selectedEmail.replyTo,
to: selectedEmail.to,
@@ -1653,7 +1898,7 @@ export default function Home() {
onReplyAll={handleReplyAll}
onForward={handleForward}
onDelete={handleDelete}
onArchive={handleArchive}
onArchive={() => handleArchive()}
onToggleStar={handleToggleStar}
onSetColorTag={handleSetColorTag}
onMarkAsSpam={handleMarkAsSpam}
+2 -1
View File
@@ -36,6 +36,7 @@ import { IdentitySettings } from '@/components/settings/identity-settings';
import { VacationSettings } from '@/components/settings/vacation-settings';
import { CalendarSettings } from '@/components/settings/calendar-settings';
import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings';
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
import { FilterSettings } from '@/components/settings/filter-settings';
import { TemplateSettings } from '@/components/settings/template-settings';
import { AdvancedSettings } from '@/components/settings/advanced-settings';
@@ -211,7 +212,7 @@ export default function SettingsPage() {
{activeTab === 'encryption' && <SmimeSettings />}
{activeTab === 'vacation' && <VacationSettings />}
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{activeTab === 'contacts' && <ContactsSettings />}
{activeTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{activeTab === 'filters' && <FilterSettings />}
{activeTab === 'templates' && <TemplateSettings />}
{activeTab === 'folders' && <FolderSettings />}
+4 -3
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { Save, Loader2, RotateCcw } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry {
value: unknown;
@@ -19,7 +20,7 @@ export default function AdminAuthPage() {
async function fetchConfig() {
setLoading(true);
const res = await fetch('/api/admin/config');
const res = await apiFetch('/api/admin/config');
if (res.ok) setConfig(await res.json());
setLoading(false);
}
@@ -39,7 +40,7 @@ export default function AdminAuthPage() {
setSaving(true);
setMessage(null);
const res = await fetch('/api/admin/config', {
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits),
@@ -57,7 +58,7 @@ export default function AdminAuthPage() {
}
async function handleRevert(key: string) {
const res = await fetch('/api/admin/config', {
const res = await apiFetch('/api/admin/config', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
+6 -5
View File
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from 'react';
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry {
value: unknown;
@@ -38,7 +39,7 @@ export default function AdminBrandingPage() {
async function fetchConfig() {
setLoading(true);
const res = await fetch('/api/admin/config');
const res = await apiFetch('/api/admin/config');
if (res.ok) setConfig(await res.json());
setLoading(false);
}
@@ -58,7 +59,7 @@ export default function AdminBrandingPage() {
setSaving(true);
setMessage(null);
const res = await fetch('/api/admin/config', {
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits),
@@ -83,7 +84,7 @@ export default function AdminBrandingPage() {
formData.append('file', file);
formData.append('slot', slot);
const res = await fetch('/api/admin/branding', {
const res = await apiFetch('/api/admin/branding', {
method: 'POST',
body: formData,
});
@@ -112,7 +113,7 @@ export default function AdminBrandingPage() {
async function handleDeleteUpload(slot: string) {
setMessage(null);
const res = await fetch('/api/admin/branding', {
const res = await apiFetch('/api/admin/branding', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slot }),
@@ -133,7 +134,7 @@ export default function AdminBrandingPage() {
}
async function handleRevert(key: string) {
const res = await fetch('/api/admin/config', {
const res = await apiFetch('/api/admin/config', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
+2 -1
View File
@@ -3,6 +3,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Lock } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
export default function ChangePasswordPage() {
const router = useRouter();
@@ -28,7 +29,7 @@ export default function ChangePasswordPage() {
}
setLoading(true);
const res = await fetch('/api/admin/change-password', {
const res = await apiFetch('/api/admin/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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 { useAuthStore } from '@/stores/auth-store';
import { apiFetch } from '@/lib/browser-navigation';
const NAV_GROUPS = [
{
@@ -85,7 +86,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
async function checkAuth() {
try {
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 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 (stalwartAdmin) {
const loginRes = await fetch('/api/admin/auth', {
const loginRes = await apiFetch('/api/admin/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
body: JSON.stringify({ stalwartAuth: true }),
@@ -122,7 +123,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
}
async function handleLogout() {
await fetch('/api/admin/auth', { method: 'DELETE' });
await apiFetch('/api/admin/auth', { method: 'DELETE' });
router.replace('/admin/login');
}
+2 -1
View File
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import { Shield } from 'lucide-react';
import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store';
import { apiFetch } from '@/lib/browser-navigation';
export default function AdminLoginPage() {
const router = useRouter();
@@ -21,7 +22,7 @@ export default function AdminLoginPage() {
setLoading(true);
try {
const res = await fetch('/api/admin/auth', {
const res = await apiFetch('/api/admin/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
+3 -2
View File
@@ -3,6 +3,7 @@
import { useEffect, useState, useCallback } from 'react';
import { RefreshCw } from 'lucide-react';
import type { AuditEntry } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
export default function AdminLogsPage() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
@@ -17,7 +18,7 @@ export default function AdminLogsPage() {
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
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) {
const data = await res.json();
setEntries(data.entries || []);
@@ -138,7 +139,7 @@ export default function AdminLogsPage() {
}
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.key) return `${detail.key}: ${JSON.stringify(detail.old)}${JSON.stringify(detail.new)}`;
if (detail.changes && Array.isArray(detail.changes)) {
+4 -3
View File
@@ -2,6 +2,7 @@
import { useEffect, useState, useCallback } from 'react';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface Extension {
slug: string;
@@ -57,7 +58,7 @@ export default function AdminMarketplacePage() {
params.set('perPage', String(perPage));
params.set('sort', 'newest');
const res = await fetch(`/api/admin/marketplace?${params}`);
const res = await apiFetch(`/api/admin/marketplace?${params}`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.error || 'Failed to connect to extension directory');
@@ -95,7 +96,7 @@ export default function AdminMarketplacePage() {
setMessage(null);
try {
const res = await fetch('/api/admin/marketplace', {
const res = await apiFetch('/api/admin/marketplace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -116,7 +117,7 @@ export default function AdminMarketplacePage() {
setMessage({ type: 'error', text: data.error || 'Installation failed' });
}
} catch {
setMessage({ type: 'error', text: 'Installation failed network error' });
setMessage({ type: 'error', text: 'Installation failed - network error' });
} finally {
setInstalling(null);
}
+13 -12
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { AlertTriangle } from 'lucide-react';
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
import type { AuditEntry } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
interface AdminStatus {
enabled: boolean;
@@ -38,13 +39,13 @@ export default function AdminDashboardPage() {
async function fetchDashboardData() {
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([
fetch('/api/admin/auth'),
fetch('/api/admin/audit?limit=10'),
fetch('/api/config'),
fetch('/api/admin/config'),
fetch('/api/admin/plugins').catch(() => null),
fetch('/api/admin/themes').catch(() => null),
fetch('/api/admin/policy').catch(() => null),
apiFetch('/api/admin/auth'),
apiFetch('/api/admin/audit?limit=10'),
apiFetch('/api/config'),
apiFetch('/api/admin/config'),
apiFetch('/api/admin/plugins').catch(() => null),
apiFetch('/api/admin/themes').catch(() => null),
apiFetch('/api/admin/policy').catch(() => null),
]);
if (statusRes.ok) setStatus(await statusRes.json());
@@ -75,7 +76,7 @@ export default function AdminDashboardPage() {
if (configData?.jmapServerUrl) {
try {
const jmapRes = await fetch('/api/config');
const jmapRes = await apiFetch('/api/config');
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
} catch {
setJmapHealth('error');
@@ -98,8 +99,8 @@ export default function AdminDashboardPage() {
setWarnings(w);
}
const jmapUrl = config?.jmapServerUrl || '';
const jmapHostname = jmapUrl !== '' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '';
const jmapUrl = config?.jmapServerUrl || '-';
const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-';
return (
<div className="max-w-3xl space-y-8">
@@ -126,9 +127,9 @@ export default function AdminDashboardPage() {
{/* Server Info */}
<SettingsSection title="Server" description="Application and connection details">
<SettingItem label="Application">
<span className="text-sm text-foreground">{config?.appName || ''}</span>
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
</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>
</SettingItem>
<SettingItem label="JMAP Connection">
+6 -5
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
import Link from 'next/link';
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigField {
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
@@ -68,8 +69,8 @@ export default function PluginConfigPage() {
setLoading(true);
try {
const [pluginsRes, configRes] = await Promise.all([
fetch('/api/admin/plugins'),
fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
apiFetch('/api/admin/plugins'),
apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
]);
if (pluginsRes.ok) {
@@ -117,7 +118,7 @@ export default function PluginConfigPage() {
// Delete if clearing a non-required field
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
@@ -130,7 +131,7 @@ export default function PluginConfigPage() {
continue;
}
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
@@ -230,7 +231,7 @@ export default function PluginConfigPage() {
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value=""> Select </option>
<option value="">- Select -</option>
{field.options.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
+31 -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 type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
interface PluginEntry {
id: string;
@@ -34,7 +35,7 @@ export default function AdminPluginsPage() {
async function fetchPolicy() {
try {
const res = await fetch('/api/admin/policy');
const res = await apiFetch('/api/admin/policy');
if (res.ok) {
const data = await res.json();
setPolicy(data);
@@ -60,11 +61,20 @@ export default function AdminPluginsPage() {
setMessage(null);
}
function toggleRequirePluginApproval() {
setPolicy(prev => ({
...prev,
features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval },
}));
setPolicyDirty(true);
setMessage(null);
}
async function handleSavePolicy() {
setSavingPolicy(true);
setMessage(null);
try {
const res = await fetch('/api/admin/policy', {
const res = await apiFetch('/api/admin/policy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policy),
@@ -86,7 +96,7 @@ export default function AdminPluginsPage() {
async function fetchPlugins() {
setLoading(true);
try {
const res = await fetch('/api/admin/plugins');
const res = await apiFetch('/api/admin/plugins');
if (res.ok) setPlugins(await res.json());
} finally {
setLoading(false);
@@ -104,7 +114,7 @@ export default function AdminPluginsPage() {
formData.append('file', file);
try {
const res = await fetch('/api/admin/plugins', {
const res = await apiFetch('/api/admin/plugins', {
method: 'POST',
body: formData,
});
@@ -127,7 +137,7 @@ export default function AdminPluginsPage() {
async function togglePlugin(id: string, enabled: boolean) {
setMessage(null);
const res = await fetch('/api/admin/plugins', {
const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, enabled }),
@@ -147,7 +157,7 @@ export default function AdminPluginsPage() {
const body: Record<string, unknown> = { id, forceEnabled };
if (forceEnabled) body.enabled = true;
const res = await fetch('/api/admin/plugins', {
const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -181,7 +191,7 @@ export default function AdminPluginsPage() {
}
let failed = 0;
for (const p of disabled) {
const res = await fetch('/api/admin/plugins', {
const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: p.id, enabled: true }),
@@ -207,7 +217,7 @@ export default function AdminPluginsPage() {
}
let failed = 0;
for (const p of enabled) {
const res = await fetch('/api/admin/plugins', {
const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: p.id, enabled: false }),
@@ -227,7 +237,7 @@ export default function AdminPluginsPage() {
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
setMessage(null);
const res = await fetch('/api/admin/plugins', {
const res = await apiFetch('/api/admin/plugins', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
@@ -248,6 +258,7 @@ export default function AdminPluginsPage() {
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
const requirePluginApproval = policy.features.requirePluginApproval ?? true;
return (
<div className="space-y-6">
@@ -320,6 +331,17 @@ export default function AdminPluginsPage() {
</button>
</div>
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<span className="text-sm text-foreground">Require Admin Approval</span>
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
</div>
<button onClick={toggleRequirePluginApproval}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${requirePluginApproval ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${requirePluginApproval ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
{/* Force enable / disable all */}
{plugins.length > 0 && (
<div className="px-4 py-3 flex items-center justify-between gap-4">
+3 -2
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { Save, Loader2, Lock } from 'lucide-react';
import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
// Feature gates managed on their own admin pages (excluded from this list)
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
@@ -54,7 +55,7 @@ export default function AdminPolicyPage() {
async function fetchPolicy() {
setLoading(true);
try {
const res = await fetch('/api/admin/policy');
const res = await apiFetch('/api/admin/policy');
if (res.ok) {
const data = await res.json();
setPolicy(data);
@@ -106,7 +107,7 @@ export default function AdminPolicyPage() {
setSaving(true);
setMessage(null);
const res = await fetch('/api/admin/policy', {
const res = await apiFetch('/api/admin/policy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policy),
+4 -3
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { Save, RotateCcw, Loader2 } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry {
value: unknown;
@@ -21,7 +22,7 @@ export default function AdminSettingsPage() {
async function fetchConfig() {
setLoading(true);
const res = await fetch('/api/admin/config');
const res = await apiFetch('/api/admin/config');
if (res.ok) {
setConfig(await res.json());
}
@@ -43,7 +44,7 @@ export default function AdminSettingsPage() {
setSaving(true);
setMessage(null);
const res = await fetch('/api/admin/config', {
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits),
@@ -61,7 +62,7 @@ export default function AdminSettingsPage() {
}
async function handleRevert(key: string) {
const res = await fetch('/api/admin/config', {
const res = await apiFetch('/api/admin/config', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
+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 type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
const BUILTIN_THEME_OPTIONS = [
{ id: 'builtin-nord', name: 'Nord' },
@@ -38,7 +39,7 @@ export default function AdminThemesPage() {
async function fetchPolicy() {
try {
const res = await fetch('/api/admin/policy');
const res = await apiFetch('/api/admin/policy');
if (res.ok) {
const data = await res.json();
setPolicy({
@@ -122,7 +123,7 @@ export default function AdminThemesPage() {
setSavingPolicy(true);
setMessage(null);
try {
const res = await fetch('/api/admin/policy', {
const res = await apiFetch('/api/admin/policy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policy),
@@ -144,7 +145,7 @@ export default function AdminThemesPage() {
async function fetchThemes() {
setLoading(true);
try {
const res = await fetch('/api/admin/themes');
const res = await apiFetch('/api/admin/themes');
if (res.ok) setThemes(await res.json());
} finally {
setLoading(false);
@@ -162,7 +163,7 @@ export default function AdminThemesPage() {
formData.append('file', file);
try {
const res = await fetch('/api/admin/themes', {
const res = await apiFetch('/api/admin/themes', {
method: 'POST',
body: formData,
});
@@ -185,7 +186,7 @@ export default function AdminThemesPage() {
async function toggleTheme(id: string, enabled: boolean) {
setMessage(null);
const res = await fetch('/api/admin/themes', {
const res = await apiFetch('/api/admin/themes', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, enabled }),
@@ -204,7 +205,7 @@ export default function AdminThemesPage() {
const body: Record<string, unknown> = { id, forceEnabled };
if (forceEnabled) body.enabled = true;
const res = await fetch('/api/admin/themes', {
const res = await apiFetch('/api/admin/themes', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -237,7 +238,7 @@ export default function AdminThemesPage() {
}
let failed = 0;
for (const t of disabled) {
const res = await fetch('/api/admin/themes', {
const res = await apiFetch('/api/admin/themes', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: t.id, enabled: true }),
@@ -262,7 +263,7 @@ export default function AdminThemesPage() {
}
let failed = 0;
for (const t of enabled) {
const res = await fetch('/api/admin/themes', {
const res = await apiFetch('/api/admin/themes', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
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;
setMessage(null);
const res = await fetch('/api/admin/themes', {
const res = await apiFetch('/api/admin/themes', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
+17 -2
View File
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/**
* GET /api/account/stalwart/auth
* Proxy to Stalwart GET /api/account/auth
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart auth info failed', { status: response.status });
const detail = parseStalwartError(text);
logger.warn('Stalwart auth info failed', { status: response.status, detail });
return NextResponse.json(
{ error: 'Failed to fetch auth info', details: text },
{ error: detail || 'Failed to fetch auth info' },
{ status: response.status }
);
}
+17 -2
View File
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/**
* GET /api/account/stalwart/crypto
* Proxy to Stalwart GET /api/account/crypto
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart crypto info failed', { status: response.status });
const detail = parseStalwartError(text);
logger.warn('Stalwart crypto info failed', { status: response.status, detail });
return NextResponse.json(
{ error: 'Failed to fetch crypto info', details: text },
{ error: detail || 'Failed to fetch crypto info' },
{ status: response.status }
);
}
+17 -2
View File
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/**
* GET /api/account/stalwart/principal
* Proxy to Stalwart GET /api/principal/{username}
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart principal fetch failed', { status: response.status });
const detail = parseStalwartError(text);
logger.warn('Stalwart principal fetch failed', { status: response.status, detail });
return NextResponse.json(
{ error: 'Failed to fetch principal', details: text },
{ error: detail || 'Failed to fetch principal' },
{ status: response.status }
);
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { readAuditLog } from '@/lib/admin/audit';
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) {
try {
+3 -3
View File
@@ -30,7 +30,7 @@ async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
}
/**
* POST /api/admin/auth Login
* POST /api/admin/auth - Login
*/
export async function POST(request: NextRequest) {
try {
@@ -92,7 +92,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).
*/
export async function GET(request: NextRequest) {
@@ -136,7 +136,7 @@ export async function GET(request: NextRequest) {
}
/**
* DELETE /api/admin/auth Logout
* DELETE /api/admin/auth - Logout
*/
export async function DELETE(request: NextRequest) {
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.
* 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:
* - 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 }
*/
+1 -1
View File
@@ -5,7 +5,7 @@ import { auditLog } from '@/lib/admin/audit';
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) {
try {
+3 -3
View File
@@ -6,7 +6,7 @@ import { CONFIG_ENV_MAP } from '@/lib/admin/types';
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() {
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) {
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) {
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';
/**
* GET /api/admin/marketplace Search/browse the extension directory
* GET /api/admin/marketplace - Search/browse the extension directory
* Proxies to the extension directory API
*/
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' }
*/
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';
/**
* 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.
* 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';
/**
* 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.
* 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 }
* 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 }
*/
+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() {
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) {
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 }
*/
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 }
*/
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';
/**
* 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() {
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) {
try {
+1 -1
View File
@@ -5,7 +5,7 @@ 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.
* Probes the admin-only principal-list endpoint - if the user can access it, they're an admin.
*/
export async function GET(request: NextRequest) {
try {
+1 -1
View File
@@ -3,7 +3,7 @@ import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry';
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(
_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';
/**
* GET /api/admin/themes List all admin-managed themes
* GET /api/admin/themes - List all admin-managed themes
*/
export async function GET() {
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) {
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 }
*/
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 }
*/
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
* browser fetch() requests succeed. Non-browser clients cannot forge these.
*/
+2 -1
View File
@@ -7,13 +7,14 @@ import { getRequiredConfig } from '@/lib/oauth/token-exchange';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
export async function POST(request: NextRequest) {
try {
if (!process.env.SESSION_SECRET) {
if (!process.env.SESSION_SECRET && !readFileEnv(process.env.SESSION_SECRET_FILE)) {
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
}
+3 -2
View File
@@ -4,6 +4,7 @@ import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
/**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
@@ -65,7 +66,7 @@ async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
return url;
}
} catch {
// Network error endpoint not reachable
// Network error - endpoint not reachable
}
}
@@ -113,7 +114,7 @@ async function attemptAllStrategies(
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
const clientId = process.env.OAUTH_CLIENT_ID;
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
const clientSecret = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const attempts: Array<{ strategy: string; error: string }> = [];
+3 -2
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env';
/**
* Runtime configuration endpoint
@@ -33,8 +34,8 @@ export async function GET() {
oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
rememberMeEnabled: !!process.env.SESSION_SECRET,
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && !!process.env.SESSION_SECRET,
rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
stalwartFeaturesEnabled,
devMode: configManager.get<boolean>('devMode', false),
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
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';
/**
* 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.
* 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() {
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 });
}
}
+24 -7
View File
@@ -3,8 +3,10 @@ import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { decryptSession } from '@/lib/auth/crypto';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env';
function classifyError(error: unknown): { message: string; status: number } {
const code = (error as NodeJS.ErrnoException).code;
@@ -47,24 +49,39 @@ function classifyError(error: unknown): { message: string; status: number } {
}
function isEnabled(): boolean {
return process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET;
return process.env.SETTINGS_SYNC_ENABLED === 'true' && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE));
}
/** Strip trailing slashes so differently-formatted URLs still match. */
function normalizeUrl(url: string): string {
return url.replace(/\/+$/, '');
}
/**
* Verify identity against session cookies across all account slots.
* With multi-account, the requesting account may be on any slot (0-4).
* Returns true only if a matching session cookie is found.
* Checks both basic-auth session cookies and stalwart auth context cookies
* (used by OAuth/SSO and TOTP-upgraded sessions).
* Returns true only if a matching cookie is found.
*/
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
const cookieStore = await cookies();
const normalizedServerUrl = normalizeUrl(serverUrl);
for (let slot = 0; slot <= 4; slot++) {
// Check basic-auth session cookie
const token = cookieStore.get(sessionCookieName(slot))?.value;
if (!token) continue;
if (token) {
const session = decryptSession(token);
if (session && session.username === username && normalizeUrl(session.serverUrl) === normalizedServerUrl) {
return true;
}
}
const session = decryptSession(token);
if (session && session.username === username && session.serverUrl === serverUrl) {
return true; // Found a matching slot
// Check stalwart auth context cookie (set for all auth modes)
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
if (ctx && ctx.username === username && normalizeUrl(ctx.serverUrl) === normalizedServerUrl) {
return true;
}
}
@@ -118,7 +135,7 @@ export async function POST(request: NextRequest) {
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();
const policy = configManager.getPolicy();
const filteredSettings = { ...settings };
+10 -4
View File
@@ -79,10 +79,14 @@ export async function POST(request: NextRequest) {
const overwrite = request.headers.get('Overwrite');
if (overwrite) upstreamHeaders['Overwrite'] = overwrite;
// Forward request body for methods that need it
let body: ArrayBuffer | null = null;
if (method === 'PROPFIND' || method === 'PUT') {
// Forward request body for methods that need it.
// PUT streams directly to upstream to avoid buffering large uploads in memory.
// PROPFIND bodies are small XML and are read fully.
let body: ArrayBuffer | ReadableStream<Uint8Array> | null = null;
if (method === 'PROPFIND') {
body = await request.arrayBuffer();
} else if (method === 'PUT') {
body = request.body;
}
const response = await fetch(targetUrl, {
@@ -90,7 +94,9 @@ export async function POST(request: NextRequest) {
headers: upstreamHeaders,
body,
redirect: 'follow',
});
// `duplex: 'half'` is required by undici when sending a streaming request body.
...(method === 'PUT' ? { duplex: 'half' } : {}),
} as Parameters<typeof fetch>[1] & { duplex?: 'half' });
// For file downloads (GET), stream the response back
if (method === 'GET') {
+5
View File
@@ -208,6 +208,11 @@ body {
padding: 1rem 1.25rem;
}
.email-content-text a {
color: var(--color-primary);
text-decoration: underline;
}
.email-content {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
+19 -2
View File
@@ -1,3 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Gravit.io -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clipPath_Sdk6Fary7vPYZxd20WRVIKca0qb2ogEk"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clipPath_Sdk6Fary7vPYZxd20WRVIKca0qb2ogEk)"><rect width="1000" height="1000" style="fill:rgb(0,0,0)" fill-opacity="0"/><path d=" M 483.028 563.647 L 63.713 187.183 C 59.029 182.978 55.226 174.454 55.226 168.159 L 55.226 122.542 C 55.226 116.247 60.206 109.988 66.339 108.573 L 215.181 74.225 C 221.314 72.809 226.293 76.77 226.293 83.065 L 226.293 176.92 L 352.034 147.895 C 358.167 146.479 363.147 140.219 363.147 133.925 L 363.147 51.483 C 363.147 45.189 368.126 38.93 374.259 37.514 L 488.888 11.061 C 495.021 9.646 504.979 9.646 511.112 11.061 L 625.741 37.514 C 631.874 38.93 636.853 45.189 636.853 51.483 L 636.853 133.925 C 636.853 140.219 641.833 146.479 647.966 147.895 L 773.707 176.92 L 773.707 83.065 C 773.707 76.77 778.686 72.809 784.819 74.225 L 933.661 108.573 C 939.794 109.988 944.774 116.247 944.774 122.542 L 944.774 168.159 C 944.774 174.454 940.971 182.978 936.287 187.183 L 516.972 563.647 C 507.605 572.056 492.395 572.056 483.028 563.647 Z " fill="rgb(219,45,84)"/><path d=" M 944.774 332.832 L 944.774 396.969 C 944.774 403.263 941.16 411.987 936.709 416.437 L 884.411 468.736 C 879.96 473.186 876.347 481.91 876.347 488.204 L 876.347 682.08 C 876.345 718.462 866.664 750.017 847.953 778.668 L 658.833 589.547 L 944.774 332.832 Z " fill="rgb(219,45,84)"/><path d=" M 55.226 332.832 L 55.226 385.564 C 55.226 398.153 62.453 415.6 71.355 424.501 L 107.525 460.671 C 116.426 469.573 123.653 487.02 123.653 499.609 L 123.653 682.08 C 123.655 718.462 133.336 750.017 152.047 778.668 L 341.167 589.547 L 55.226 332.832 Z " fill="rgb(219,45,84)"/><path d=" M 765.645 857.641 C 701.996 901.327 612.26 941.932 500 990 Q 500 990 500 990 C 387.74 941.932 298.004 901.327 234.355 857.641 L 427.917 664.079 C 435.604 668.703 443.74 672.579 452.215 675.636 C 467.543 681.179 483.703 684.007 500 683.996 C 516.297 684.007 532.457 681.179 547.785 675.636 C 556.261 672.579 564.396 668.703 572.083 664.079 L 765.645 857.641 Z " fill="rgb(219,45,84)"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000">
<defs>
<clipPath id="_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV"><rect width="1000" height="1000"/></clipPath>
<style>
.icon-bg { fill: #ffffff; }
.icon-mark { fill: rgb(219,45,84); }
@media (prefers-color-scheme: dark) {
.icon-bg { fill: #18181b; }
}
</style>
</defs>
<g clip-path="url(#_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV)">
<rect width="1000" height="1000" class="icon-bg"/>
<path d=" M 489.315 575.068 L 225.342 338.071 C 222.394 335.424 220 330.058 220 326.095 L 220 297.377 C 220 293.415 223.135 289.474 226.996 288.583 L 320.697 266.96 C 324.558 266.069 327.692 268.563 327.692 272.525 L 327.692 331.61 L 406.851 313.338 C 410.712 312.446 413.846 308.506 413.846 304.543 L 413.846 252.643 C 413.846 248.681 416.981 244.741 420.842 243.85 L 493.004 227.197 C 496.865 226.306 503.135 226.306 506.996 227.197 L 579.158 243.85 C 583.019 244.741 586.154 248.681 586.154 252.643 L 586.154 304.543 C 586.154 308.506 589.288 312.446 593.149 313.338 L 672.308 331.61 L 672.308 272.525 C 672.308 268.563 675.442 266.069 679.303 266.96 L 773.004 288.583 C 776.865 289.474 780 293.415 780 297.377 L 780 326.095 C 780 330.058 777.606 335.424 774.658 338.071 L 510.685 575.068 C 504.788 580.362 495.212 580.362 489.315 575.068 Z " class="icon-mark"/>
<path d=" M 780 429.762 L 780 470.138 C 780 474.101 777.725 479.593 774.923 482.394 L 742 515.318 C 739.198 518.12 736.923 523.612 736.923 527.574 L 736.923 649.625 C 736.922 672.529 730.827 692.394 719.048 710.431 L 599.991 591.373 L 780 429.762 Z " class="icon-mark"/>
<path d=" M 220 429.762 L 220 462.959 C 220 470.884 224.55 481.867 230.153 487.471 L 252.924 510.241 C 258.527 515.845 263.077 526.829 263.077 534.754 L 263.077 649.625 C 263.078 672.529 269.173 692.394 280.952 710.431 L 400.009 591.373 L 220 429.762 Z " class="icon-mark"/>
<path d=" M 667.232 760.147 C 627.163 787.649 570.672 813.211 500 843.472 Q 500 843.472 500 843.472 C 429.328 813.211 372.837 787.649 332.768 760.147 L 454.622 638.293 C 459.461 641.204 464.582 643.644 469.918 645.569 C 479.567 649.058 489.741 650.839 500 650.832 C 510.259 650.839 520.433 649.058 530.082 645.569 C 535.418 643.644 540.539 641.204 545.378 638.293 L 667.232 760.147 Z " class="icon-mark"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+20
View File
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import { getLocale } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import "./globals.css";
const geistSans = Geist({
@@ -20,6 +22,14 @@ export async function generateMetadata(): Promise<Metadata> {
return {
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
description: "Minimalist webmail client using JMAP protocol",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
},
formatDetection: {
telephone: false,
},
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
};
}
@@ -36,6 +46,14 @@ export default async function RootLayout({
return (
<html lang={locale} suppressHydrationWarning>
<head>
<meta name="theme-color" content="#ffffff" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-title"
content={process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail"}
/>
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
{parentOrigin && (
<meta name="parent-origin" content={parentOrigin} />
)}
@@ -63,7 +81,9 @@ export default async function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ServiceWorkerRegistration />
{children}
<PWAInstallPrompt />
</body>
</html>
);
+53
View File
@@ -0,0 +1,53 @@
import type { MetadataRoute } from "next";
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" },
],
};
}
@@ -2,13 +2,14 @@
import { useState, useRef, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2 } from "lucide-react";
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useTaskStore } from "@/stores/task-store";
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { toast } from "@/stores/toast-store";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
@@ -164,6 +165,9 @@ export function CalendarSidebarPanel({
)}
</>
)}
{cal.id === BIRTHDAY_CALENDAR_ID && (
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)}
</button>
{/* Subscription context menu on right-click */}
+1 -1
View File
@@ -88,7 +88,7 @@ function formatDurationDisplay(minutes: number): string {
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
if (!event.alerts) return null;
const first = Object.values(event.alerts)[0];
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
if (!first || !first.trigger || first.trigger["@type"] !== "OffsetTrigger") return null;
const offset = first.trigger.offset;
if (offset === "PT0S") return t("alerts.at_time");
const minMatch = offset.match(/-?PT?(\d+)M$/);
+4 -3
View File
@@ -20,6 +20,7 @@ import {
} from "@/lib/calendar-participants";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils";
export interface PendingEventPreview {
start: Date;
@@ -379,8 +380,10 @@ export function EventModal({
{ name: organizerName, email: organizerEmail },
attendees
) as Record<string, CalendarParticipant>;
data.replyTo = { imip: `mailto:${organizerEmail}` };
} else if (attendees.length === 0 && event?.participants) {
data.participants = null;
data.replyTo = null;
}
const shouldSendScheduling = attendees.length > 0 && sendInvitations;
@@ -402,9 +405,7 @@ export function EventModal({
if (!event || !onDuplicate) return;
const start = getEventStartDate(event);
const newStart = addDays(start, 1);
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const newUid = generateUUID();
const data: Partial<CalendarEvent> = {
uid: newUid,
title: event.title,
+2 -1
View File
@@ -11,6 +11,7 @@ import { getEventStartDate } from "@/lib/calendar-utils";
import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import { apiFetch } from "@/lib/browser-navigation";
interface ICalImportModalProps {
calendars: Calendar[];
@@ -126,7 +127,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
setIsParsing(true);
try {
const response = await fetch("/api/fetch-ical", {
const response = await apiFetch("/api/fetch-ical", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: trimmed }),
+4 -4
View File
@@ -87,7 +87,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
try {
let derBytes: ArrayBuffer | string | null = null;
if (key.uri.startsWith('data:')) {
// data URI extract base64 content
// data URI - extract base64 content
const commaIdx = key.uri.indexOf(',');
if (commaIdx === -1) continue;
const b64 = key.uri.substring(commaIdx + 1);
@@ -178,7 +178,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
<div className="flex items-center gap-4">
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
<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 && (
<p className="text-sm text-muted-foreground truncate">&ldquo;{nicknames.map(n => n.name).join(", ")}&rdquo;</p>
)}
@@ -278,7 +278,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
<div key={i} className="text-sm">
{o.name}
{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>
))}
@@ -371,7 +371,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
{contact.speakToAs.pronouns && (() => {
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
return firstPronoun ? (
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " " : ""}{firstPronoun}</span>
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " - " : ""}{firstPronoun}</span>
) : null;
})()}
</div>
+12 -26
View File
@@ -304,11 +304,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
if (contact?.addressBookIds) {
const ids = Object.keys(contact.addressBookIds).filter(k => contact.addressBookIds[k]);
if (ids.length > 0) {
// For shared contacts, the addressBookIds uses the original (non-namespaced) id
// but we need the namespaced id to match addressBooks entries
if (contact.isShared && contact.accountId) {
return `${contact.accountId}:${ids[0]}`;
}
// addressBookIds are already namespaced for shared contacts (e.g. "accountId:bookId")
// so we can use them directly to match addressBook entries
return ids[0];
}
}
@@ -510,7 +507,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div>
)}
{/* Name & Identity full width */}
{/* Name & Identity - full width */}
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={User} title={t("section_identity")} category="contact">
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
@@ -578,7 +575,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
setEmails(next);
}}
>
<option value=""></option>
<option value="">-</option>
<option value="work">{t("context_work")}</option>
<option value="private">{t("context_private")}</option>
</Select>
@@ -642,7 +639,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
setPhones(next);
}}
>
<option value=""></option>
<option value="">-</option>
<option value="work">{t("context_work")}</option>
<option value="private">{t("context_private")}</option>
</Select>
@@ -680,7 +677,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div>
</FormSection>
{/* Addresses full width */}
{/* Addresses - full width */}
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
<div className="space-y-3">
@@ -701,7 +698,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
value={addr.context}
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="private">{t("context_private")}</option>
</Select>
@@ -836,7 +833,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
<div>
<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">
<option value=""></option>
<option value="">-</option>
<option value="masculine">{t("gender_male")}</option>
<option value="feminine">{t("gender_female")}</option>
<option value="other">{t("gender_other")}</option>
@@ -869,7 +866,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div>
</FormSection>
{/* Notes full width */}
{/* Notes - full width */}
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
<textarea
@@ -914,7 +911,6 @@ function CategoryComboBox({
}) {
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Parse current keywords from comma-separated string
@@ -949,17 +945,6 @@ function CategoryComboBox({
onChange(next);
}, [currentKeywords, onChange]);
// Close dropdown on outside click
useEffect(() => {
if (!isOpen) return;
const handler = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [isOpen]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
@@ -973,7 +958,7 @@ function CategoryComboBox({
};
return (
<div ref={wrapperRef} className="relative">
<div className="relative">
{/* Keyword badges */}
{currentKeywords.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
@@ -1001,6 +986,7 @@ function CategoryComboBox({
value={inputValue}
onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }}
onFocus={() => setIsOpen(true)}
onBlur={() => setIsOpen(false)}
onKeyDown={handleKeyDown}
placeholder={currentKeywords.length === 0 ? placeholder : ""}
/>
@@ -1008,7 +994,7 @@ function CategoryComboBox({
{/* Dropdown */}
{isOpen && (suggestions.length > 0 || canAddNew) && (
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1">
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1" onMouseDown={(e) => e.preventDefault()}>
{suggestions.map(kw => (
<button
key={kw}
@@ -199,7 +199,7 @@ export function ContactImportDialog({
</div>
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-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 && (
<div className="text-xs text-muted-foreground truncate">{cEmail}</div>
)}
+1 -1
View File
@@ -90,7 +90,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{name || email || ""}
{name || email || "-"}
</div>
{density !== 'extra-compact' && email && name && (
<div className="text-xs text-muted-foreground truncate">{email}</div>
+111 -28
View File
@@ -2,7 +2,8 @@
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2 } from "lucide-react";
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
@@ -25,6 +26,8 @@ interface ContactsSidebarProps {
onDeleteGroup?: (groupId: string) => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
onRenameAddressBook?: (addressBook: AddressBook) => void;
onRenameKeyword?: (keyword: string) => void;
className?: string;
}
@@ -58,10 +61,15 @@ export function ContactsSidebar({
onDeleteGroup,
onDropContacts,
onDropContactsToCategory,
onRenameAddressBook,
onRenameKeyword,
className,
}: ContactsSidebarProps) {
const t = useTranslations("contacts");
const router = useRouter();
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
const { contextMenu: bookContextMenu, openContextMenu: openBookContextMenu, closeContextMenu: closeBookContextMenu, menuRef: bookMenuRef } = useContextMenu<AddressBook>();
const { contextMenu: keywordContextMenu, openContextMenu: openKeywordContextMenu, closeContextMenu: closeKeywordContextMenu, menuRef: keywordMenuRef } = useContextMenu<string>();
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
const [showMenu, setShowMenu] = useState(false);
@@ -246,19 +254,32 @@ export function ContactsSidebar({
{/* My Address Books */}
{personalBooks.length > 0 && (
<div className="mt-2">
<button
onClick={() => toggleSection("addressBooks")}
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
>
{collapsed.addressBooks ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
)}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t("address_books.title")}
</span>
</button>
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection("addressBooks")}
className="flex items-center gap-1 flex-1 text-left"
>
{collapsed.addressBooks ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
)}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t("address_books.title")}
</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
title={t("address_books.manage")}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
</div>
{!collapsed.addressBooks && personalBooks.map((book) => (
<AddressBookItem
key={book.id}
@@ -267,6 +288,7 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
@@ -362,6 +384,7 @@ export function ContactsSidebar({
isActive={isActive}
onSelect={() => onSelectCategory({ keyword })}
onDropContacts={onDropContactsToCategory}
onContextMenu={onRenameKeyword ? (e) => openKeywordContextMenu(e, keyword) : undefined}
/>
);
})}
@@ -372,20 +395,33 @@ export function ContactsSidebar({
{/* Shared accounts with address books */}
{sharedBookGroups.map((group) => (
<div key={group.accountId} className="mt-2">
<button
onClick={() => toggleSection(`shared-${group.accountId}`)}
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
>
{collapsed[`shared-${group.accountId}`] ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
)}
<Share2 className="w-3 h-3 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
{t("address_books.shared_prefix", { name: group.accountName })}
</span>
</button>
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection(`shared-${group.accountId}`)}
className="flex items-center gap-1 flex-1 min-w-0 text-left"
>
{collapsed[`shared-${group.accountId}`] ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
)}
<Share2 className="w-3 h-3 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
{t("address_books.shared_prefix", { name: group.accountName })}
</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
title={t("address_books.manage")}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
</div>
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
<AddressBookItem
key={book.id}
@@ -394,12 +430,53 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
))}
</div>
{/* Address book context menu */}
{bookContextMenu.data && onRenameAddressBook && (
<ContextMenu
ref={bookMenuRef}
isOpen={bookContextMenu.isOpen}
position={bookContextMenu.position}
onClose={closeBookContextMenu}
>
<ContextMenuItem
icon={Pencil}
label={t("address_books.rename")}
onClick={() => {
const book = bookContextMenu.data!;
closeBookContextMenu();
onRenameAddressBook(book);
}}
/>
</ContextMenu>
)}
{/* Keyword (category) context menu */}
{keywordContextMenu.data && onRenameKeyword && (
<ContextMenu
ref={keywordMenuRef}
isOpen={keywordContextMenu.isOpen}
position={keywordContextMenu.position}
onClose={closeKeywordContextMenu}
>
<ContextMenuItem
icon={Pencil}
label={t("rename_category")}
onClick={() => {
const kw = keywordContextMenu.data!;
closeKeywordContextMenu();
onRenameKeyword(kw);
}}
/>
</ContextMenu>
)}
{/* Group context menu */}
{groupContextMenu.data && (
<ContextMenu
@@ -438,12 +515,14 @@ function CategoryItem({
isActive,
onSelect,
onDropContacts,
onContextMenu,
}: {
keyword: string;
count: number;
isActive: boolean;
onSelect: () => void;
onDropContacts?: (contactIds: string[], keyword: string) => void;
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) {
const [isDragOver, setIsDragOver] = useState(false);
@@ -476,6 +555,7 @@ function CategoryItem({
return (
<button
onClick={onSelect}
onContextMenu={onContextMenu}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -503,12 +583,14 @@ function AddressBookItem({
contactCount,
onSelect,
onDropContacts,
onContextMenu,
}: {
book: AddressBook;
isActive: boolean;
contactCount: number;
onSelect: () => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) {
const [isDragOver, setIsDragOver] = useState(false);
@@ -541,6 +623,7 @@ function AddressBookItem({
return (
<button
onClick={onSelect}
onContextMenu={onContextMenu}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
+58 -57
View File
@@ -527,7 +527,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|| null;
// 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.
const sendImipReply = async () => {
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
@@ -754,49 +754,67 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
{/* Content */}
<div className="px-4 py-3 space-y-2.5">
{/* Event title */}
{summary?.title && (
<div className="flex items-start justify-between gap-2">
<h3 className={cn(
"text-base font-semibold leading-snug",
isCancellation ? "line-through text-muted-foreground" : "text-foreground"
)}>
{summary.title}
</h3>
{parsedEvent?.sequence != null && parsedEvent.sequence > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground flex-shrink-0 whitespace-nowrap">
{t('event_updated', { sequence: parsedEvent.sequence })}
</span>
<div className="lg:flex lg:gap-6">
{/* Left: Event info */}
<div className="lg:flex-1 space-y-2.5 min-w-0">
{/* Event title */}
{summary?.title && (
<div className="flex items-start justify-between gap-2">
<h3 className={cn(
"text-base font-semibold leading-snug",
isCancellation ? "line-through text-muted-foreground" : "text-foreground"
)}>
{summary.title}
</h3>
{parsedEvent?.sequence != null && parsedEvent.sequence > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground flex-shrink-0 whitespace-nowrap">
{t('event_updated', { sequence: parsedEvent.sequence })}
</span>
)}
</div>
)}
</div>
)}
{/* Event details */}
<div className="space-y-1">
{summary?.start && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
<span>
{formatDateTime(summary.start)}
{summary.end && ` ${formatDateTime(summary.end)}`}
</span>
{/* Event details */}
<div className="lg:flex lg:items-center lg:gap-4 lg:flex-wrap space-y-1 lg:space-y-0">
{summary?.start && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
<span>
{formatDateTime(summary.start)}
{summary.end && ` ${formatDateTime(summary.end)}`}
</span>
</div>
)}
{summary?.location && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
<span>{summary.location}</span>
</div>
)}
{summary?.organizer && (
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Users className="w-3.5 h-3.5 flex-shrink-0" />
{t('organizer', { name: summary.organizer })}
</span>
)}
{summary && summary.attendeeCount > 0 && (
<span className="text-sm text-muted-foreground">{t('attendees', { count: summary.attendeeCount })}</span>
)}
</div>
)}
{summary?.location && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
<span>{summary.location}</span>
</div>
)}
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
{summary?.organizer && (
<span className="flex items-center gap-1.5">
<Users className="w-3.5 h-3.5 flex-shrink-0" />
{t('organizer', { name: summary.organizer })}
</span>
</div>
{/* Right: Info & actor messages on large screens */}
<div className="lg:flex-shrink-0 lg:text-right lg:max-w-xs mt-2.5 lg:mt-0 space-y-1">
{bannerInfo && (
<p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
)}
{summary && summary.attendeeCount > 0 && (
<span>{t('attendees', { count: summary.attendeeCount })}</span>
{actorMessage && (
<p className="text-xs text-muted-foreground">{actorMessage}</p>
)}
{actorSummary?.participationComment && (
<p className="text-xs text-muted-foreground italic">
{t('actor_note', { comment: actorSummary.participationComment })}
</p>
)}
</div>
</div>
@@ -840,23 +858,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
</div>
)}
{/* Info text */}
{bannerInfo && (
<p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
)}
{/* Actor message */}
{actorMessage && (
<p className="text-xs text-muted-foreground">{actorMessage}</p>
)}
{/* Actor comment */}
{actorSummary?.participationComment && (
<p className="text-xs text-muted-foreground italic">
{t('actor_note', { comment: actorSummary.participationComment })}
</p>
)}
{/* Proposed changes */}
{proposedChanges.length > 0 && (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs">
+90 -17
View File
@@ -12,6 +12,7 @@ import { toast } from "@/stores/toast-store";
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store";
import { useSmimeStore } from "@/stores/smime-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
@@ -34,9 +35,8 @@ import { RichTextEditor } from "@/components/email/rich-text-editor";
/** Strip HTML tags and decode entities to get a plain-text version */
function htmlToPlainText(html: string): string {
const tmp = document.createElement('div');
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || '';
const doc = new DOMParser().parseFromString(html, 'text/html');
return doc.body.textContent || '';
}
export interface ComposerDraftData {
@@ -85,6 +85,7 @@ interface EmailComposerProps {
body?: string;
htmlBody?: string;
receivedAt?: string;
accountId?: string;
};
}
@@ -104,6 +105,8 @@ export function EmailComposer({
const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
// Initialize with reply/forward data if provided
const getInitialTo = () => {
@@ -213,6 +216,8 @@ export function EmailComposer({
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
const [smimePassphraseInput, setSmimePassphraseInput] = useState('');
const [smimePassphraseError, setSmimePassphraseError] = useState('');
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
const saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate,
@@ -226,6 +231,12 @@ export function EmailComposer({
restoreFocus: true,
});
const attachmentWarningRef = useFocusTrap({
isActive: showAttachmentWarning,
onEscape: () => setShowAttachmentWarning(false),
restoreFocus: true,
});
const { client } = useAuthStore();
const identities = useIdentityStore((s) => s.identities);
const primaryIdentity = identities[0] ?? null;
@@ -245,12 +256,28 @@ export function EmailComposer({
if (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,
identities,
initialData?.selectedIdentityId,
mode,
replyTo?.accountId,
replyTo?.bcc,
replyTo?.cc,
replyTo?.to,
@@ -533,17 +560,18 @@ export function EmailComposer({
}
}, [client, t]);
const handleImageUpload = useCallback(async (file: File): Promise<string | null> => {
if (!client) return null;
try {
const { blobId } = await client.uploadBlob(file);
return await client.fetchBlobAsObjectUrl(blobId, file.name, file.type);
} catch (error) {
debug.error(`Failed to upload inline image ${file.name}:`, error);
toast.error(t('upload_failed', { filename: file.name }));
return null;
}
}, [client, t]);
const handleImageUpload = useCallback((file: File): Promise<string | null> => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
reader.onerror = () => {
debug.error(`Failed to read inline image ${file.name}`);
toast.error(t('upload_failed', { filename: file.name }));
resolve(null);
};
reader.readAsDataURL(file);
});
}, [t]);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files) return;
@@ -723,7 +751,7 @@ export function EmailComposer({
return undefined;
};
const handleSend = async () => {
const handleSend = async (skipAttachmentCheck = false) => {
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
@@ -742,6 +770,21 @@ export function EmailComposer({
return;
}
// Attachment reminder check
if (!skipAttachmentCheck && attachmentReminderEnabled) {
const hasAttachments = attachments.some(att => att.blobId && !att.uploading && !att.error);
if (!hasAttachments) {
const bodyText = htmlToPlainText(body);
const searchText = `${subject} ${bodyText}`.toLowerCase();
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
if (matched) {
setAttachmentWarningKeyword(matched);
setShowAttachmentWarning(true);
return;
}
}
}
let finalDraftId = draftId;
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
@@ -1002,7 +1045,7 @@ export function EmailComposer({
</div>
{/* Mobile: send button in header */}
<Button
onClick={handleSend}
onClick={() => handleSend()}
disabled={!canSend}
title={getSendTooltip()}
size="sm"
@@ -1367,7 +1410,7 @@ export function EmailComposer({
{t('discard')}
</button>
<Button
onClick={handleSend}
onClick={() => handleSend()}
disabled={!canSend}
title={getSendTooltip()}
className="hidden md:inline-flex"
@@ -1467,6 +1510,36 @@ export function EmailComposer({
</div>
)}
{showAttachmentWarning && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
onClick={() => setShowAttachmentWarning(false)}
>
<div
ref={attachmentWarningRef}
role="alertdialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
>
<div className="p-6">
<h2 className="text-lg font-semibold text-foreground">{t('forgot_attachment.title')}</h2>
<p className="mt-2 text-sm text-muted-foreground">
{t('forgot_attachment.message', { keyword: attachmentWarningKeyword })}
</p>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
{t('forgot_attachment.back')}
</Button>
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true); }}>
{t('forgot_attachment.send_anyway')}
</Button>
</div>
</div>
</div>
)}
{showCloseDialog && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
+30 -26
View File
@@ -89,17 +89,18 @@ const getMailboxIcon = (role?: string) => {
}
};
// Get current label/color from email keywords (supports both $label: and legacy $color:)
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
if (!keywords) return null;
// Get all active label/color tag IDs from email keywords
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
return key.startsWith("$label:")
? key.slice("$label:".length)
: key.slice("$color:".length);
tags.push(
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
);
}
}
return null;
return tags;
};
export function EmailContextMenu({
@@ -137,7 +138,7 @@ export function EmailContextMenu({
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isDraft = email.keywords?.['$draft'] === true;
const currentColor = getCurrentColor(email.keywords);
const currentColors = getCurrentColors(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
@@ -306,24 +307,27 @@ export function EmailContextMenu({
{/* Set tag submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
{colorOptions.map((option) => (
<button
key={option.value}
role="menuitem"
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
className={cn(
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
currentColor === option.value && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="flex-1">{option.name}</span>
{currentColor === option.value && (
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
)}
</button>
))}
{currentColor && (
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
role="menuitem"
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
className={cn(
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="flex-1">{option.name}</span>
{isActive && (
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
)}
</button>
);
})}
{currentColors.length > 0 && (
<>
<ContextMenuSeparator />
<ContextMenuItem
+1 -1
View File
@@ -94,7 +94,7 @@ export function EmailHoverActions({
onToggleStar?.();
break;
case "markRead":
onMarkAsRead?.(!isUnread);
onMarkAsRead?.(isUnread);
break;
case "archive":
onArchive?.();
+20 -13
View File
@@ -15,7 +15,7 @@ import { useLongPress } from "@/hooks/use-long-press";
import { useUIStore } from "@/stores/ui-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { EmailHoverActions } from "./email-hover-actions";
import { getEmailColorTag } from "@/lib/thread-utils";
import { getEmailColorTags } from "@/lib/thread-utils";
interface EmailListItemProps {
email: Email;
@@ -32,7 +32,7 @@ interface EmailListItemProps {
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
const t = useTranslations('email_viewer');
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -44,13 +44,18 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const isImportant = email.keywords?.["$important"];
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0];
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tag using keyword definitions from settings
const colorTagId = getEmailColorTag(email.keywords);
const keywordDef = colorTagId ? emailKeywords.find(k => k.id === colorTagId) : null;
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
const colorTagIds = getEmailColorTags(email.keywords);
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
// Use first tag for background coloring
const keywordDef = keywordDefs[0] ?? null;
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
// Drag and drop functionality
@@ -196,7 +201,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
</>
)}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
{keywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
<span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
@@ -246,15 +253,15 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && (
<span className={cn(
{keywordDefs.map((kd) => (
<span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{kd.label}
</span>
)}
))}
<span className={cn(
"text-xs tabular-nums",
isUnread
+1 -1
View File
@@ -176,7 +176,7 @@ export function EmailList({
setIsProcessing(true);
try {
await batchDelete(client);
await batchDelete(client, isInTrash);
} finally {
setTimeout(() => setIsProcessing(false), 500);
}
+155 -103
View File
@@ -8,7 +8,7 @@ import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/ema
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime } from "@/lib/utils";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
import {
Reply,
@@ -85,7 +85,7 @@ import { UnsubscribeBanner } from "./unsubscribe-banner";
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
import { useTour } from "@/components/tour/tour-provider";
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 { isFilePreviewable } from "@/lib/file-preview";
import { SmimeStatusBanner } from "./smime-status-banner";
@@ -193,16 +193,17 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
return 'Attachment';
};
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
if (!keywords) return null;
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
return key.startsWith("$label:")
? key.slice("$label:".length)
: key.slice("$color:".length);
tags.push(
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
);
}
}
return null;
return tags;
};
// Helper function to format recipients with contextual display
@@ -739,7 +740,7 @@ function ContactSidebarPanel({
<div key={i} className="text-sm">
{o.name}
{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>
))}
@@ -887,6 +888,9 @@ export function EmailViewer({
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
@@ -933,7 +937,8 @@ export function EmailViewer({
const moveMenuRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
const currentColor = getCurrentColor(email?.keywords);
const currentColors = getCurrentColors(email?.keywords);
const currentColor = currentColors[0] ?? null;
// S/MIME state
const [smimeStatus, setSmimeStatus] = useState<SmimeStatus | null>(null);
@@ -1152,9 +1157,27 @@ export function EmailViewer({
}
);
const autoMarkedEmailRef = useRef<string | null>(null);
// Reset auto-mark tracking when email changes
useEffect(() => {
autoMarkedEmailRef.current = null;
}, [email?.id]);
useEffect(() => {
// Mark as read when email is viewed, respecting the delay setting
if (!email || email.keywords?.$seen || !onMarkAsRead) {
if (!email || !onMarkAsRead) {
return;
}
// Already read - record that so manual unread toggle won't re-trigger auto-mark
if (email.keywords?.$seen) {
autoMarkedEmailRef.current = email.id;
return;
}
// Don't re-trigger if we already auto-marked this email (user may have toggled it back to unread)
if (autoMarkedEmailRef.current === email.id) {
return;
}
@@ -1167,17 +1190,18 @@ export function EmailViewer({
// Instant mark
if (markAsReadDelay === 0) {
autoMarkedEmailRef.current = email.id;
onMarkAsRead(email.id, true);
return;
}
// Delayed mark
const timeout = setTimeout(() => {
autoMarkedEmailRef.current = email.id;
onMarkAsRead(email.id, true);
}, markAsReadDelay);
return () => clearTimeout(timeout);
// eslint-disable-next-line react-hooks/exhaustive-deps -- email?.id changes when email changes, which is the intended trigger
}, [email?.id, email?.keywords?.$seen, onMarkAsRead]);
// Reset external content permission and quick reply when email changes
@@ -1620,7 +1644,7 @@ export function EmailViewer({
// Check if inner content is also signed
const nestedSignedData = extractNestedSignedDataCandidate(parsed, result.mimeBytes);
if (nestedSignedData) {
// Nested sign-then-encrypt verify inner signature
// Nested sign-then-encrypt - verify inner signature
const innerBytes = normalizeCmsBytes(nestedSignedData.bytes);
smimeDebug('[S/MIME] nested signed-data candidate:', {
source: nestedSignedData.source,
@@ -1829,7 +1853,7 @@ export function EmailViewer({
// 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
// boilerplate (CSS + &nbsp;) treat these as effectively empty.
// boilerplate (CSS + &nbsp;) - treat these as effectively empty.
const htmlPartId = email.htmlBody?.[0]?.partId;
const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : '';
let hasRealHtmlBody = !!htmlValue;
@@ -1874,7 +1898,7 @@ export function EmailViewer({
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) {
setTnefHtml(parsed.htmlBody);
@@ -1888,7 +1912,7 @@ export function EmailViewer({
}
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();
@@ -1951,7 +1975,7 @@ export function EmailViewer({
const parsed = await parser.parse(new Uint8Array(blobBytes));
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)',
', attachments:', parsed.attachments?.length ?? 0);
@@ -2060,11 +2084,15 @@ export function EmailViewer({
}));
}
const hasCalInvitation = calendarInvitationParsingEnabled && !!email && !!findCalendarAttachment(email);
const jmapAttachments = (email?.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))
// Hide message/rfc822 when we have unwrapped the embedded email
.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))
.map((attachment, index) => ({
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
name: attachment.name || null,
@@ -2095,7 +2123,7 @@ export function EmailViewer({
}));
return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted];
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments]);
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments, calendarInvitationParsingEnabled]);
// Generate email source for viewing
const generateEmailSource = (email: Email): string => {
@@ -2290,9 +2318,11 @@ export function EmailViewer({
// Use shared sanitization config as base (more secure)
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
// Check if sender is trusted
// Check if sender is trusted (localStorage list or address book)
const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false;
const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
// Block external content based on policy:
// 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted
@@ -2399,7 +2429,7 @@ export function EmailViewer({
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
isHtml: false
};
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, cidBlobUrls]);
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, isTrustedAddressBookSender, trustedSendersAddressBook, cidBlobUrls]);
// Override email content with S/MIME decrypted content when available
const effectiveEmailContent = useMemo(() => {
@@ -2931,7 +2961,7 @@ export function EmailViewer({
<PluginSlot name="toolbar-actions" />
</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">
{isLoading && (
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
@@ -3028,7 +3058,7 @@ export function EmailViewer({
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
</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 className="w-px h-5 bg-border mx-0.5" />
<div ref={tagMenuRef} className="relative">
@@ -3036,43 +3066,51 @@ export function EmailViewer({
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
className={cn(
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
currentColor && "bg-muted/50"
currentColors.length > 0 && "bg-muted/50"
)}
title={t('set_color')}
>
{(() => {
const kw = currentColor ? emailKeywords.find(k => k.id === currentColor) : null;
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
return dotClass ? (
<>
<span className={cn("w-3 h-3 rounded-full", dotClass)} />
{showToolbarLabels && <span className="text-xs font-medium text-foreground">{kw!.label}</span>}
</>
) : (
<>
<Tag className="w-4 h-4 text-muted-foreground" />
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
</>
);
})()}
{currentColors.length > 0 ? (
<>
<span className="flex items-center gap-0.5">
{currentColors.slice(0, 3).map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
return <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500')} />;
})}
</span>
{showToolbarLabels && currentColors.length === 1 && (
<span className="text-xs font-medium text-foreground">
{emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
</span>
)}
</>
) : (
<>
<Tag className="w-4 h-4 text-muted-foreground" />
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
</>
)}
</button>
{tagMenuOpen && (
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
{colorOptions.map((option) => (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
currentColor === option.value && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
</button>
))}
{currentColor && (
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<button
@@ -3089,7 +3127,7 @@ export function EmailViewer({
</div>
</div>
{/* Spam hidden on mobile, overflows to More menu */}
{/* Spam - hidden on mobile, overflows to More menu */}
{(onMarkAsSpam || onUndoSpam) && (
<Button
variant="ghost"
@@ -3111,7 +3149,7 @@ export function EmailViewer({
</Button>
)}
{/* Toggle read state hidden on mobile, overflows to More menu */}
{/* Toggle read state - hidden on mobile, overflows to More menu */}
<Button
variant="ghost"
size="sm"
@@ -3124,7 +3162,7 @@ export function EmailViewer({
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
</Button>
{/* Print hidden on mobile, overflows to More menu */}
{/* Print - hidden on mobile, overflows to More menu */}
<Button
variant="ghost"
size="sm"
@@ -3138,7 +3176,7 @@ export function EmailViewer({
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('print')}</span>}
</Button>
{/* View source hidden on mobile, overflows to More menu */}
{/* View source - hidden on mobile, overflows to More menu */}
<Button
variant="ghost"
size="sm"
@@ -3166,7 +3204,7 @@ export function EmailViewer({
</Button>
)}
{/* More menu click-based */}
{/* More menu - click-based */}
<div ref={moreMenuRef} className="relative">
<Button
variant="ghost"
@@ -3212,7 +3250,7 @@ export function EmailViewer({
<Archive className="w-4 h-4" />
{t('archive')}
</button>
{/* Overflow: move to folder submenu */}
{/* Overflow: move to folder - submenu */}
{moveTree.length > 0 && onMoveToMailbox && (
<div className={cn("relative", hiddenPriorities.has(5) ? "" : "sm:hidden")}
onMouseEnter={() => setMoreMenuSub('move')}
@@ -3264,7 +3302,7 @@ export function EmailViewer({
)}
</div>
)}
{/* Overflow: tag submenu */}
{/* Overflow: tag - submenu */}
{colorOptions.length > 0 && (
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
onMouseEnter={() => setMoreMenuSub('tag')}
@@ -3280,21 +3318,24 @@ export function EmailViewer({
</button>
{moreMenuSub === 'tag' && (
<div className="absolute right-full top-0 mr-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
{colorOptions.map((option) => (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
currentColor === option.value && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
</button>
))}
{currentColor && (
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<button
@@ -3470,21 +3511,24 @@ export function EmailViewer({
<>
<div className="h-px bg-border my-1" />
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
{colorOptions.map((option) => (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
className={cn(
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
currentColor === option.value && "bg-accent font-medium"
)}
>
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{currentColor === option.value && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
</button>
))}
{currentColor && (
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
className={cn(
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
@@ -3630,14 +3674,18 @@ export function EmailViewer({
)} />
</button>
)}
{/* Color tag dot */}
{currentColor && (() => {
const kw = emailKeywords.find(k => k.id === currentColor);
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
return dotClass ? (
<span className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
) : null;
})()}
{/* Color tag dots */}
{currentColors.length > 0 && (
<span className="flex items-center gap-0.5">
{currentColors.map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
return (
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw.label} />
);
})}
</span>
)}
{isImportant && (
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
{t('important')}
@@ -3991,7 +4039,7 @@ export function EmailViewer({
<div className="flex-1">
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
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 className={cn(
"text-xs",
@@ -4507,7 +4555,7 @@ export function EmailViewer({
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
hasCalendarInvitation) && (
<div className="border-b border-border bg-muted/30 isolate">
<div className="max-w-4xl mx-auto px-6 py-1.5">
<div className="max-w-6xl mx-auto px-6 py-1.5">
<div className="flex flex-col gap-3 isolate">
{/* External Content Controls */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
@@ -4526,7 +4574,11 @@ export function EmailViewer({
onClick={() => {
const senderEmail = email.from?.[0]?.email;
if (senderEmail) {
addTrustedSender(senderEmail);
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
setAllowExternalContent(true);
}
}}
@@ -4874,7 +4926,7 @@ export function EmailViewer({
if (client && supportsSync) {
createContact(client, contactData).then(() => toast.success('Contact added'));
} else {
addLocalContact({ id: `local-${crypto.randomUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
toast.success('Contact added');
}
}}
+13 -1
View File
@@ -14,7 +14,7 @@ interface RecipientPopoverProps {
email: string;
/** Display label override (e.g. "me") */
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;
className?: string;
}
@@ -93,6 +93,18 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
return () => document.removeEventListener("keydown", handler);
}, [isOpen]);
// Close on scroll so the popover does not float while content moves.
useEffect(() => {
if (!isOpen) return;
const handler = () => handleClose();
document.addEventListener("scroll", handler, true);
window.addEventListener("resize", handler);
return () => {
document.removeEventListener("scroll", handler, true);
window.removeEventListener("resize", handler);
};
}, [isOpen]);
const handleViewContact = () => {
if (onViewContact) {
onViewContact(contact ?? null, email);
+1
View File
@@ -118,6 +118,7 @@ export function RichTextEditor({
);
if (imageFiles.length === 0) return false;
event.preventDefault();
event.stopPropagation();
for (const file of imageFiles) {
upload(file).then((url) => {
if (url) {
+13 -2
View File
@@ -31,6 +31,7 @@ import {
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { isFilePreviewable } from "@/lib/file-preview";
@@ -84,6 +85,10 @@ export function ThreadConversationView({
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const { client } = useAuthStore();
// Track which emails are expanded (most recent by default)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
@@ -164,7 +169,9 @@ export function ThreadConversationView({
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
{emails.map((email, index) => {
const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false;
const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
return (
<EmailCard
key={email.id}
@@ -175,7 +182,11 @@ export function ThreadConversationView({
onToggleExpanded={() => toggleExpanded(email.id)}
onAllowExternal={() => toggleAllowExternal(email.id)}
onTrustSender={senderEmail ? () => {
addTrustedSender(senderEmail);
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
toggleAllowExternal(email.id);
} : undefined}
onReply={onReply ? () => onReply(email) : undefined}
+71 -20
View File
@@ -9,7 +9,8 @@ import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSqu
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store";
import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils";
import { useAccountStore } from "@/stores/account-store";
import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { ThreadEmailItem } from "./thread-email-item";
@@ -55,18 +56,25 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0];
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density);
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 isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color and keyword definition from keyword definitions if not passed directly
const tagId = getEmailColorTag(email.keywords);
const resolvedKeywordDef = tagId ? emailKeywords.find(k => k.id === tagId) : null;
// Resolve color tags using keyword definitions; unknown tags fall back to gray
const tagIds = getEmailColorTags(email.keywords);
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
const resolvedColorTag = (() => {
if (colorTag) return colorTag;
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
@@ -180,6 +188,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{isFocusedMailLayout ? (
<div className="flex items-center justify-between 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(
'w-32 shrink-0 truncate text-sm lg:w-40',
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
@@ -209,7 +224,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</>
)}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{resolvedKeywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || 'bg-gray-400')} />}
{resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
<span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
@@ -222,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 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(
"truncate text-sm",
isUnread
@@ -252,15 +276,15 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{resolvedKeywordDef && (
<span className={cn(
{resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
{resolvedKeywordDef.label}
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{kd.label}
</span>
)}
))}
<span className={cn(
"text-xs tabular-nums",
isUnread
@@ -339,7 +363,18 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, 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")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const displayNames = showRecipient
? Array.from(new Set(
thread.emails.flatMap(e => (e.to ?? []).map(r => r.name || r.email.split('@')[0]))
)).slice(0, 4)
: participantNames;
const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0];
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
email: latestEmail,
@@ -360,7 +395,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const threadColor = getThreadColorTag(thread.emails);
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 isSelected = selectedEmailId === latestEmail.id ||
@@ -522,8 +557,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{!isFocusedMailLayout && density !== 'extra-compact' && (
<Avatar
name={latestEmail.from?.[0]?.name}
email={latestEmail.from?.[0]?.email}
name={avatarPerson?.name}
email={avatarPerson?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
@@ -533,11 +568,18 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{isFocusedMailLayout ? (
<div className="flex items-center justify-between 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(
'w-32 shrink-0 truncate text-sm lg:w-44',
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
)}>
{participantNames.join(', ')}
{displayNames.join(', ')}
</span>
<span
className={cn(
@@ -572,7 +614,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</>
)}
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
{keywordDef && (
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
)}
<span className={cn(
'text-xs tabular-nums',
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
@@ -585,13 +629,20 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
<>
<div className="flex items-center justify-between gap-2 mb-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(
"truncate text-sm",
hasUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{participantNames.join(", ")}
{displayNames.join(", ")}
</span>
<span
className={cn(
+3 -3
View File
@@ -412,7 +412,7 @@ export function FileBrowser({
// Filter and sort resources
const displayResources = useMemo(() => {
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") {
filtered = filtered.filter(r => !r.isDirectory);
}
@@ -837,7 +837,7 @@ export function FileBrowser({
return (
<div
ref={containerRef}
className="flex flex-col h-full"
className="flex flex-col flex-1 min-h-0"
onClick={handleContainerClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -1466,7 +1466,7 @@ export function FileBrowser({
</div>
</td>
<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 className="px-4 py-2.5 text-muted-foreground hidden lg:table-cell tabular-nums">
{formatDate(resource.lastModified)}
+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) {
setChildrenCache(prev => new Map(prev).set(cacheKey, folders));
}
+13 -6
View File
@@ -16,7 +16,8 @@ import type {
FilterActionType,
} from "@/lib/jmap/sieve-types";
import type { Mailbox } from "@/lib/jmap/types";
import { buildMailboxTree, flattenMailboxTree, type MailboxNode } from "@/lib/utils";
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
interface FilterRuleModalProps {
rule?: FilterRule;
@@ -58,6 +59,7 @@ export function FilterRuleModal({
}: FilterRuleModalProps) {
const t = useTranslations("settings.filters");
const isEdit = !!rule;
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const [name, setName] = useState(rule?.name || "");
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
@@ -109,7 +111,7 @@ export function FilterRuleModal({
}
onSave({
id: rule?.id || crypto.randomUUID(),
id: rule?.id || generateUUID(),
name: trimmedName,
enabled: rule?.enabled ?? true,
matchType,
@@ -375,12 +377,17 @@ export function FilterRuleModal({
)}
{action.type === "add_label" && (
<Input
<select
value={action.value || ""}
onChange={(e) => updateAction(index, { value: e.target.value })}
placeholder={t("label_placeholder")}
className="flex-1 min-w-[140px]"
/>
className={`${selectClass} 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
@@ -14,6 +14,10 @@ function useSyncIdentities() {
const syncIdentities = useAuthStore((state) => state.syncIdentities);
return syncIdentities;
}
function useRefreshIdentities() {
return useAuthStore((state) => state.refreshIdentities);
}
import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { toast } from '@/stores/toast-store';
import { useFocusTrap } from '@/hooks/use-focus-trap';
@@ -49,6 +53,15 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary);
const syncIdentities = useSyncIdentities();
const refreshIdentitiesFromServer = useRefreshIdentities();
// Refresh identities from server whenever the modal is opened
useEffect(() => {
if (isOpen) {
refreshIdentitiesFromServer();
}
}, [isOpen, refreshIdentitiesFromServer]);
const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
+3 -2
View File
@@ -22,6 +22,7 @@ import { getInitials } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { apiFetch } from "@/lib/browser-navigation";
interface NavItem {
id: string;
@@ -222,13 +223,13 @@ export function NavigationRail({
let cancelled = false;
const headers = getActiveAccountSlotHeaders();
if (!headers['X-JMAP-Cookie-Slot']) return;
fetch('/api/admin/stalwart-check', { headers })
apiFetch('/api/admin/stalwart-check', { headers })
.then(res => res.json())
.then(data => {
if (!cancelled && data.isStalwartAdmin) {
setIsStalwartAdmin(true);
// Pre-create admin session so /admin works even after full page navigation
fetch('/api/admin/auth', {
apiFetch('/api/admin/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ stalwartAuth: true }),
+3 -3
View File
@@ -4,7 +4,7 @@ import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight } from 'lucide-react';
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn, generateUUID } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { IconPicker } from './icon-picker';
@@ -155,7 +155,7 @@ function SidebarAppForm({
{t('icon_label')} <span className="text-destructive">*</span>
{SelectedIcon && (
<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>
)}
</label>
@@ -207,7 +207,7 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
});
const handleCreate = useCallback((data: SidebarAppFormData) => {
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const id = `app-${generateUUID()}`;
addSidebarApp({ id, ...data });
setIsCreating(false);
}, [addSidebarApp]);
File diff suppressed because it is too large Load Diff
+10
View File
@@ -6,24 +6,34 @@ import { useLocaleStore } from '@/stores/locale-store';
import enMessages from '@/locales/en/common.json';
import frMessages from '@/locales/fr/common.json';
import jaMessages from '@/locales/ja/common.json';
import koMessages from '@/locales/ko/common.json';
import esMessages from '@/locales/es/common.json';
import itMessages from '@/locales/it/common.json';
import deMessages from '@/locales/de/common.json';
import lvMessages from '@/locales/lv/common.json';
import nlMessages from '@/locales/nl/common.json';
import plMessages from '@/locales/pl/common.json';
import ptMessages from '@/locales/pt/common.json';
import ruMessages from '@/locales/ru/common.json';
import ukMessages from '@/locales/uk/common.json';
import zhMessages from '@/locales/zh/common.json';
// Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = {
en: enMessages,
fr: frMessages,
ja: jaMessages,
ko: koMessages,
es: esMessages,
it: itMessages,
de: deMessages,
lv: lvMessages,
nl: nlMessages,
pl: plMessages,
pt: ptMessages,
ru: ruMessages,
uk: ukMessages,
zh: zhMessages,
};
interface IntlProviderProps {
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useEffect, useState } from "react";
import { X, Download } from "lucide-react";
import { useConfig } from "@/hooks/use-config";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
const DISMISSED_KEY = "pwa-install-dismissed";
export function PWAInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [showPrompt, setShowPrompt] = useState(false);
const { appName, faviconUrl, appLogoLightUrl, appLogoDarkUrl } = useConfig();
useEffect(() => {
if (localStorage.getItem(DISMISSED_KEY)) return;
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setShowPrompt(true);
};
window.addEventListener("beforeinstallprompt", handler);
return () => {
window.removeEventListener("beforeinstallprompt", handler);
};
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") {
setDeferredPrompt(null);
setShowPrompt(false);
}
};
const handleDismiss = () => {
setShowPrompt(false);
};
const handleDismissForever = () => {
localStorage.setItem(DISMISSED_KEY, "1");
setShowPrompt(false);
};
if (!showPrompt || !deferredPrompt) {
return null;
}
const logoSrc = appLogoLightUrl || faviconUrl;
return (
<div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-start gap-3">
{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>
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
Install {appName}
</h3>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
Install our app for quick access and offline support.
</p>
</div>
</div>
<button
onClick={handleDismiss}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
aria-label="Dismiss install prompt"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<button
onClick={handleDismiss}
className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
>
Not now
</button>
<button
onClick={handleInstall}
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
>
Install
</button>
</div>
<button
onClick={handleDismissForever}
className="w-full text-xs text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors text-center"
>
Don&apos;t remind me again
</button>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
"use client";
import { useEffect } from "react";
export function ServiceWorkerRegistration() {
useEffect(() => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
return;
}
if (process.env.NODE_ENV !== "production") {
// In development, unregister any previously installed service worker
// and clear its caches so hot reload always serves fresh assets.
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((registration) => registration.unregister());
});
if ("caches" in window) {
caches.keys().then((keys) => {
keys.forEach((key) => caches.delete(key));
});
}
return;
}
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log("Service Worker registered successfully:", registration);
})
.catch((error) => {
console.error("Service Worker registration failed:", error);
});
}, []);
return null;
}
@@ -157,6 +157,7 @@ function DisplayNameSection() {
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={displayName || t('display_name.placeholder')}
className="w-48"
/>
<Button
@@ -523,7 +524,7 @@ export function AccountSecuritySettings() {
if (isStalwart === false) {
return (
<SettingsSection title={t('title')} description={t('description')}>
<p className="text-sm text-muted-foreground py-4">{t('not_available')}</p>
<div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: t('not_available') }} />
</SettingsSection>
);
}
@@ -0,0 +1,247 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Book, Pencil, Share2, Tag } from "lucide-react";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
import { SettingsSection } from "./settings-section";
import { cn } from "@/lib/utils";
import type { AddressBook } from "@/lib/jmap/types";
function AddressBookEditRow({
initial,
onSave,
onCancel,
isLoading,
}: {
initial: string;
onSave: (name: string) => void;
onCancel: () => void;
isLoading: boolean;
}) {
const t = useTranslations("contacts.address_books");
const tCal = useTranslations("calendar.management");
const [name, setName] = useState(initial);
const isValid = name.trim().length > 0;
return (
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name_label")}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && isValid) onSave(name.trim());
if (e.key === "Escape") onCancel();
}}
className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
disabled={isLoading}
/>
</div>
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => isValid && onSave(name.trim())}
disabled={isLoading || !isValid}
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{tCal("save")}
</button>
<button
onClick={onCancel}
disabled={isLoading}
className="px-3 py-1.5 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{tCal("cancel")}
</button>
</div>
</div>
);
}
export function AddressBookManagementSettings() {
const t = useTranslations("contacts.address_books");
const tContacts = useTranslations("contacts");
const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore();
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, renameKeyword } = useContactStore();
const [editingId, setEditingId] = useState<string | null>(null);
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (client && addressBooks.length === 0) {
fetchAddressBooks(client);
}
}, [client, addressBooks.length, fetchAddressBooks]);
const handleUpdate = async (book: AddressBook, newName: string) => {
if (!client) return;
setIsLoading(true);
try {
await renameAddressBook(client, book, newName);
setEditingId(null);
toast.success(t("renamed"));
} catch {
toast.error(t("rename_failed"));
} finally {
setIsLoading(false);
}
};
// Group: personal first, then by shared account
const personal = addressBooks.filter((b) => !b.isShared);
const sharedGroups = new Map<string, { accountName: string; books: AddressBook[] }>();
for (const book of addressBooks) {
if (!book.isShared || !book.accountId) continue;
const key = book.accountId;
const existing = sharedGroups.get(key);
if (existing) existing.books.push(book);
else sharedGroups.set(key, { accountName: book.accountName || book.accountId, books: [book] });
}
const renderBook = (book: AddressBook) => {
if (editingId === book.id) {
return (
<AddressBookEditRow
key={book.id}
initial={book.name}
onSave={(name) => handleUpdate(book, name)}
onCancel={() => setEditingId(null)}
isLoading={isLoading}
/>
);
}
const canRename = !book.isShared || book.myRights?.mayWrite !== false;
return (
<div
key={book.id}
className={cn(
"flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
)}
>
<Book className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{book.name}</span>
</div>
{book.isDefault && (
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{t("default")}
</span>
)}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{canRename && (
<button
type="button"
onClick={() => setEditingId(book.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t("rename")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
};
// Collect keywords with counts
const keywordCounts: Record<string, number> = {};
for (const c of contacts) {
if (c.kind === "group" || !c.keywords) continue;
for (const [kw, active] of Object.entries(c.keywords)) {
if (active) keywordCounts[kw] = (keywordCounts[kw] || 0) + 1;
}
}
const sortedKeywords = Object.entries(keywordCounts).sort(([a], [b]) => a.localeCompare(b));
const handleRenameKeyword = async (oldKw: string, newKw: string) => {
setIsLoading(true);
try {
await renameKeyword(supportsSync && client ? client : null, oldKw, newKw);
setEditingKeyword(null);
toast.success(tContacts("category_renamed"));
} catch {
toast.error(tContacts("category_rename_failed"));
} finally {
setIsLoading(false);
}
};
return (
<>
<SettingsSection title={tSettings("manage_title")} description={tSettings("manage_description")}>
<div className="space-y-2">
{personal.map(renderBook)}
{Array.from(sharedGroups.entries()).map(([accountId, group]) => (
<div key={accountId} className="mt-4 space-y-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
{t("shared_prefix", { name: group.accountName })}
</h4>
{group.books.map(renderBook)}
</div>
))}
{addressBooks.length === 0 && (
<p className="text-sm text-muted-foreground py-2">{tSettings("no_address_books")}</p>
)}
</div>
</SettingsSection>
<div className="mt-8">
<SettingsSection title={tSettings("categories_title")} description={tSettings("categories_description")}>
<div className="space-y-2">
{sortedKeywords.map(([keyword, count]) => {
if (editingKeyword === keyword) {
return (
<AddressBookEditRow
key={keyword}
initial={keyword}
onSave={(name) => handleRenameKeyword(keyword, name)}
onCancel={() => setEditingKeyword(null)}
isLoading={isLoading}
/>
);
}
return (
<div
key={keyword}
className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
>
<Tag className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{keyword}</span>
</div>
<span className="text-xs text-muted-foreground tabular-nums">{count}</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => setEditingKeyword(keyword)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={tContacts("rename_category")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
{sortedKeywords.length === 0 && (
<p className="text-sm text-muted-foreground py-2">{tSettings("no_categories")}</p>
)}
</div>
</SettingsSection>
</div>
</>
);
}
+58
View File
@@ -8,6 +8,11 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { usePolicyStore } from '@/stores/policy-store';
import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
import { ExternalLink } from 'lucide-react';
import { SpamSiegeGame } from './spam-siege-game';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
export function AdvancedSettings() {
const t = useTranslations('settings.advanced');
@@ -18,6 +23,20 @@ export function AdvancedSettings() {
const [showResetConfirm, setShowResetConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const [showGame, setShowGame] = useState(false);
const logoClickCount = useRef(0);
const logoClickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleLogoClick = () => {
logoClickCount.current++;
if (logoClickTimer.current) clearTimeout(logoClickTimer.current);
if (logoClickCount.current >= 3) {
logoClickCount.current = 0;
setShowGame(true);
} else {
logoClickTimer.current = setTimeout(() => { logoClickCount.current = 0; }, 2000);
}
};
const handleExport = () => {
const settingsJson = exportSettings();
@@ -65,6 +84,44 @@ export function AdvancedSettings() {
};
return (
<>
{showGame && <SpamSiegeGame onClose={() => setShowGame(false)} />}
{/* About */}
<div className="rounded-lg border border-border bg-card p-5 mb-6">
<div className="flex items-center gap-4">
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About">
<div className="shrink-0">
<img
src="/branding/Bulwark_Logo_Color.svg"
alt="Bulwark"
className="w-12 h-12 object-contain dark:hidden group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
/>
<img
src="/branding/Bulwark_Logo_White.svg"
alt="Bulwark"
className="w-12 h-12 object-contain hidden dark:block group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">
{t('about.title')}
</p>
<p className="text-xs text-muted-foreground group-hover/about:translate-x-0.5 group-active/about:translate-y-px transition-transform">
v{APP_VERSION} <span className="text-muted-foreground/60">({GIT_COMMIT})</span>
</p>
</div>
</button>
<a
href="https://github.com/bulwarkmail/webmail"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
GitHub <ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
<SettingsSection title={t('title')} description={t('description')}>
{/* Debug Mode */}
{!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
@@ -147,5 +204,6 @@ export function AdvancedSettings() {
</Button>
</SettingItem>
</SettingsSection>
</>
);
}
+25 -2
View File
@@ -10,6 +10,7 @@ import { useTour } from '@/components/tour/tour-provider';
import { Button } from '@/components/ui/button';
import { PlayCircle } from 'lucide-react';
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 }> = {
'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 }) {
const cfg = DENSITY_PREVIEW[density];
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: 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 tTour = useTranslations('tour');
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 { isSettingLocked, isSettingHidden } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
return (
<SettingsSection title={t('title')} description={t('description')}>
@@ -161,6 +163,27 @@ export function AppearanceSettings() {
/>
</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 */}
{!isSettingHidden('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 { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
import { useSettingsStore } from '@/stores/settings-store';
import { apiFetch } from '@/lib/browser-navigation';
const CALENDAR_COLORS = [
"#3b82f6", // blue
@@ -202,7 +203,7 @@ export function CalendarManagementSettings() {
const controller = new AbortController();
fetch('/api/caldav/discover', {
apiFetch('/api/caldav/discover', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+11
View File
@@ -19,6 +19,7 @@ export function CalendarSettings() {
showWeekNumbers,
enableCalendarTasks,
showTasksOnCalendar,
showBirthdayCalendar,
calendarHoverPreview,
updateSetting,
} = useSettingsStore();
@@ -98,6 +99,16 @@ export function CalendarSettings() {
/>
</SettingItem>
<SettingItem
label={t('show_birthday_calendar')}
description={t('show_birthday_calendar_desc')}
>
<ToggleSwitch
checked={showBirthdayCalendar}
onChange={(checked) => updateSetting('showBirthdayCalendar', checked)}
/>
</SettingItem>
{isFeatureEnabled('calendarTasksEnabled') && (
<>
<SettingItem
+74 -2
View File
@@ -11,8 +11,9 @@ import { useEmailStore } from '@/stores/email-store';
import { cn } from '@/lib/utils';
import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react';
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail, X } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
import { useContactStore } from '@/stores/contact-store';
const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
@@ -110,6 +111,8 @@ export function EmailSettings() {
}
}, []);
const [newKeyword, setNewKeyword] = useState('');
const {
markAsReadDelay,
deleteAction,
@@ -129,12 +132,16 @@ export function EmailSettings() {
hoverActionsMode,
hoverActionsCorner,
trustedSenders,
trustedSendersAddressBook,
attachmentReminderEnabled,
attachmentReminderKeywords,
updateSetting,
} = useSettingsStore();
const { trustedSenderEmails } = useContactStore();
// Get count label for trusted senders button
const getTrustedSendersCount = () => {
const count = trustedSenders.length;
const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length;
if (count === 0) return t('trusted_senders.count_zero');
if (count === 1) return t('trusted_senders.count_one');
return t('trusted_senders.count_other', { count });
@@ -337,6 +344,63 @@ export function EmailSettings() {
/>
</SettingItem>
{/* Attachment Reminder */}
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
<ToggleSwitch
checked={attachmentReminderEnabled}
onChange={(checked) => updateSetting('attachmentReminderEnabled', checked)}
/>
</SettingItem>
{attachmentReminderEnabled && (
<div className="py-3 border-b border-border space-y-2">
<div>
<label className="text-sm font-medium text-foreground">{t('attachment_reminder.keywords_label')}</label>
<p className="text-xs text-muted-foreground mt-1">{t('attachment_reminder.keywords_description')}</p>
</div>
<div className="flex flex-wrap gap-1.5">
{attachmentReminderKeywords.map((kw) => (
<span key={kw} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-foreground">
{kw}
<button
type="button"
aria-label={t('attachment_reminder.remove')}
onClick={() => updateSetting('attachmentReminderKeywords', attachmentReminderKeywords.filter(k => k !== kw))}
className="text-muted-foreground hover:text-foreground"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
const trimmed = newKeyword.trim().toLowerCase();
if (trimmed && !attachmentReminderKeywords.includes(trimmed)) {
updateSetting('attachmentReminderKeywords', [...attachmentReminderKeywords, trimmed]);
}
setNewKeyword('');
}}
>
<input
type="text"
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
placeholder={t('attachment_reminder.add_placeholder')}
className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
/>
<button
type="submit"
disabled={!newKeyword.trim()}
className="px-3 py-1 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('attachment_reminder.add')}
</button>
</form>
</div>
)}
{/* Quick Hover Actions */}
{isFeatureEnabled('hoverActionsConfigEnabled') && (
<div className="py-3 border-b border-border space-y-3">
@@ -511,6 +575,14 @@ export function EmailSettings() {
</button>
</SettingItem>
{/* Trusted Senders - address book storage */}
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
<ToggleSwitch
checked={trustedSendersAddressBook}
onChange={(checked) => updateSetting('trustedSendersAddressBook', checked)}
/>
</SettingItem>
{/* Trusted Senders Modal */}
<TrustedSendersModal
isOpen={showTrustedModal}
+1 -1
View File
@@ -49,7 +49,7 @@ function getPreviewIcon(file: SampleFile, colored: boolean, size: "sm" | "lg") {
}
function formatSize(bytes: number): string {
if (bytes === 0) return "";
if (bytes === 0) return "-";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+127 -75
View File
@@ -23,8 +23,13 @@ import {
Filter,
RotateCcw,
PalmtreeIcon,
Lock,
} from "lucide-react";
function isReadonlyRule(r: FilterRule): boolean {
return r.origin === "external" || r.origin === "opaque";
}
function RuleSummary({ rule }: { rule: FilterRule }) {
const t = useTranslations("settings.filters");
@@ -429,90 +434,137 @@ export function FilterSettings() {
{!isOpaque && rules.length > 0 && (
<div className="space-y-1" role="list" aria-label={t("rule_list")}>
{rules.map((rule, index) => (
<div
key={rule.id}
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" : ""}`}
>
{rules.map((rule, index) => {
const readonly = isReadonlyRule(rule);
if (readonly) {
const label = rule.originLabel || t("origin_external");
const tooltip = t("managed_by_tooltip", { source: label });
const hasStructuredSummary =
rule.origin === "external" &&
rule.conditions.length > 0 &&
rule.actions.length > 0;
return (
<div
key={rule.id}
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
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground pt-0.5"
aria-label={t("drag_to_reorder")}
key={rule.id}
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">
<ToggleSwitch
checked={rule.enabled}
onChange={() => handleToggle(rule.id)}
/>
</div>
<div className="pt-0.5">
<ToggleSwitch
checked={rule.enabled}
onChange={() => handleToggle(rule.id)}
/>
</div>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => {
setEditingRule(rule);
setShowRuleModal(true);
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => {
setEditingRule(rule);
setShowRuleModal(true);
}
}}
>
<p className="text-sm font-medium text-foreground truncate">
{rule.name}
</p>
{expandedFilterView ? (
<VisualRuleSummary rule={rule} />
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setEditingRule(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>
{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>
)}
</SettingsSection>
+21 -5
View File
@@ -99,11 +99,17 @@ function IconPicker({ currentIcon, onSelect, onClose }: {
export function FolderSettings() {
const t = useTranslations('settings.folders');
const { client } = useAuthStore();
const { mailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore();
const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore();
const { folderIcons, setFolderIcon } = useSettingsStore();
const { isFeatureEnabled } = usePolicyStore();
const folderIconsAllowed = isFeatureEnabled('folderIconsEnabled');
useEffect(() => {
if (client && mailboxes.length === 0) {
fetchMailboxes(client);
}
}, [client, mailboxes.length, fetchMailboxes]);
const [isCreating, setIsCreating] = useState(false);
const [creatingParentId, setCreatingParentId] = useState<string | null>(null);
const [newFolderName, setNewFolderName] = useState('');
@@ -200,8 +206,18 @@ export function FolderSettings() {
await deleteMailbox(client, mailboxId);
setDeletingId(null);
toast.success(t('folder_deleted'));
} catch {
toast.error(t('error_delete'));
} catch (err: unknown) {
const jmapType = (err as Error & { jmapType?: string })?.jmapType;
switch (jmapType) {
case 'mailboxHasChild':
toast.error(t('error_delete_has_children'));
break;
case 'mailboxHasEmail':
toast.error(t('error_delete_has_email'));
break;
default:
toast.error(t('error_delete'));
}
} finally {
setIsLoading(false);
}
@@ -474,7 +490,7 @@ export function FolderSettings() {
return (
<div className="space-y-8">
{/* Folder List primary section */}
{/* Folder List - primary section */}
<SettingsSection title={t('folder_list')} description={t('folder_list_description')}>
<div className="space-y-0.5">
{folderTree.length === 0 ? (
@@ -500,7 +516,7 @@ export function FolderSettings() {
)}
</SettingsSection>
{/* Standard Folder Roles advanced section */}
{/* Standard Folder Roles - advanced section */}
<SettingsSection title={t('standard_roles')} description={t('standard_roles_description')}>
{STANDARD_ROLES.map((role) => {
// Disambiguate duplicate folder names by appending parent path

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