Compare commits

...
93 Commits
Author SHA1 Message Date
Linus Rath 9207ec563c Merge branch 'dev' 2026-04-12 02:19:38 +02:00
Linus Rath 44e0e17203 chore: update version to 1.4.13 2026-04-12 02:17:54 +02:00
Linus Rath b9c9901643 chore: update version to 1.4.13 2026-04-12 02:13:55 +02:00
Linus Rath a201c1617b feat: add onAvatarResolve plugin hook 2026-04-12 01:59:53 +02:00
Linus Rath d24f402b0c fix: style links in plain text emails 2026-04-12 01:38:53 +02:00
Linus Rath 5cfc905f10 fix: seed list history entry when app initializes on an email view 2026-04-12 01:33:28 +02:00
Linus Rath 4531cfe47c fix: resolve TS errors from missing createAddressBook in demo client and client ref in thread view 2026-04-12 01:14:25 +02:00
Linus Rath 927a3b8b11 feat: store trusted senders in a dedicated JMAP address book #176 2026-04-12 01:10:28 +02:00
Linus Rath 4ad8396adc fix: remount composer on draft edit and preserve identity #60 2026-04-12 00:11:36 +02:00
shukiandLinus Rath 6aaeb34272 feat: publish standalone tarball on release
Adds a GitHub Action that builds the Next.js standalone output on
release and attaches architecture-specific tarballs (amd64 + arm64)
as release assets. Downstream projects can download and extract
instead of building from source.

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

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

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

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

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

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

All three are backwards-compatible: unset = identical to current behavior.
2026-04-06 23:22:06 +02:00
p.kwiatkowskiandLinus Rath 523711cca3 feat: add Polish language support 2026-04-06 23:20:25 +02:00
Linus Rath bcb487810a feat: add Korean language support 2026-04-05 16:46:17 +02:00
rldn12134andLinus Rath 7e1d644f7d feat: Add Korean translation file 2026-04-05 13:51:22 +02:00
Linus Rath 4aac65d7d2 feat: add birthday calendar feature with settings and localization 2026-04-03 22:45:07 +02:00
Linus Rath 14ecae61dc feat: enhance error handling in Stalwart API responses 2026-04-03 15:10:11 +02:00
Linus Rath c53ff5a30a fix: file deletion logic to update recent files and handle errors #146 2026-04-03 14:20:06 +02:00
Linus Rath 5aad97d64e feat: add PWA support with service worker and install prompt 2026-04-03 04:39:19 +02:00
Linus Rath 081c865018 fix: guard against undefined trigger in calendar event alert popover #143 2026-04-03 03:15:55 +02:00
Linus Rath 050f38b1fa fix: use dynamic batch size for calendar event retrieval 2026-04-02 23:29:37 +02:00
Linus Rath d657bdfa75 fix: improve calendar event retrieval by batching requests to avoid server limits #141 2026-04-02 22:55:07 +02:00
Linus Rath bbf724d9e1 fix: correct logic for marking email as read in EmailViewer component 2026-04-02 22:50:04 +02:00
Linus Rath 9d8c6044e3 fix: optimize htmlToPlainText function using DOMParser 2026-04-02 17:32:53 +02:00
Linus Rath 2d17ca71e3 fix: replace random ID generation with generateUUID() 2026-04-02 17:29:00 +02:00
Linus Rath d77dd1e3e1 fix: replace non-UUID fallback 2026-04-02 17:25:10 +02:00
Linus Rath 9d97b74684 fix: adjust max width of calendar invitation banner for better layout 2026-04-02 14:56:04 +02:00
Linus Rath c4673acb65 fix: replace unguarded crypto.randomUUID() with safe generateUUID() utility 2026-04-02 14:51:21 +02:00
Linus Rath 1bdc51dcc7 feat: implement version badge and easter egg in settings 2026-04-02 14:31:25 +02:00
Linus Rath 6ee0f6a40a feat: disable plugins by default, require admin approval 2026-04-02 13:50:02 +02:00
Linus Rath 9ee25c930e fix: compute per-occurrence utcStart/utcEnd in recurrence expansion #116 2026-04-02 13:35:26 +02:00
Linus Rath 1b2c70a90b fix: de-namespace addressBookIds for shared contacts in create and update operations #133 2026-04-02 11:34:26 +02:00
Linus Rath cdea876992 fix: fetch mailboxes on mount in FolderSettings when store is empty 2026-04-02 11:26:21 +02:00
Linus Rath 16557830ae fix: mailbox deletion error handling 2026-04-02 11:24:11 +02:00
Linus Rath 8836f9cdca fix: wrap handleArchive call to prevent MouseEvent leaking as argument 2026-04-02 11:19:36 +02:00
Linus Rath 26bfe9cb6c fix: extend file drop zone to fill remaining viewport height 2026-04-02 11:14:38 +02:00
Linus Rath 05eaaad61f fix: fallback to application/octet-stream for long MIME types in createFileNode 2026-04-02 11:04:59 +02:00
Linus Rath 2e0852b272 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-02 10:57:03 +02:00
Linus Rath 6173a9ad13 fix: resolve settings sync Identity mismatch for OAuth/SSO sessions #127 2026-04-02 10:54:35 +02:00
Linus RathandGitHub e3560d9cb4 Add funding configuration for Bulwark Webmail 2026-04-02 00:37:58 +02:00
Niklas VossandLinus Rath 52326326e2 fix: check plugin http.post url against origin and add regression tests 2026-04-01 11:26:12 +02:00
Niklas VossandLinus Rath 2734fa08b7 feat: instead of exposing auth headers, offer a http proxy api for plugins 2026-04-01 11:26:12 +02:00
Niklas VossandLinus Rath 67a0d622bc feat: add auth:read permission and ability to retrieve auth headers 2026-04-01 11:26:12 +02:00
chrilepandGitHub 58968a1cbf updated to show latest version in badge 2026-04-01 09:49:23 +02:00
Linus Rath 01c8afbfe8 Merge branch 'dev' 2026-03-31 18:34:49 +02:00
Linus Rath be2e0f2b68 chore: update version to 1.4.11 2026-03-31 18:24:25 +02:00
Linus Rath aa40c8be26 fix: harden proxy auth and SSRF defenses 2026-03-31 17:47:09 +02:00
Linus Rath b3d4c9241c i18n: add missing translation keys to all non-English locales 2026-03-31 16:40:36 +02:00
Linus Rath 34dd5122b3 feat: logging to include categories for better log management 2026-03-31 16:31:27 +02:00
Linus Rath dab3606b04 fix: strip server-immutable fields from updates before sending to JMAP #128 2026-03-31 16:15:56 +02:00
Linus Rath 0f7638055c fix: auto-focus input fields in email composer for improved user experience #126 2026-03-31 16:10:14 +02:00
Linus Rath 66fe7fd359 fix: enhance security by blocking plugins with dangerous JS patterns and enforcing strict session secret length 2026-03-31 15:56:32 +02:00
Linus Rath 1b2ee7da3a fix: prevent orphaning of nested mailboxes by restricting deduplication to root-level folders 2026-03-31 15:29:09 +02:00
Linus Rath f6bec519f4 fix: update file feature disabled messages and add stability warnings 2026-03-31 15:18:13 +02:00
Linus Rath 7102add194 fix: add self-signed certificate detection and update status messages for S/MIME signatures 2026-03-31 15:13:48 +02:00
Linus Rath a3d894730b fix: hardened security, CSP enforcement, SSRF redirect validation, reenabled S/MIME chain verify, IP spoofing prevention, PDF iframe sandbox 2026-03-31 15:11:38 +02:00
163 changed files with 15427 additions and 1048 deletions
+4
View File
@@ -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 (server-side only, never exposed to the browser)
# OAUTH_CLIENT_SECRET=your-client-secret # OAUTH_CLIENT_SECRET=your-client-secret
# Alternatively, you can specify the path to a file containing the OAuth client secret.
# OAUTH_CLIENT_SECRET_FILE=/oauth-client-secret
# OpenID Connect issuer URL for discovery # OpenID Connect issuer URL for discovery
# OAUTH_ISSUER_URL=https://your-idp.example.com # OAUTH_ISSUER_URL=https://your-idp.example.com
@@ -60,6 +62,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Required for both "Remember me" and settings sync features. # Required for both "Remember me" and settings sync features.
# Generate with: openssl rand -base64 32 # Generate with: openssl rand -base64 32
# SESSION_SECRET=your-secret-key-here # SESSION_SECRET=your-secret-key-here
# Alternatively, you can specify the path to a file containing the session secret.
# SESSION_SECRET_FILE=/session-secret
# ============================================================================= # =============================================================================
# Settings Sync # Settings Sync
+4
View File
@@ -0,0 +1,4 @@
# Bulwark Webmail Funding configuration
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
github: [bulwarkmail]
+23 -11
View File
@@ -21,11 +21,23 @@ on:
- ".github/workflows/docker-publish.yml" - ".github/workflows/docker-publish.yml"
workflow_dispatch: workflow_dispatch:
env:
IMAGE_NAME: ghcr.io/${{ github.repository }}
jobs: jobs:
prepare:
runs-on: ubuntu-latest
outputs:
image_name: ${{ steps.set.outputs.image_name }}
steps:
- name: Set image name
id: set
run: |
if [ "${{ github.ref_name }}" = "main" ]; then
echo "image_name=ghcr.io/${{ github.repository }}-beta" >> $GITHUB_OUTPUT
else
echo "image_name=ghcr.io/${{ github.repository }}-${{ github.ref_name }}" >> $GITHUB_OUTPUT
fi
build: build:
needs: prepare
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -57,7 +69,7 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.IMAGE_NAME }} images: ${{ needs.prepare.outputs.image_name }}
- name: Build and push by digest - name: Build and push by digest
id: build id: build
@@ -66,7 +78,7 @@ jobs:
context: . context: .
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.platform }} cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
@@ -86,7 +98,7 @@ jobs:
merge: merge:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build needs: [prepare, build]
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -113,17 +125,17 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.IMAGE_NAME }} images: ${{ needs.prepare.outputs.image_name }}
tags: | tags: |
type=raw,value={{branch}} type=raw,value=latest
type=sha,prefix={{branch}}- type=sha
- name: Create manifest list and push - name: Create manifest list and push
working-directory: /tmp/digests working-directory: /tmp/digests
run: | run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *) $(printf '${{ needs.prepare.outputs.image_name }}@sha256:%s ' *)
- name: Inspect image - name: Inspect image
run: | run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} docker buildx imagetools inspect ${{ needs.prepare.outputs.image_name }}:${{ steps.meta.outputs.version }}
+68
View File
@@ -0,0 +1,68 @@
name: Publish Standalone Tarball on Release
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
arch: amd64
- os: ubuntu-24.04-arm
arch: arm64
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build standalone
run: npm run build
- name: Package tarball
env:
REF_NAME: ${{ github.ref_name }}
ARCH: ${{ matrix.arch }}
run: |
VERSION="${REF_NAME#v}"
TARBALL="bulwark-standalone-${VERSION}-linux-${ARCH}.tar.gz"
mkdir -p bulwark-standalone
cp -r .next/standalone/. bulwark-standalone/
cp -r .next/static bulwark-standalone/.next/static
cp -r public bulwark-standalone/public
tar -czf "$TARBALL" bulwark-standalone/
echo "TARBALL=$TARBALL" >> "$GITHUB_ENV"
- name: Upload release asset
if: github.event_name == 'release'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG_NAME" "$TARBALL" --clobber
- name: Upload artifact (workflow_dispatch)
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v4
with:
name: standalone-${{ matrix.arch }}
path: ${{ env.TARBALL }}
retention-days: 7
+105
View File
@@ -1,5 +1,110 @@
# Changelog # Changelog
## 1.4.13 (2026-04-12)
Thank you for your donations:
**One-time**
- [@boris22100](https://github.com/boris22100)
- [@mkorthaus-private](https://github.com/mkorthaus-private)
**Monthly**
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
### Features
- **Contacts**: Store trusted senders in a dedicated JMAP address book (#176)
- **Email**: Warn on send when attachment keyword found but no file attached (#172)
- **Email**: Enable keyword reordering (#174) and multi-tag support per email (#173)
- **PWA**: Add "don't remind me again" option to install prompt
- **Auth**: Add `SESSION_SECRET_FILE` and `OAUTH_CLIENT_SECRET_FILE` environment variable support
- **Plugins**: Add `onAvatarResolve` plugin hook
- **Docker**: Publish main and dev branches as separate GHCR packages
### Fixes
- **Email**: Style links in plain text emails
- **Email**: Seed list history entry when app initializes on an email view
- **Email**: Remount composer on draft edit and preserve identity (#60)
- **Contacts**: Display contact names stored in `name.full` (#179)
- **Contacts**: Fix category dropdown blocking Save button in contact form (#177)
- **Contacts**: Resolve TS error from optional `name.components` in vCard parser
- **Search**: Search all folders when filtering emails by tag (#175)
- **Auth**: Include mount prefix in SSO redirect URI when app is served under a subpath
- **PWA**: Correct PWA icons with proper sizing, transparency, and dark/light mode support
## 1.4.12 (2026-04-09)
Thank you for your donations:
**One-time**
- [@mkorthaus-private](https://github.com/mkorthaus-private)
**Monthly**
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
### Features
- **PWA**: Add PWA support with service worker and install prompt
- **Calendar**: Add birthday calendar feature with settings and localization
- **Calendar**: Clamp February 29 birthdays in non-leap years
- **Identity**: Add automatic identity synchronization (#167)
- **Plugins**: Disable plugins by default and require admin approval
- **Plugins**: Replace auth header exposure with a secure HTTP proxy API for plugins
- **Auth**: Add configurable OAuth scopes and cookie security via environment variables
- **Email**: Sync mail view to browser history for back/forward navigation
- **Contacts**: Add ability to rename address books (#152)
- **UI**: Add version badge in settings
- **i18n**: Add Latvian (lv) locale support
- **i18n**: Add Polish language support
- **i18n**: Add Korean language support
- **i18n**: Add Simplified Chinese (zh_CN) locale support
### Fixes
- **Email**: Show recipient instead of sender in Sent and Drafts folder lists
- **Email**: Embed dropped images as data URLs and prevent duplicate attachments (#163)
- **Email**: Fix logic for marking email as read in EmailViewer
- **Email**: Fix archive action passing MouseEvent as argument
- **Mailbox**: Preserve search filters on push-triggered mailbox refresh (#164)
- **Mailbox**: Align shared account folders with primary folders (#151)
- **Mailbox**: Fetch mailboxes on mount in FolderSettings when store is empty
- **Mailbox**: Improve mailbox deletion error handling
- **Calendar**: Improve calendar event retrieval by batching requests to avoid server limits (#141)
- **Calendar**: Compute per-occurrence UTC start/end in recurrence expansion (#116)
- **Calendar**: Guard against undefined trigger in calendar event alert popover (#143)
- **Files**: Stream WebDAV PUT uploads to avoid buffering in memory (#162)
- **Files**: Prune recent files against server nodes on refresh (#146)
- **Files**: Fix file deletion logic to update recent files and handle errors (#146)
- **Files**: Extend file drop zone to fill remaining viewport height
- **Files**: Fallback to application/octet-stream for long MIME types
- **Security**: Replace unguarded crypto.randomUUID() with safe generateUUID() utility
- **Security**: Validate plugin HTTP post URL against origin with regression tests
- **Security**: Allow blob images in CSP for inline drag-and-drop (#163)
- **Auth**: Resolve settings sync identity mismatch for OAuth/SSO sessions (#127)
- **Contacts**: Fix address book ID namespacing for shared contacts in create and update operations (#133)
- **UI**: Fix focused mode expanding beyond screen bounds (#156)
- **API**: Handle 403 on principal fetch without console error
- **API**: Enhance error handling in Stalwart API responses
## 1.4.11 (2026-03-31)
### Features
- **Logging**: Add logging categories for better log management
### Fixes
- **Security**: Harden security with CSP enforcement, SSRF redirect validation, reenabled S/MIME chain verify, IP spoofing prevention, and PDF iframe sandbox
- **Security**: Harden proxy authentication and SSRF defenses
- **Security**: Block plugins with dangerous JS patterns and enforce strict session secret length validation
- **S/MIME**: Add self-signed certificate detection and update status messages for S/MIME signatures
- **Email**: Auto-focus input fields in email composer for improved user experience (#126)
- **Mailbox**: Prevent orphaning of nested mailboxes by restricting deduplication to root-level folders
- **JMAP**: Strip server-immutable fields from updates before sending to JMAP (#128)
- **Files**: Update file feature disabled messages and add stability warnings
- **i18n**: Add missing translation keys to all non-English locales
## 1.4.10 (2026-03-31) ## 1.4.10 (2026-03-31)
### Features ### Features
+14 -3
View File
@@ -1,9 +1,9 @@
<div align="center"> <div align="center">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20White%20and%20Color.svg" /> <source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" /> <source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
<img src="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" alt="Bulwark Webmail" width="220" /> <img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
</picture> </picture>
</div> </div>
@@ -12,6 +12,17 @@
Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors. Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors.
## Join our Community
**New to the project or looking for a place to start?** You don't need to be an expert to contribute! Whether you need help setting up your environment, want to report a bug, or are interested in helping with translations, our Discord is the best place to connect.
* **Get Support:** Get real-time help with development hurdles.
* **Contribute:** Share ideas, suggest features, or help us improve documentation.
* **Collaborate:** Meet the team and other contributors working to make Bulwark better.
[**Join the Bulwark Discord Server**](https://discord.gg/tYCujymGrT)
---
## Getting Started ## Getting Started
### Development Setup ### Development Setup
+4 -2
View File
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.4.10-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-1.4.13-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div> </div>
@@ -271,6 +271,7 @@ PORT=3000 # Default listen port
OAUTH_ENABLED=true OAUTH_ENABLED=true
OAUTH_CLIENT_ID=webmail OAUTH_CLIENT_ID=webmail
OAUTH_CLIENT_SECRET= # optional, for confidential clients OAUTH_CLIENT_SECRET= # optional, for confidential clients
OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik) OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
``` ```
@@ -282,7 +283,8 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
<summary>Remember Me</summary> <summary>Remember Me</summary>
```env ```env
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32 SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
SESSION_SECRET_FILE=/session-secret # Path to a file containing the session secret
``` ```
Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry). Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry).
+1 -1
View File
@@ -1 +1 @@
1.4.10 1.4.12
+43 -12
View File
@@ -41,9 +41,11 @@ import { ResizeHandle } from "@/components/layout/resize-handle";
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization"; import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
import { getEventStartDate } from "@/lib/calendar-utils"; import { getEventStartDate } from "@/lib/calendar-utils";
import { useTaskStore } from "@/stores/task-store"; import { useTaskStore } from "@/stores/task-store";
import { useContactStore } from "@/stores/contact-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types"; import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
import { getUserParticipantId } from "@/lib/calendar-participants"; import { getUserParticipantId } from "@/lib/calendar-participants";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
type PendingScopeAction = type PendingScopeAction =
@@ -69,10 +71,11 @@ export default function CalendarPage() {
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
refreshAllSubscriptions, icalSubscriptions, refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore(); } = useCalendarStore();
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview } = useSettingsStore(); const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
const taskStore = useTaskStore(); const taskStore = useTaskStore();
const fetchTasksFn = useTaskStore(state => state.fetchTasks); const fetchTasksFn = useTaskStore(state => state.fetchTasks);
const { identities } = useIdentityStore(); const { identities } = useIdentityStore();
const contacts = useContactStore((s) => s.contacts);
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month"; const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
const currentUserEmails = useMemo(() => const currentUserEmails = useMemo(() =>
@@ -148,6 +151,13 @@ export default function CalendarPage() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [client, refreshAllSubscriptions]); }, [client, refreshAllSubscriptions]);
// Auto-add birthday calendar to selected IDs when enabled
useEffect(() => {
if (showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
toggleCalendarVisibility(BIRTHDAY_CALENDAR_ID);
}
}, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps
const dateRange = useMemo(() => { const dateRange = useMemo(() => {
const d = selectedDate; const d = selectedDate;
switch (normalizedViewMode) { switch (normalizedViewMode) {
@@ -743,14 +753,31 @@ export default function CalendarPage() {
return () => window.removeEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey);
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent, enableCalendarTasks]); }, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent, enableCalendarTasks]);
const visibleEvents = useMemo(() => const birthdayEvents = useMemo(() => {
events.filter((e) => { if (!showBirthdayCalendar || !dateRange) return [];
return generateBirthdayEvents(contacts, dateRange.start, dateRange.end);
}, [showBirthdayCalendar, contacts, dateRange]);
const birthdayCalendarName = (() => {
try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
})();
const allCalendars = useMemo(() => {
if (!showBirthdayCalendar) return calendars;
return [...calendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
}, [calendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
const visibleEvents = useMemo(() => {
const filtered = events.filter((e) => {
if (!e.start || !e.calendarIds) return false; if (!e.start || !e.calendarIds) return false;
const calIds = Object.keys(e.calendarIds); const calIds = Object.keys(e.calendarIds);
return calIds.some((id) => selectedCalendarIds.includes(id)); return calIds.some((id) => selectedCalendarIds.includes(id));
}), });
[events, selectedCalendarIds] if (showBirthdayCalendar && selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
); return [...filtered, ...birthdayEvents];
}
return filtered;
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents]);
useEffect(() => { useEffect(() => {
const hiddenEvents = events.filter((event) => { const hiddenEvents = events.filter((event) => {
@@ -765,7 +792,7 @@ export default function CalendarPage() {
return; return;
} }
debug.log('Calendar visibility summary', { debug.log('calendar', 'Calendar visibility summary', {
totalEvents: events.length, totalEvents: events.length,
visibleEvents: visibleEvents.length, visibleEvents: visibleEvents.length,
hiddenEvents: hiddenEvents.length, hiddenEvents: hiddenEvents.length,
@@ -799,7 +826,7 @@ export default function CalendarPage() {
<CalendarMonthView <CalendarMonthView
selectedDate={selectedDate} selectedDate={selectedDate}
events={visibleEvents} events={visibleEvents}
calendars={calendars} calendars={allCalendars}
onSelectDate={handleSelectDate} onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
@@ -815,7 +842,7 @@ export default function CalendarPage() {
<CalendarWeekView <CalendarWeekView
selectedDate={selectedDate} selectedDate={selectedDate}
events={visibleEvents} events={visibleEvents}
calendars={calendars} calendars={allCalendars}
onSelectDate={handleSelectDate} onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
@@ -834,7 +861,7 @@ export default function CalendarPage() {
<CalendarDayView <CalendarDayView
selectedDate={selectedDate} selectedDate={selectedDate}
events={visibleEvents} events={visibleEvents}
calendars={calendars} calendars={allCalendars}
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave} onHoverLeave={handleHoverLeave}
@@ -851,7 +878,7 @@ export default function CalendarPage() {
<CalendarAgendaView <CalendarAgendaView
selectedDate={selectedDate} selectedDate={selectedDate}
events={visibleEvents} events={visibleEvents}
calendars={calendars} calendars={allCalendars}
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave} onHoverLeave={handleHoverLeave}
@@ -942,10 +969,14 @@ export default function CalendarPage() {
showWeekNumbers={showWeekNumbers} showWeekNumbers={showWeekNumbers}
/> />
<CalendarSidebarPanel <CalendarSidebarPanel
calendars={calendars} calendars={allCalendars}
selectedCalendarIds={selectedCalendarIds} selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility} onToggleVisibility={toggleCalendarVisibility}
onColorChange={client ? (calendarId, color) => { onColorChange={client ? (calendarId, color) => {
if (calendarId === BIRTHDAY_CALENDAR_ID) {
updateSetting('birthdayCalendarColor', color);
return;
}
updateCalendar(client, calendarId, { color }); updateCalendar(client, calendarId, { color });
} : undefined} } : undefined}
onSubscribe={() => setShowSubscriptionModal(true)} onSubscribe={() => setShowSubscriptionModal(true)}
+49 -2
View File
@@ -13,12 +13,13 @@ import { ContactGroupForm } from "@/components/contacts/contact-group-form";
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail"; import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar"; import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog"; import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { RenameDialog } from "@/components/files/rename-dialog";
import { exportContacts } from "@/components/contacts/contact-export"; import { exportContacts } from "@/components/contacts/contact-export";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store"; import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils"; import { cn, generateUUID } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail"; import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view"; import { InlineAppView } from "@/components/layout/inline-app-view";
@@ -72,12 +73,16 @@ export default function ContactsPage() {
bulkDeleteContacts, bulkDeleteContacts,
bulkAddToGroup, bulkAddToGroup,
moveContactToAddressBook, moveContactToAddressBook,
renameAddressBook,
renameKeyword,
importContacts, importContacts,
} = useContactStore(); } = useContactStore();
const [view, setView] = useState<View>("list"); const [view, setView] = useState<View>("list");
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all"); const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
const [showImportDialog, setShowImportDialog] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false);
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null); const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const hasFetched = useRef(false); const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
@@ -274,7 +279,7 @@ export default function ContactsPage() {
toast.success(t("toast.created")); toast.success(t("toast.created"));
} else { } else {
const localContact: ContactCard = { const localContact: ContactCard = {
id: `local-${crypto.randomUUID()}`, id: `local-${generateUUID()}`,
addressBookIds: {}, addressBookIds: {},
...data, ...data,
}; };
@@ -631,6 +636,8 @@ export default function ContactsPage() {
onDeleteGroup={handleDeleteGroupFromSidebar} onDeleteGroup={handleDeleteGroupFromSidebar}
onDropContacts={handleDropContacts} onDropContacts={handleDropContacts}
onDropContactsToCategory={handleDropContactsToCategory} onDropContactsToCategory={handleDropContactsToCategory}
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
/> />
</div> </div>
<ResizeHandle <ResizeHandle
@@ -725,6 +732,46 @@ export default function ContactsPage() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
{renamingKeyword !== null && (
<RenameDialog
currentName={renamingKeyword}
title={t("rename_category")}
label={t("category_name_label")}
onCancel={() => setRenamingKeyword(null)}
onConfirm={async (newName) => {
try {
await renameKeyword(supportsSync && client ? client : null, renamingKeyword, newName);
toast.success(t("category_renamed"));
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === renamingKeyword) {
setActiveCategory({ keyword: newName.trim() });
}
setRenamingKeyword(null);
} catch (err) {
console.error("Failed to rename category:", err);
toast.error(t("category_rename_failed"));
}
}}
/>
)}
{renamingAddressBook && (
<RenameDialog
currentName={renamingAddressBook.name}
title={t("address_books.rename")}
label={t("address_books.name_label")}
onCancel={() => setRenamingAddressBook(null)}
onConfirm={async (newName) => {
if (!client) return;
try {
await renameAddressBook(client, renamingAddressBook, newName);
toast.success(t("address_books.renamed"));
setRenamingAddressBook(null);
} catch (err) {
console.error("Failed to rename address book:", err);
toast.error(t("address_books.rename_failed"));
}
}}
/>
)}
{showImportDialog && ( {showImportDialog && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden"> <div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
+19 -2
View File
@@ -17,15 +17,18 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view"; import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsMobile } from "@/hooks/use-media-query"; import { useIsMobile } from "@/hooks/use-media-query";
import { usePolicyStore } from "@/stores/policy-store";
import { FileBrowser } from "@/components/files/file-browser"; import { FileBrowser } from "@/components/files/file-browser";
import { ImagePreviewModal } from "@/components/files/image-preview-modal"; import { ImagePreviewModal } from "@/components/files/image-preview-modal";
import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { loadFilesSettings } from "@/components/files/files-settings-dialog"; import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog"; import type { FolderLayout } from "@/components/files/files-settings-dialog";
import { AlertTriangle } from "lucide-react";
export default function FilesPage() { export default function FilesPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations("files"); const t = useTranslations("files");
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore(); const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
@@ -392,12 +395,25 @@ export default function FilesPage() {
</div> </div>
)} )}
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0 flex flex-col">
{supportsFiles === false ? ( {!filesEnabled ? (
<div className="flex items-center justify-center h-full">
<div className="max-w-lg text-center space-y-3 px-4">
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
<p className="text-sm font-medium">{t("disabled_title")}</p>
<p className="text-xs text-muted-foreground">{t("disabled_description")}</p>
</div>
</div>
) : supportsFiles === false ? (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">{t("not_available")}</p> <p className="text-sm text-muted-foreground">{t("not_available")}</p>
</div> </div>
) : ( ) : (
<div className="flex flex-col flex-1 min-h-0">
<div className="mx-4 mt-3 mb-1 flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 px-3 py-2">
<AlertTriangle className="w-4 h-4 text-yellow-500 shrink-0 mt-0.5" />
<p className="text-xs text-yellow-700 dark:text-yellow-400">{t("stability_warning")}</p>
</div>
<FileBrowser <FileBrowser
currentPath={currentPath} currentPath={currentPath}
resources={resources} resources={resources}
@@ -442,6 +458,7 @@ export default function FilesPage() {
onToggleDetails={handleToggleDetails} onToggleDetails={handleToggleDetails}
detailResource={detailResource} detailResource={detailResource}
/> />
</div>
)} )}
</div> </div>
</div> </div>
+43 -9
View File
@@ -10,13 +10,15 @@ import { useAuthStore } from "@/stores/auth-store";
import { useThemeStore } from "@/stores/theme-store"; import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
import { getPathPrefix } from "@/lib/browser-navigation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play } from "lucide-react"; import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
const APP_VERSION = "1.4.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 = [ const THEME_OPTIONS = [
{ value: "light" as const, icon: Sun, label: "Light" }, { value: "light" as const, icon: Sun, label: "Light" },
@@ -24,6 +26,41 @@ const THEME_OPTIONS = [
{ value: "system" as const, icon: Monitor, label: "System" }, { value: "system" as const, icon: Monitor, label: "System" },
]; ];
function VersionBadge() {
const [copied, setCopied] = useState(false);
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}`;
const handleCopy = () => {
navigator.clipboard.writeText(versionInfo).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div className="relative inline-flex justify-center">
<p className="peer text-center text-xs text-muted-foreground/40 cursor-default">
v{APP_VERSION}
</p>
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border opacity-0 peer-hover:opacity-100 hover:opacity-100 transition-opacity whitespace-nowrap z-10">
<div className="flex items-center gap-2">
<div className="space-y-0.5">
<p>Version: <span className="font-medium">{APP_VERSION}</span></p>
<p>Build: <span className="font-medium">{GIT_COMMIT}</span></p>
</div>
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label="Copy version info"
>
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
</div>
</div>
);
}
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations("login"); const t = useTranslations("login");
@@ -195,7 +232,8 @@ export default function LoginPage() {
const startServerSideSso = useCallback(async () => { const startServerSideSso = useCallback(async () => {
setOauthLoading(true); setOauthLoading(true);
try { try {
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`; const prefix = getPathPrefix(params.locale as string);
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
const res = await fetch('/api/auth/sso/start', { const res = await fetch('/api/auth/sso/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -579,9 +617,7 @@ export default function LoginPage() {
)} )}
</div> </div>
)} )}
<p className="text-center text-xs text-muted-foreground/40"> <VersionBadge />
v{APP_VERSION}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -1075,9 +1111,7 @@ export default function LoginPage() {
)} )}
</div> </div>
)} )}
<p className="text-center text-xs text-muted-foreground/40"> <VersionBadge />
v{APP_VERSION}
</p>
</div> </div>
</div> </div>
</div> </div>
+162 -27
View File
@@ -14,11 +14,13 @@ import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useIdentityStore } from "@/stores/identity-store"; import { useIdentityStore } from "@/stores/identity-store";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { useDeviceDetection } from "@/hooks/use-media-query"; import { useDeviceDetection } from "@/hooks/use-media-query";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound"; import { playNotificationSound } from "@/lib/notification-sound";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -38,6 +40,7 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view"; import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIdentitySync } from "@/hooks/use-identity-sync";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview"; import { isFilePreviewable } from "@/lib/file-preview";
@@ -59,6 +62,7 @@ export default function Home() {
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose'); const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
const [composerDraftText, setComposerDraftText] = useState(""); const [composerDraftText, setComposerDraftText] = useState("");
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null); const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
const [composerSessionId, setComposerSessionId] = useState(0);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
@@ -76,6 +80,16 @@ export default function Home() {
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null); const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
const { identities } = useIdentityStore(); const { identities } = useIdentityStore();
useIdentitySync();
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore();
// Load trusted senders address book when feature is enabled
useEffect(() => {
if (trustedSendersAddressBook && client && !trustedSendersLoaded) {
loadTrustedSendersBook(client);
}
}, [trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]);
useEffect(() => { useEffect(() => {
if (!isRateLimited || !rateLimitUntil) { if (!isRateLimited || !rateLimitUntil) {
@@ -143,6 +157,102 @@ export default function Home() {
fetchEmailContent, fetchEmailContent,
} = useEmailStore(); } = useEmailStore();
// Browser back / forward integration. The restore handler reads the
// latest values from a ref so we don't have to recreate the callback on
// every render (and so the popstate listener is never stale).
const navRestoreStateRef = useRef({
client,
emails,
mailboxes,
selectedMailbox,
selectedEmailId: selectedEmail?.id ?? null,
conversationThreadId: null as string | null,
});
navRestoreStateRef.current.client = client;
navRestoreStateRef.current.emails = emails;
navRestoreStateRef.current.mailboxes = mailboxes;
navRestoreStateRef.current.selectedMailbox = selectedMailbox;
navRestoreStateRef.current.selectedEmailId = selectedEmail?.id ?? null;
navRestoreStateRef.current.conversationThreadId = conversationThread?.threadId ?? null;
const handleNavRestore = useCallback(async (state: NavSnapshot) => {
const ctx = navRestoreStateRef.current;
// Restore sidebar overlay state.
setSidebarOpen(state.sidebarOpen);
// Restore composer visibility.
if (!state.composerOpen) {
setShowComposer(false);
}
// Derive the mobile view from the saved snapshot. The view is a
// function of which content the user is looking at: an email, a
// thread, the composer, or the bare list.
const derivedView: "list" | "viewer" =
state.emailId || state.threadId || state.composerOpen ? "viewer" : "list";
setActiveView(derivedView);
// Restore mailbox selection. selectMailbox clears the current email,
// which is fine because we re-apply the saved email below.
if (state.mailboxId && state.mailboxId !== ctx.selectedMailbox) {
selectMailbox(state.mailboxId);
if (ctx.client) {
try {
await fetchEmails(ctx.client, state.mailboxId);
} catch (error) {
debug.error('Failed to fetch emails on history restore:', error);
}
}
}
// Restore conversation thread (mobile only). We can clear it directly,
// but reopening requires the thread group; if the user pressed forward
// to return to a thread, we silently skip — back navigation always works.
if ((state.threadId ?? null) !== ctx.conversationThreadId) {
if (state.threadId === null) {
setConversationThread(null);
setConversationEmails([]);
}
}
// Restore email selection.
if (state.emailId !== ctx.selectedEmailId) {
if (state.emailId === null) {
selectEmail(null);
} else {
// Try the in-memory list first; the existing useEffect will fetch
// body content if it's missing.
const found = ctx.emails.find(e => e.id === state.emailId);
if (found) {
selectEmail(found);
} else if (ctx.client) {
// Email isn't in the current list (e.g. mailbox just changed).
// Fetch it directly.
try {
const mailbox = ctx.mailboxes.find(mb => mb.id === state.mailboxId);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const fullEmail = await ctx.client.getEmail(state.emailId, accountId);
if (fullEmail) selectEmail(fullEmail);
} catch (error) {
debug.error('Failed to fetch email on history restore:', error);
}
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useBrowserNavigation({
mailboxId: selectedMailbox,
emailId: selectedEmail?.id ?? null,
threadId: conversationThread?.threadId ?? null,
composerOpen: showComposer,
sidebarOpen,
onRestore: handleNavRestore,
enabled: isAuthenticated && mailboxes.length > 0,
});
// Keyboard shortcuts handlers // Keyboard shortcuts handlers
const keyboardHandlers = useMemo(() => ({ const keyboardHandlers = useMemo(() => ({
onNextEmail: () => { onNextEmail: () => {
@@ -352,13 +462,13 @@ export default function Home() {
if (pushEnabled) { if (pushEnabled) {
setPushConnected(true); setPushConnected(true);
debug.log('[Push] Push notifications successfully enabled'); debug.log('push', '[Push] Push notifications successfully enabled');
} else { } else {
debug.log('[Push] Push notifications not available on this server'); debug.log('push', '[Push] Push notifications not available on this server');
} }
} catch (error) { } catch (error) {
// Push notifications are optional - don't break the app if they fail // Push notifications are optional - don't break the app if they fail
debug.log('[Push] Failed to setup push notifications:', error); debug.log('push', '[Push] Failed to setup push notifications:', error);
} }
} catch (error) { } catch (error) {
console.error('Error loading email data:', error); console.error('Error loading email data:', error);
@@ -392,7 +502,7 @@ export default function Home() {
useEffect(() => { useEffect(() => {
// Clear any existing timeout when email changes // Clear any existing timeout when email changes
if (markAsReadTimeoutRef.current) { if (markAsReadTimeoutRef.current) {
debug.log('[Mark as Read] Clearing previous timeout'); debug.log('email', '[Mark as Read] Clearing previous timeout');
clearTimeout(markAsReadTimeoutRef.current); clearTimeout(markAsReadTimeoutRef.current);
markAsReadTimeoutRef.current = null; markAsReadTimeoutRef.current = null;
} }
@@ -404,20 +514,20 @@ export default function Home() {
// Get current setting value // Get current setting value
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay; const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
debug.log('[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id); debug.log('email', '[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
if (markAsReadDelay === -1) { if (markAsReadDelay === -1) {
// Never mark as read automatically // Never mark as read automatically
debug.log('[Mark as Read] Never mode - email will stay unread'); debug.log('email', '[Mark as Read] Never mode - email will stay unread');
} else if (markAsReadDelay === 0) { } else if (markAsReadDelay === 0) {
// Mark as read instantly // Mark as read instantly
debug.log('[Mark as Read] Instant mode - marking as read now'); debug.log('email', '[Mark as Read] Instant mode - marking as read now');
markAsRead(client, selectedEmail.id, true); markAsRead(client, selectedEmail.id, true);
} else { } else {
// Mark as read after delay // Mark as read after delay
debug.log('[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms'); debug.log('email', '[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms');
markAsReadTimeoutRef.current = setTimeout(() => { markAsReadTimeoutRef.current = setTimeout(() => {
debug.log('[Mark as Read] Timeout fired - marking as read now'); debug.log('email', '[Mark as Read] Timeout fired - marking as read now');
markAsRead(client, selectedEmail.id, true); markAsRead(client, selectedEmail.id, true);
markAsReadTimeoutRef.current = null; markAsReadTimeoutRef.current = null;
}, markAsReadDelay); }, markAsReadDelay);
@@ -426,7 +536,7 @@ export default function Home() {
// Cleanup on unmount or when dependencies change // Cleanup on unmount or when dependencies change
return () => { return () => {
if (markAsReadTimeoutRef.current) { if (markAsReadTimeoutRef.current) {
debug.log('[Mark as Read] Cleanup - clearing timeout'); debug.log('email', '[Mark as Read] Cleanup - clearing timeout');
clearTimeout(markAsReadTimeoutRef.current); clearTimeout(markAsReadTimeoutRef.current);
markAsReadTimeoutRef.current = null; markAsReadTimeoutRef.current = null;
} }
@@ -441,7 +551,7 @@ export default function Home() {
if (emailNotificationsEnabled && emailNotificationSound) { if (emailNotificationsEnabled && emailNotificationSound) {
playNotificationSound(notificationSoundChoice); playNotificationSound(notificationSoundChoice);
} }
debug.log('New email received:', newEmailNotification.subject); debug.log('email', 'New email received:', newEmailNotification.subject);
clearNewEmailNotification(); clearNewEmailNotification();
} }
}, [newEmailNotification, clearNewEmailNotification]); }, [newEmailNotification, clearNewEmailNotification]);
@@ -543,6 +653,16 @@ export default function Home() {
const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId] const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId]
? draft.bodyValues[draft.htmlBody[0].partId].value ? draft.bodyValues[draft.htmlBody[0].partId].value
: undefined; : undefined;
// Try to find the identity that matches the draft's from address to preserve it
const draftFromEmail = draft.from?.[0]?.email;
const matchedIdentity = draftFromEmail
? identities.find(id => id.email === draftFromEmail)
: null;
// Increment session ID to force the composer to remount with fresh state,
// even if it was already open (e.g. right-clicking a draft while composing).
setComposerSessionId(id => id + 1);
setPendingDraft({ setPendingDraft({
to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '', to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '',
cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '', cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '',
@@ -551,7 +671,7 @@ export default function Home() {
body: htmlBody || bodyText, body: htmlBody || bodyText,
showCc: (draft.cc?.length || 0) > 0, showCc: (draft.cc?.length || 0) > 0,
showBcc: (draft.bcc?.length || 0) > 0, showBcc: (draft.bcc?.length || 0) > 0,
selectedIdentityId: null, selectedIdentityId: matchedIdentity?.id ?? null,
subAddressTag: '', subAddressTag: '',
mode: 'compose', mode: 'compose',
draftId: draft.id, draftId: draft.id,
@@ -731,16 +851,22 @@ export default function Home() {
const keywords = { ...email.keywords }; const keywords = { ...email.keywords };
// Remove old label and legacy color tags - set to false for JMAP to remove them if (color === null) {
Object.keys(keywords).forEach(key => { // Remove all label/color tags
if (key.startsWith("$label:") || key.startsWith("$color:")) { Object.keys(keywords).forEach(key => {
keywords[key] = false; if (key.startsWith("$label:") || key.startsWith("$color:")) {
keywords[key] = false;
}
});
} else {
const jmapKey = `$label:${color}`;
if (keywords[jmapKey] === true) {
// Toggle off if already active
keywords[jmapKey] = false;
} else {
// Add the tag without disturbing others
keywords[jmapKey] = true;
} }
});
// Add new label tag if specified (using new $label: prefix)
if (color) {
keywords[`$label:${color}`] = true;
} }
// Update email keywords via JMAP // Update email keywords via JMAP
@@ -1018,9 +1144,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 = () => { const handleMobileBack = () => {
// If in conversation view, clear it if (typeof window !== 'undefined') {
window.history.back();
return;
}
if (conversationThread) { if (conversationThread) {
setConversationThread(null); setConversationThread(null);
setConversationEmails([]); setConversationEmails([]);
@@ -1209,11 +1342,12 @@ export default function Home() {
"max-md:flex-1 max-md:border-r-0", "max-md:flex-1 max-md:border-r-0",
isMobile && activeView !== "list" && "max-md:hidden", isMobile && activeView !== "list" && "max-md:hidden",
// Tablet/Desktop: fixed width with collapse animation // Tablet/Desktop: fixed width with collapse animation
"md:flex-shrink-0 md:shadow-sm", shouldHideViewerPane ? "md:flex-1 md:border-r-0" : "md:flex-shrink-0",
"md:shadow-sm",
!isResizing && "transition-all duration-200 ease-out", !isResizing && "transition-all duration-200 ease-out",
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0" shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
)} )}
style={!isMobile && !shouldCollapseListPane ? { width: emailListWidth } : undefined} style={!isMobile && !shouldCollapseListPane && !shouldHideViewerPane ? { width: emailListWidth } : undefined}
> >
{/* Mobile Header for List View */} {/* Mobile Header for List View */}
<MobileHeader <MobileHeader
@@ -1549,8 +1683,9 @@ export default function Home() {
}} }}
> >
<EmailComposer <EmailComposer
key={composerSessionId}
mode={pendingDraft?.mode ?? composerMode} mode={pendingDraft?.mode ?? composerMode}
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? { replyTo={pendingDraft !== null ? pendingDraft.replyTo : (selectedEmail ? {
from: selectedEmail.from, from: selectedEmail.from,
replyToAddresses: selectedEmail.replyTo, replyToAddresses: selectedEmail.replyTo,
to: selectedEmail.to, to: selectedEmail.to,
@@ -1653,7 +1788,7 @@ export default function Home() {
onReplyAll={handleReplyAll} onReplyAll={handleReplyAll}
onForward={handleForward} onForward={handleForward}
onDelete={handleDelete} onDelete={handleDelete}
onArchive={handleArchive} onArchive={() => handleArchive()}
onToggleStar={handleToggleStar} onToggleStar={handleToggleStar}
onSetColorTag={handleSetColorTag} onSetColorTag={handleSetColorTag}
onMarkAsSpam={handleMarkAsSpam} onMarkAsSpam={handleMarkAsSpam}
+2 -1
View File
@@ -36,6 +36,7 @@ import { IdentitySettings } from '@/components/settings/identity-settings';
import { VacationSettings } from '@/components/settings/vacation-settings'; import { VacationSettings } from '@/components/settings/vacation-settings';
import { CalendarSettings } from '@/components/settings/calendar-settings'; import { CalendarSettings } from '@/components/settings/calendar-settings';
import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings'; import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings';
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
import { FilterSettings } from '@/components/settings/filter-settings'; import { FilterSettings } from '@/components/settings/filter-settings';
import { TemplateSettings } from '@/components/settings/template-settings'; import { TemplateSettings } from '@/components/settings/template-settings';
import { AdvancedSettings } from '@/components/settings/advanced-settings'; import { AdvancedSettings } from '@/components/settings/advanced-settings';
@@ -211,7 +212,7 @@ export default function SettingsPage() {
{activeTab === 'encryption' && <SmimeSettings />} {activeTab === 'encryption' && <SmimeSettings />}
{activeTab === 'vacation' && <VacationSettings />} {activeTab === 'vacation' && <VacationSettings />}
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>} {activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{activeTab === 'contacts' && <ContactsSettings />} {activeTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{activeTab === 'filters' && <FilterSettings />} {activeTab === 'filters' && <FilterSettings />}
{activeTab === 'templates' && <TemplateSettings />} {activeTab === 'templates' && <TemplateSettings />}
{activeTab === 'folders' && <FolderSettings />} {activeTab === 'folders' && <FolderSettings />}
+2 -7
View File
@@ -24,6 +24,7 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store'; import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
@@ -78,13 +79,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
}, [pathname]); }, [pathname]);
function getJmapHeaders(): Record<string, string> { function getJmapHeaders(): Record<string, string> {
const client = useAuthStore.getState().client; return getActiveAccountSlotHeaders();
if (!client) return {};
return {
'Authorization': client.getAuthHeader(),
'X-JMAP-Server-URL': client.getServerUrl(),
'X-JMAP-Username': client.getUsername(),
};
} }
async function checkAuth() { async function checkAuth() {
+21
View File
@@ -60,6 +60,15 @@ export default function AdminPluginsPage() {
setMessage(null); setMessage(null);
} }
function toggleRequirePluginApproval() {
setPolicy(prev => ({
...prev,
features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval },
}));
setPolicyDirty(true);
setMessage(null);
}
async function handleSavePolicy() { async function handleSavePolicy() {
setSavingPolicy(true); setSavingPolicy(true);
setMessage(null); setMessage(null);
@@ -248,6 +257,7 @@ export default function AdminPluginsPage() {
const pluginsEnabled = policy.features.pluginsEnabled ?? true; const pluginsEnabled = policy.features.pluginsEnabled ?? true;
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true; const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
const requirePluginApproval = policy.features.requirePluginApproval ?? true;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -320,6 +330,17 @@ export default function AdminPluginsPage() {
</button> </button>
</div> </div>
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<span className="text-sm text-foreground">Require Admin Approval</span>
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
</div>
<button onClick={toggleRequirePluginApproval}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${requirePluginApproval ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${requirePluginApproval ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
{/* Force enable / disable all */} {/* Force enable / disable all */}
{plugins.length > 0 && ( {plugins.length > 0 && (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex items-center justify-between gap-4">
+1
View File
@@ -19,6 +19,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' }, debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' }, folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' }, hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
}; };
const RESTRICTABLE_SETTINGS = [ const RESTRICTABLE_SETTINGS = [
+17 -2
View File
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/** /**
* GET /api/account/stalwart/auth * GET /api/account/stalwart/auth
* Proxy to Stalwart GET /api/account/auth * Proxy to Stalwart GET /api/account/auth
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
logger.warn('Stalwart auth info failed', { status: response.status }); const detail = parseStalwartError(text);
logger.warn('Stalwart auth info failed', { status: response.status, detail });
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to fetch auth info', details: text }, { error: detail || 'Failed to fetch auth info' },
{ status: response.status } { status: response.status }
); );
} }
+17 -2
View File
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/** /**
* GET /api/account/stalwart/crypto * GET /api/account/stalwart/crypto
* Proxy to Stalwart GET /api/account/crypto * Proxy to Stalwart GET /api/account/crypto
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
logger.warn('Stalwart crypto info failed', { status: response.status }); const detail = parseStalwartError(text);
logger.warn('Stalwart crypto info failed', { status: response.status, detail });
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to fetch crypto info', details: text }, { error: detail || 'Failed to fetch crypto info' },
{ status: response.status } { status: response.status }
); );
} }
+13 -3
View File
@@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { encryptSession } from '@/lib/auth/crypto'; import { encryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie'; import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
const COOKIE_OPTIONS = { const COOKIE_OPTIONS = {
httpOnly: true, httpOnly: true,
@@ -69,10 +70,19 @@ export async function POST(request: NextRequest) {
} }
// If session cookie exists, update it with the new password // If session cookie exists, update it with the new password
const cookieStore = await cookies();
if (creds.hasSessionCookie) { if (creds.hasSessionCookie) {
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword); const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
const cookieStore = await cookies(); cookieStore.set(sessionCookieName(creds.slot), newToken, COOKIE_OPTIONS);
cookieStore.set(SESSION_COOKIE, newToken, COOKIE_OPTIONS); }
if (creds.authHeader.startsWith('Basic ')) {
setStalwartAuthContextInStore(cookieStore, creds.slot, {
serverUrl: creds.serverUrl,
username: creds.username,
authHeader: `Basic ${Buffer.from(`${creds.username}:${newPassword}`).toString('base64')}`,
});
} }
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
+17 -2
View File
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/** /**
* GET /api/account/stalwart/principal * GET /api/account/stalwart/principal
* Proxy to Stalwart GET /api/principal/{username} * Proxy to Stalwart GET /api/principal/{username}
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
logger.warn('Stalwart principal fetch failed', { status: response.status }); const detail = parseStalwartError(text);
logger.warn('Stalwart principal fetch failed', { status: response.status, detail });
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to fetch principal', details: text }, { error: detail || 'Failed to fetch principal' },
{ status: response.status } { status: response.status }
); );
} }
+20
View File
@@ -196,6 +196,26 @@ export async function POST(request: NextRequest) {
const code = await jsFile.async('string'); const code = await jsFile.async('string');
// Block plugins with dangerous JS patterns
const DANGEROUS_JS_PATTERNS = [
{ pattern: /\beval\s*\(/g, label: 'eval()' },
{ pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
{ pattern: /document\.cookie/g, label: 'document.cookie' },
{ pattern: /document\.write/g, label: 'document.write' },
{ pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
];
const dangerousFindings: string[] = [];
for (const { pattern, label } of DANGEROUS_JS_PATTERNS) {
if (pattern.test(code)) dangerousFindings.push(label);
pattern.lastIndex = 0;
}
if (dangerousFindings.length > 0) {
return NextResponse.json(
{ error: `Plugin rejected: contains ${dangerousFindings.join(', ')}. These patterns are not allowed for security reasons.` },
{ status: 400 },
);
}
// Validate permissions // Validate permissions
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : []; const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]); const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
+9 -3
View File
@@ -139,12 +139,18 @@ export async function POST(request: NextRequest) {
} }
const code = await entryFile.async('string'); const code = await entryFile.async('string');
// Security warnings (logged but not blocking for admin) // Security: block plugins containing dangerous JS patterns
const warnings: string[] = []; const warnings: string[] = [];
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) { for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
if (pattern.test(code)) warnings.push(`Contains ${label}`); if (pattern.test(code)) warnings.push(`Contains ${label}`);
pattern.lastIndex = 0; pattern.lastIndex = 0;
} }
if (warnings.length > 0) {
return NextResponse.json(
{ error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` },
{ status: 400 },
);
}
const now = new Date().toISOString(); const now = new Date().toISOString();
const plugin: ServerPlugin = { const plugin: ServerPlugin = {
@@ -165,9 +171,9 @@ export async function POST(request: NextRequest) {
}; };
await savePlugin(plugin, code); await savePlugin(plugin, code);
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, warnings }, ip); await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip);
return NextResponse.json({ plugin, warnings }); return NextResponse.json({ plugin });
} catch (error) { } catch (error) {
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' }); logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+40 -4
View File
@@ -4,6 +4,11 @@ import { logger } from '@/lib/logger';
import { encryptSession, decryptSession } from '@/lib/auth/crypto'; import { encryptSession, decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie'; import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import {
clearStalwartAuthContextInStore,
setStalwartAuthContextInStore,
} from '@/lib/stalwart/auth-context';
const COOKIE_OPTIONS = { const COOKIE_OPTIONS = {
...getCookieOptions(), ...getCookieOptions(),
@@ -31,12 +36,23 @@ export async function POST(request: NextRequest) {
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request); const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
const cookieName = sessionCookieName(slot); const cookieName = sessionCookieName(slot);
const token = encryptSession(serverUrl, username, password); const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
const token = encryptSession(normalizedServerUrl, username, password);
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.set(cookieName, token, COOKIE_OPTIONS); cookieStore.set(cookieName, token, COOKIE_OPTIONS);
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: normalizedServerUrl,
username,
authHeader,
});
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) { } catch (error) {
if (error instanceof JmapAuthVerificationError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' }); logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
} }
@@ -56,9 +72,16 @@ export async function GET(request: NextRequest) {
const credentials = decryptSession(token); const credentials = decryptSession(token);
if (!credentials) { if (!credentials) {
cookieStore.delete(cookieName); cookieStore.delete(cookieName);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 }); return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
} }
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
});
// Only return non-sensitive fields. Use PUT to retrieve full credentials. // Only return non-sensitive fields. Use PUT to retrieve full credentials.
const { serverUrl, username } = credentials; const { serverUrl, username } = credentials;
return NextResponse.json( return NextResponse.json(
@@ -73,13 +96,17 @@ 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 Sec-Fetch-Site to ensure only same-origin browser requests succeed. * Protected by multiple Sec-Fetch-* headers to ensure only same-origin
* browser fetch() requests succeed. Non-browser clients cannot forge these.
*/ */
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
try { try {
// Block non-browser and cross-origin requests // Require all Sec-Fetch-* headers to match a same-origin fetch() call.
// Browsers set these automatically and they cannot be overridden by JS.
const secFetchSite = request.headers.get('sec-fetch-site'); const secFetchSite = request.headers.get('sec-fetch-site');
if (secFetchSite !== 'same-origin') { const secFetchMode = request.headers.get('sec-fetch-mode');
const secFetchDest = request.headers.get('sec-fetch-dest');
if (secFetchSite !== 'same-origin' || secFetchMode !== 'cors' || secFetchDest !== 'empty') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
} }
@@ -95,9 +122,16 @@ export async function PUT(request: NextRequest) {
const credentials = decryptSession(token); const credentials = decryptSession(token);
if (!credentials) { if (!credentials) {
cookieStore.delete(cookieName); cookieStore.delete(cookieName);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 }); return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
} }
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
});
return NextResponse.json(credentials, { return NextResponse.json(credentials, {
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' }, headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
}); });
@@ -116,10 +150,12 @@ export async function DELETE(request: NextRequest) {
// Delete all session cookies (slots 0-4) // Delete all session cookies (slots 0-4)
for (let i = 0; i <= 4; i++) { for (let i = 0; i <= 4; i++) {
cookieStore.delete(sessionCookieName(i)); cookieStore.delete(sessionCookieName(i));
clearStalwartAuthContextInStore(cookieStore, i);
} }
} else { } else {
const slot = getSlot(request); const slot = getSlot(request);
cookieStore.delete(sessionCookieName(slot)); cookieStore.delete(sessionCookieName(slot));
clearStalwartAuthContextInStore(cookieStore, slot);
} }
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
+2 -1
View File
@@ -7,13 +7,14 @@ import { getRequiredConfig } from '@/lib/oauth/token-exchange';
import { discoverOAuth } from '@/lib/oauth/discovery'; import { discoverOAuth } from '@/lib/oauth/discovery';
import { OAUTH_SCOPES } from '@/lib/oauth/tokens'; import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
const SSO_PENDING_COOKIE = 'sso_pending'; const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE = 300; // 5 minutes const SSO_PENDING_MAX_AGE = 300; // 5 minutes
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
if (!process.env.SESSION_SECRET) { if (!process.env.SESSION_SECRET && !readFileEnv(process.env.SESSION_SECRET_FILE)) {
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 }); return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
} }
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
function getSlot(request: NextRequest, bodySlot: unknown): number {
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
return bodySlot;
}
const raw = request.nextUrl.searchParams.get('slot');
if (raw === null) return 0;
const slot = parseInt(raw, 10);
return Number.isNaN(slot) || slot < 0 || slot > 4 ? 0 : slot;
}
export async function POST(request: NextRequest) {
try {
const { serverUrl, username, authHeader, slot: bodySlot } = await request.json();
if (!serverUrl || !username || !authHeader) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const slot = getSlot(request, bodySlot);
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl,
username,
authHeader,
});
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
logger.error('Failed to store Stalwart auth context', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery'; import { discoverOAuth } from '@/lib/oauth/discovery';
import { refreshTokenCookieName } from '@/lib/oauth/tokens'; import { refreshTokenCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
/** /**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens. * Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
@@ -113,7 +114,7 @@ async function attemptAllStrategies(
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint }); logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
const clientId = process.env.OAUTH_CLIENT_ID; 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 basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const attempts: Array<{ strategy: string; error: string }> = []; const attempts: Array<{ strategy: string; error: string }> = [];
+3 -2
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env';
/** /**
* Runtime configuration endpoint * Runtime configuration endpoint
@@ -33,8 +34,8 @@ export async function GET() {
oauthOnly, oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''), oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''), oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
rememberMeEnabled: !!process.env.SESSION_SECRET, rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && !!process.env.SESSION_SECRET, settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
stalwartFeaturesEnabled, stalwartFeaturesEnabled,
devMode: configManager.get<boolean>('devMode', false), devMode: configManager.get<boolean>('devMode', false),
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'), faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
+86 -21
View File
@@ -1,9 +1,39 @@
import { lookup } from 'node:dns/promises';
import { BlockList, isIP } from 'node:net';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const FETCH_TIMEOUT_MS = 15000; const FETCH_TIMEOUT_MS = 15000;
function isValidExternalUrl(urlString: string): boolean { const blockedAddressRanges = new BlockList();
blockedAddressRanges.addAddress('0.0.0.0');
blockedAddressRanges.addAddress('127.0.0.1');
blockedAddressRanges.addSubnet('10.0.0.0', 8);
blockedAddressRanges.addSubnet('172.16.0.0', 12);
blockedAddressRanges.addSubnet('192.168.0.0', 16);
blockedAddressRanges.addSubnet('169.254.0.0', 16);
blockedAddressRanges.addAddress('::', 'ipv6');
blockedAddressRanges.addAddress('::1', 'ipv6');
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
function normalizeHostname(hostname: string): string {
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
}
function isBlockedIpAddress(hostname: string): boolean {
const normalized = normalizeHostname(hostname);
const family = isIP(normalized);
if (family === 4) {
return blockedAddressRanges.check(normalized, 'ipv4');
}
if (family === 6) {
return blockedAddressRanges.check(normalized, 'ipv6');
}
return false;
}
async function isValidExternalUrl(urlString: string): Promise<boolean> {
let url: URL; let url: URL;
try { try {
url = new URL(urlString); url = new URL(urlString);
@@ -15,21 +45,16 @@ function isValidExternalUrl(urlString: string): boolean {
return false; return false;
} }
const hostname = url.hostname.toLowerCase(); const hostname = normalizeHostname(url.hostname);
// Block private/internal hostnames // Block private/internal hostnames
if ( if (
hostname === 'localhost' || hostname === 'localhost' ||
hostname === '127.0.0.1' || hostname.endsWith('.localhost') ||
hostname === '::1' ||
hostname === '0.0.0.0' ||
hostname.endsWith('.local') || hostname.endsWith('.local') ||
hostname.endsWith('.internal') || hostname.endsWith('.internal') ||
hostname.endsWith('.arpa') || hostname.endsWith('.arpa') ||
hostname.startsWith('10.') || hostname.endsWith('.localdomain')
hostname.startsWith('192.168.') ||
hostname.startsWith('169.254.') ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
) { ) {
return false; return false;
} }
@@ -39,7 +64,24 @@ function isValidExternalUrl(urlString: string): boolean {
return false; return false;
} }
return true; if (isBlockedIpAddress(hostname)) {
return false;
}
if (isIP(hostname)) {
return true;
}
try {
const records = await lookup(hostname, { all: true, verbatim: true });
if (records.length === 0) {
return false;
}
return records.every((record) => !isBlockedIpAddress(record.address));
} catch {
return false;
}
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -56,7 +98,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'URL is required' }, { status: 400 }); return NextResponse.json({ error: 'URL is required' }, { status: 400 });
} }
if (!isValidExternalUrl(url)) { if (!(await isValidExternalUrl(url))) {
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 }); return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
} }
@@ -64,20 +106,43 @@ export async function POST(request: NextRequest) {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const response = await fetch(url, { const MAX_REDIRECTS = 5;
signal: controller.signal, let currentUrl = url;
headers: { let response: Response | undefined;
'Accept': 'text/calendar, application/ics, text/plain, */*',
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher', for (let i = 0; i <= MAX_REDIRECTS; i++) {
}, if (!(await isValidExternalUrl(currentUrl))) {
redirect: 'follow', clearTimeout(timeout);
}); return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
}
response = await fetch(currentUrl, {
signal: controller.signal,
headers: {
'Accept': 'text/calendar, application/ics, text/plain, */*',
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
},
redirect: 'manual',
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) {
clearTimeout(timeout);
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
}
// Resolve relative redirects
currentUrl = new URL(location, currentUrl).toString();
continue;
}
break;
}
clearTimeout(timeout); clearTimeout(timeout);
if (!response.ok) { if (!response || !response.ok) {
return NextResponse.json( return NextResponse.json(
{ error: `Remote server returned ${response.status}` }, { error: `Remote server returned ${response?.status ?? 'unknown'}` },
{ status: 502 } { status: 502 }
); );
} }
+23 -6
View File
@@ -3,8 +3,10 @@ import { cookies } from 'next/headers';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { decryptSession } from '@/lib/auth/crypto'; import { decryptSession } from '@/lib/auth/crypto';
import { sessionCookieName } from '@/lib/auth/session-cookie'; import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync'; import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env';
function classifyError(error: unknown): { message: string; status: number } { function classifyError(error: unknown): { message: string; status: number } {
const code = (error as NodeJS.ErrnoException).code; const code = (error as NodeJS.ErrnoException).code;
@@ -47,24 +49,39 @@ function classifyError(error: unknown): { message: string; status: number } {
} }
function isEnabled(): boolean { function isEnabled(): boolean {
return process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET; return process.env.SETTINGS_SYNC_ENABLED === 'true' && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE));
}
/** Strip trailing slashes so differently-formatted URLs still match. */
function normalizeUrl(url: string): string {
return url.replace(/\/+$/, '');
} }
/** /**
* Verify identity against session cookies across all account slots. * Verify identity against session cookies across all account slots.
* With multi-account, the requesting account may be on any slot (0-4). * With multi-account, the requesting account may be on any slot (0-4).
* Returns true 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> { async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
const cookieStore = await cookies(); const cookieStore = await cookies();
const normalizedServerUrl = normalizeUrl(serverUrl);
for (let slot = 0; slot <= 4; slot++) { for (let slot = 0; slot <= 4; slot++) {
// Check basic-auth session cookie
const token = cookieStore.get(sessionCookieName(slot))?.value; const token = cookieStore.get(sessionCookieName(slot))?.value;
if (!token) continue; if (token) {
const session = decryptSession(token);
if (session && session.username === username && normalizeUrl(session.serverUrl) === normalizedServerUrl) {
return true;
}
}
const session = decryptSession(token); // Check stalwart auth context cookie (set for all auth modes)
if (session && session.username === username && session.serverUrl === serverUrl) { const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
return true; // Found a matching slot if (ctx && ctx.username === username && normalizeUrl(ctx.serverUrl) === normalizedServerUrl) {
return true;
} }
} }
+42 -12
View File
@@ -4,6 +4,32 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials';
const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']); const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']);
function normalizeDavRelativePath(rawPath: string): string {
const sanitized = rawPath.replace(/\\/g, '/').split(/[?#]/, 1)[0] ?? '';
const segments = sanitized.split('/').filter(Boolean);
return segments.map((segment) => {
let decoded: string;
try {
decoded = decodeURIComponent(segment);
} catch {
throw new Error('Invalid WebDAV path encoding');
}
if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\') || decoded.includes('\0')) {
throw new Error('Invalid WebDAV path segment');
}
return encodeURIComponent(decoded);
}).join('/');
}
function buildDavTargetUrl(baseUrl: string, username: string, rawPath: string): string {
const rootUrl = new URL(`${baseUrl.replace(/\/$/, '')}/dav/file/${encodeURIComponent(username)}/`);
const relativePath = normalizeDavRelativePath(rawPath);
return relativePath ? new URL(relativePath, rootUrl).toString() : rootUrl.toString();
}
/** /**
* POST /api/webdav * POST /api/webdav
* Proxies WebDAV requests to the Stalwart server. * Proxies WebDAV requests to the Stalwart server.
@@ -29,11 +55,8 @@ export async function POST(request: NextRequest) {
} }
const davPath = request.headers.get('X-WebDAV-Path') || '/'; const davPath = request.headers.get('X-WebDAV-Path') || '/';
const cleanPath = davPath.replace(/^\/+/, '');
const baseUrl = creds.apiUrl.replace(/\/$/, ''); const baseUrl = creds.apiUrl.replace(/\/$/, '');
const targetUrl = cleanPath const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanPath}`
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
// Build headers for the upstream request // Build headers for the upstream request
const upstreamHeaders: Record<string, string> = { const upstreamHeaders: Record<string, string> = {
@@ -50,19 +73,20 @@ export async function POST(request: NextRequest) {
// For MOVE/COPY, construct the full Destination URL from the relative path // For MOVE/COPY, construct the full Destination URL from the relative path
const destination = request.headers.get('X-WebDAV-Destination'); const destination = request.headers.get('X-WebDAV-Destination');
if (destination) { if (destination) {
const cleanDest = destination.replace(/^\/+/, ''); upstreamHeaders['Destination'] = buildDavTargetUrl(baseUrl, creds.username, destination);
upstreamHeaders['Destination'] = cleanDest
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanDest}`
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
} }
const overwrite = request.headers.get('Overwrite'); const overwrite = request.headers.get('Overwrite');
if (overwrite) upstreamHeaders['Overwrite'] = overwrite; if (overwrite) upstreamHeaders['Overwrite'] = overwrite;
// Forward request body for methods that need it // Forward request body for methods that need it.
let body: ArrayBuffer | null = null; // PUT streams directly to upstream to avoid buffering large uploads in memory.
if (method === 'PROPFIND' || method === 'PUT') { // PROPFIND bodies are small XML and are read fully.
let body: ArrayBuffer | ReadableStream<Uint8Array> | null = null;
if (method === 'PROPFIND') {
body = await request.arrayBuffer(); body = await request.arrayBuffer();
} else if (method === 'PUT') {
body = request.body;
} }
const response = await fetch(targetUrl, { const response = await fetch(targetUrl, {
@@ -70,7 +94,9 @@ export async function POST(request: NextRequest) {
headers: upstreamHeaders, headers: upstreamHeaders,
body, body,
redirect: 'follow', redirect: 'follow',
}); // `duplex: 'half'` is required by undici when sending a streaming request body.
...(method === 'PUT' ? { duplex: 'half' } : {}),
} as Parameters<typeof fetch>[1] & { duplex?: 'half' });
// For file downloads (GET), stream the response back // For file downloads (GET), stream the response back
if (method === 'GET') { if (method === 'GET') {
@@ -104,6 +130,10 @@ export async function POST(request: NextRequest) {
status: response.status, status: response.status,
}); });
} catch (error) { } catch (error) {
if (error instanceof Error && error.message.startsWith('Invalid WebDAV path')) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' }); logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
} }
+5
View File
@@ -208,6 +208,11 @@ body {
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
} }
.email-content-text a {
color: var(--color-primary);
text-decoration: underline;
}
.email-content { .email-content {
font-family: font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
+19 -2
View File
@@ -1,3 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Gravit.io --> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clipPath_Sdk6Fary7vPYZxd20WRVIKca0qb2ogEk"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clipPath_Sdk6Fary7vPYZxd20WRVIKca0qb2ogEk)"><rect width="1000" height="1000" style="fill:rgb(0,0,0)" fill-opacity="0"/><path d=" M 483.028 563.647 L 63.713 187.183 C 59.029 182.978 55.226 174.454 55.226 168.159 L 55.226 122.542 C 55.226 116.247 60.206 109.988 66.339 108.573 L 215.181 74.225 C 221.314 72.809 226.293 76.77 226.293 83.065 L 226.293 176.92 L 352.034 147.895 C 358.167 146.479 363.147 140.219 363.147 133.925 L 363.147 51.483 C 363.147 45.189 368.126 38.93 374.259 37.514 L 488.888 11.061 C 495.021 9.646 504.979 9.646 511.112 11.061 L 625.741 37.514 C 631.874 38.93 636.853 45.189 636.853 51.483 L 636.853 133.925 C 636.853 140.219 641.833 146.479 647.966 147.895 L 773.707 176.92 L 773.707 83.065 C 773.707 76.77 778.686 72.809 784.819 74.225 L 933.661 108.573 C 939.794 109.988 944.774 116.247 944.774 122.542 L 944.774 168.159 C 944.774 174.454 940.971 182.978 936.287 187.183 L 516.972 563.647 C 507.605 572.056 492.395 572.056 483.028 563.647 Z " fill="rgb(219,45,84)"/><path d=" M 944.774 332.832 L 944.774 396.969 C 944.774 403.263 941.16 411.987 936.709 416.437 L 884.411 468.736 C 879.96 473.186 876.347 481.91 876.347 488.204 L 876.347 682.08 C 876.345 718.462 866.664 750.017 847.953 778.668 L 658.833 589.547 L 944.774 332.832 Z " fill="rgb(219,45,84)"/><path d=" M 55.226 332.832 L 55.226 385.564 C 55.226 398.153 62.453 415.6 71.355 424.501 L 107.525 460.671 C 116.426 469.573 123.653 487.02 123.653 499.609 L 123.653 682.08 C 123.655 718.462 133.336 750.017 152.047 778.668 L 341.167 589.547 L 55.226 332.832 Z " fill="rgb(219,45,84)"/><path d=" M 765.645 857.641 C 701.996 901.327 612.26 941.932 500 990 Q 500 990 500 990 C 387.74 941.932 298.004 901.327 234.355 857.641 L 427.917 664.079 C 435.604 668.703 443.74 672.579 452.215 675.636 C 467.543 681.179 483.703 684.007 500 683.996 C 516.297 684.007 532.457 681.179 547.785 675.636 C 556.261 672.579 564.396 668.703 572.083 664.079 L 765.645 857.641 Z " fill="rgb(219,45,84)"/></g></svg> <defs>
<clipPath id="_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV"><rect width="1000" height="1000"/></clipPath>
<style>
.icon-bg { fill: #ffffff; }
.icon-mark { fill: rgb(219,45,84); }
@media (prefers-color-scheme: dark) {
.icon-bg { fill: #18181b; }
}
</style>
</defs>
<g clip-path="url(#_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV)">
<rect width="1000" height="1000" class="icon-bg"/>
<path d=" M 489.315 575.068 L 225.342 338.071 C 222.394 335.424 220 330.058 220 326.095 L 220 297.377 C 220 293.415 223.135 289.474 226.996 288.583 L 320.697 266.96 C 324.558 266.069 327.692 268.563 327.692 272.525 L 327.692 331.61 L 406.851 313.338 C 410.712 312.446 413.846 308.506 413.846 304.543 L 413.846 252.643 C 413.846 248.681 416.981 244.741 420.842 243.85 L 493.004 227.197 C 496.865 226.306 503.135 226.306 506.996 227.197 L 579.158 243.85 C 583.019 244.741 586.154 248.681 586.154 252.643 L 586.154 304.543 C 586.154 308.506 589.288 312.446 593.149 313.338 L 672.308 331.61 L 672.308 272.525 C 672.308 268.563 675.442 266.069 679.303 266.96 L 773.004 288.583 C 776.865 289.474 780 293.415 780 297.377 L 780 326.095 C 780 330.058 777.606 335.424 774.658 338.071 L 510.685 575.068 C 504.788 580.362 495.212 580.362 489.315 575.068 Z " class="icon-mark"/>
<path d=" M 780 429.762 L 780 470.138 C 780 474.101 777.725 479.593 774.923 482.394 L 742 515.318 C 739.198 518.12 736.923 523.612 736.923 527.574 L 736.923 649.625 C 736.922 672.529 730.827 692.394 719.048 710.431 L 599.991 591.373 L 780 429.762 Z " class="icon-mark"/>
<path d=" M 220 429.762 L 220 462.959 C 220 470.884 224.55 481.867 230.153 487.471 L 252.924 510.241 C 258.527 515.845 263.077 526.829 263.077 534.754 L 263.077 649.625 C 263.078 672.529 269.173 692.394 280.952 710.431 L 400.009 591.373 L 220 429.762 Z " class="icon-mark"/>
<path d=" M 667.232 760.147 C 627.163 787.649 570.672 813.211 500 843.472 Q 500 843.472 500 843.472 C 429.328 813.211 372.837 787.649 332.768 760.147 L 454.622 638.293 C 459.461 641.204 464.582 643.644 469.918 645.569 C 479.567 649.058 489.741 650.839 500 650.832 C 510.259 650.839 520.433 649.058 530.082 645.569 C 535.418 643.644 540.539 641.204 545.378 638.293 L 667.232 760.147 Z " class="icon-mark"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+21
View File
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { getLocale } from "next-intl/server"; import { getLocale } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -20,6 +22,15 @@ export async function generateMetadata(): Promise<Metadata> {
return { return {
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail", title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
description: "Minimalist webmail client using JMAP protocol", description: "Minimalist webmail client using JMAP protocol",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
},
formatDetection: {
telephone: false,
},
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}), ...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
}; };
} }
@@ -36,6 +47,14 @@ export default async function RootLayout({
return ( return (
<html lang={locale} suppressHydrationWarning> <html lang={locale} suppressHydrationWarning>
<head> <head>
<meta name="theme-color" content="#ffffff" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-title"
content={process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail"}
/>
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
{parentOrigin && ( {parentOrigin && (
<meta name="parent-origin" content={parentOrigin} /> <meta name="parent-origin" content={parentOrigin} />
)} )}
@@ -63,7 +82,9 @@ export default async function RootLayout({
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
<ServiceWorkerRegistration />
{children} {children}
<PWAInstallPrompt />
</body> </body>
</html> </html>
); );
@@ -2,13 +2,14 @@
import { useState, useRef, useEffect, useMemo } from "react"; import { useState, useRef, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Globe, ListTodo, 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 { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types"; import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useTaskStore } from "@/stores/task-store"; import { useTaskStore } from "@/stores/task-store";
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { IJMAPClient } from '@/lib/jmap/client-interface';
@@ -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> </button>
{/* Subscription context menu on right-click */} {/* 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 { function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
if (!event.alerts) return null; if (!event.alerts) return null;
const first = Object.values(event.alerts)[0]; const first = Object.values(event.alerts)[0];
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null; if (!first || !first.trigger || first.trigger["@type"] !== "OffsetTrigger") return null;
const offset = first.trigger.offset; const offset = first.trigger.offset;
if (offset === "PT0S") return t("alerts.at_time"); if (offset === "PT0S") return t("alerts.at_time");
const minMatch = offset.match(/-?PT?(\d+)M$/); const minMatch = offset.match(/-?PT?(\d+)M$/);
+2 -3
View File
@@ -20,6 +20,7 @@ import {
} from "@/lib/calendar-participants"; } from "@/lib/calendar-participants";
import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils";
export interface PendingEventPreview { export interface PendingEventPreview {
start: Date; start: Date;
@@ -402,9 +403,7 @@ export function EventModal({
if (!event || !onDuplicate) return; if (!event || !onDuplicate) return;
const start = getEventStartDate(event); const start = getEventStartDate(event);
const newStart = addDays(start, 1); const newStart = addDays(start, 1);
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID const newUid = generateUUID();
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const data: Partial<CalendarEvent> = { const data: Partial<CalendarEvent> = {
uid: newUid, uid: newUid,
title: event.title, title: event.title,
+5 -19
View File
@@ -304,11 +304,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
if (contact?.addressBookIds) { if (contact?.addressBookIds) {
const ids = Object.keys(contact.addressBookIds).filter(k => contact.addressBookIds[k]); const ids = Object.keys(contact.addressBookIds).filter(k => contact.addressBookIds[k]);
if (ids.length > 0) { if (ids.length > 0) {
// For shared contacts, the addressBookIds uses the original (non-namespaced) id // addressBookIds are already namespaced for shared contacts (e.g. "accountId:bookId")
// but we need the namespaced id to match addressBooks entries // so we can use them directly to match addressBook entries
if (contact.isShared && contact.accountId) {
return `${contact.accountId}:${ids[0]}`;
}
return ids[0]; return ids[0];
} }
} }
@@ -914,7 +911,6 @@ function CategoryComboBox({
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Parse current keywords from comma-separated string // Parse current keywords from comma-separated string
@@ -949,17 +945,6 @@ function CategoryComboBox({
onChange(next); onChange(next);
}, [currentKeywords, onChange]); }, [currentKeywords, onChange]);
// Close dropdown on outside click
useEffect(() => {
if (!isOpen) return;
const handler = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [isOpen]);
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") { if (e.key === "Enter") {
@@ -973,7 +958,7 @@ function CategoryComboBox({
}; };
return ( return (
<div ref={wrapperRef} className="relative"> <div className="relative">
{/* Keyword badges */} {/* Keyword badges */}
{currentKeywords.length > 0 && ( {currentKeywords.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2"> <div className="flex flex-wrap gap-1.5 mb-2">
@@ -1001,6 +986,7 @@ function CategoryComboBox({
value={inputValue} value={inputValue}
onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }} onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }}
onFocus={() => setIsOpen(true)} onFocus={() => setIsOpen(true)}
onBlur={() => setIsOpen(false)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={currentKeywords.length === 0 ? placeholder : ""} placeholder={currentKeywords.length === 0 ? placeholder : ""}
/> />
@@ -1008,7 +994,7 @@ function CategoryComboBox({
{/* Dropdown */} {/* Dropdown */}
{isOpen && (suggestions.length > 0 || canAddNew) && ( {isOpen && (suggestions.length > 0 || canAddNew) && (
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1"> <div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1" onMouseDown={(e) => e.preventDefault()}>
{suggestions.map(kw => ( {suggestions.map(kw => (
<button <button
key={kw} key={kw}
+111 -28
View File
@@ -2,7 +2,8 @@
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react"; import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2 } from "lucide-react"; import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu";
@@ -25,6 +26,8 @@ interface ContactsSidebarProps {
onDeleteGroup?: (groupId: string) => void; onDeleteGroup?: (groupId: string) => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void; onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void; onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
onRenameAddressBook?: (addressBook: AddressBook) => void;
onRenameKeyword?: (keyword: string) => void;
className?: string; className?: string;
} }
@@ -58,10 +61,15 @@ export function ContactsSidebar({
onDeleteGroup, onDeleteGroup,
onDropContacts, onDropContacts,
onDropContactsToCategory, onDropContactsToCategory,
onRenameAddressBook,
onRenameKeyword,
className, className,
}: ContactsSidebarProps) { }: ContactsSidebarProps) {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const router = useRouter();
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>(); const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
const { contextMenu: bookContextMenu, openContextMenu: openBookContextMenu, closeContextMenu: closeBookContextMenu, menuRef: bookMenuRef } = useContextMenu<AddressBook>();
const { contextMenu: keywordContextMenu, openContextMenu: openKeywordContextMenu, closeContextMenu: closeKeywordContextMenu, menuRef: keywordMenuRef } = useContextMenu<string>();
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed); const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
@@ -246,19 +254,32 @@ export function ContactsSidebar({
{/* My Address Books */} {/* My Address Books */}
{personalBooks.length > 0 && ( {personalBooks.length > 0 && (
<div className="mt-2"> <div className="mt-2">
<button <div className="flex items-center px-3 py-1 group">
onClick={() => toggleSection("addressBooks")} <button
className="flex items-center gap-1 px-3 py-1 w-full text-left group" onClick={() => toggleSection("addressBooks")}
> className="flex items-center gap-1 flex-1 text-left"
{collapsed.addressBooks ? ( >
<ChevronRight className="w-3 h-3 text-muted-foreground" /> {collapsed.addressBooks ? (
) : ( <ChevronRight className="w-3 h-3 text-muted-foreground" />
<ChevronDown className="w-3 h-3 text-muted-foreground" /> ) : (
)} <ChevronDown className="w-3 h-3 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> )}
{t("address_books.title")} <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
</span> {t("address_books.title")}
</button> </span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
title={t("address_books.manage")}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
</div>
{!collapsed.addressBooks && personalBooks.map((book) => ( {!collapsed.addressBooks && personalBooks.map((book) => (
<AddressBookItem <AddressBookItem
key={book.id} key={book.id}
@@ -267,6 +288,7 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0} contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })} onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts} onDropContacts={onDropContacts}
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
/> />
))} ))}
</div> </div>
@@ -362,6 +384,7 @@ export function ContactsSidebar({
isActive={isActive} isActive={isActive}
onSelect={() => onSelectCategory({ keyword })} onSelect={() => onSelectCategory({ keyword })}
onDropContacts={onDropContactsToCategory} onDropContacts={onDropContactsToCategory}
onContextMenu={onRenameKeyword ? (e) => openKeywordContextMenu(e, keyword) : undefined}
/> />
); );
})} })}
@@ -372,20 +395,33 @@ export function ContactsSidebar({
{/* Shared accounts with address books */} {/* Shared accounts with address books */}
{sharedBookGroups.map((group) => ( {sharedBookGroups.map((group) => (
<div key={group.accountId} className="mt-2"> <div key={group.accountId} className="mt-2">
<button <div className="flex items-center px-3 py-1 group">
onClick={() => toggleSection(`shared-${group.accountId}`)} <button
className="flex items-center gap-1 px-3 py-1 w-full text-left group" onClick={() => toggleSection(`shared-${group.accountId}`)}
> className="flex items-center gap-1 flex-1 min-w-0 text-left"
{collapsed[`shared-${group.accountId}`] ? ( >
<ChevronRight className="w-3 h-3 text-muted-foreground" /> {collapsed[`shared-${group.accountId}`] ? (
) : ( <ChevronRight className="w-3 h-3 text-muted-foreground" />
<ChevronDown className="w-3 h-3 text-muted-foreground" /> ) : (
)} <ChevronDown className="w-3 h-3 text-muted-foreground" />
<Share2 className="w-3 h-3 text-muted-foreground" /> )}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate"> <Share2 className="w-3 h-3 text-muted-foreground" />
{t("address_books.shared_prefix", { name: group.accountName })} <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
</span> {t("address_books.shared_prefix", { name: group.accountName })}
</button> </span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
title={t("address_books.manage")}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
</div>
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => ( {!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
<AddressBookItem <AddressBookItem
key={book.id} key={book.id}
@@ -394,12 +430,53 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0} contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })} onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts} onDropContacts={onDropContacts}
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
/> />
))} ))}
</div> </div>
))} ))}
</div> </div>
{/* Address book context menu */}
{bookContextMenu.data && onRenameAddressBook && (
<ContextMenu
ref={bookMenuRef}
isOpen={bookContextMenu.isOpen}
position={bookContextMenu.position}
onClose={closeBookContextMenu}
>
<ContextMenuItem
icon={Pencil}
label={t("address_books.rename")}
onClick={() => {
const book = bookContextMenu.data!;
closeBookContextMenu();
onRenameAddressBook(book);
}}
/>
</ContextMenu>
)}
{/* Keyword (category) context menu */}
{keywordContextMenu.data && onRenameKeyword && (
<ContextMenu
ref={keywordMenuRef}
isOpen={keywordContextMenu.isOpen}
position={keywordContextMenu.position}
onClose={closeKeywordContextMenu}
>
<ContextMenuItem
icon={Pencil}
label={t("rename_category")}
onClick={() => {
const kw = keywordContextMenu.data!;
closeKeywordContextMenu();
onRenameKeyword(kw);
}}
/>
</ContextMenu>
)}
{/* Group context menu */} {/* Group context menu */}
{groupContextMenu.data && ( {groupContextMenu.data && (
<ContextMenu <ContextMenu
@@ -438,12 +515,14 @@ function CategoryItem({
isActive, isActive,
onSelect, onSelect,
onDropContacts, onDropContacts,
onContextMenu,
}: { }: {
keyword: string; keyword: string;
count: number; count: number;
isActive: boolean; isActive: boolean;
onSelect: () => void; onSelect: () => void;
onDropContacts?: (contactIds: string[], keyword: string) => void; onDropContacts?: (contactIds: string[], keyword: string) => void;
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) { }) {
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
@@ -476,6 +555,7 @@ function CategoryItem({
return ( return (
<button <button
onClick={onSelect} onClick={onSelect}
onContextMenu={onContextMenu}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
@@ -503,12 +583,14 @@ function AddressBookItem({
contactCount, contactCount,
onSelect, onSelect,
onDropContacts, onDropContacts,
onContextMenu,
}: { }: {
book: AddressBook; book: AddressBook;
isActive: boolean; isActive: boolean;
contactCount: number; contactCount: number;
onSelect: () => void; onSelect: () => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void; onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) { }) {
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
@@ -541,6 +623,7 @@ function AddressBookItem({
return ( return (
<button <button
onClick={onSelect} onClick={onSelect}
onContextMenu={onContextMenu}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
+57 -56
View File
@@ -754,49 +754,67 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
{/* Content */} {/* Content */}
<div className="px-4 py-3 space-y-2.5"> <div className="px-4 py-3 space-y-2.5">
{/* Event title */} <div className="lg:flex lg:gap-6">
{summary?.title && ( {/* Left: Event info */}
<div className="flex items-start justify-between gap-2"> <div className="lg:flex-1 space-y-2.5 min-w-0">
<h3 className={cn( {/* Event title */}
"text-base font-semibold leading-snug", {summary?.title && (
isCancellation ? "line-through text-muted-foreground" : "text-foreground" <div className="flex items-start justify-between gap-2">
)}> <h3 className={cn(
{summary.title} "text-base font-semibold leading-snug",
</h3> isCancellation ? "line-through text-muted-foreground" : "text-foreground"
{parsedEvent?.sequence != null && parsedEvent.sequence > 0 && ( )}>
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground flex-shrink-0 whitespace-nowrap"> {summary.title}
{t('event_updated', { sequence: parsedEvent.sequence })} </h3>
</span> {parsedEvent?.sequence != null && parsedEvent.sequence > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground flex-shrink-0 whitespace-nowrap">
{t('event_updated', { sequence: parsedEvent.sequence })}
</span>
)}
</div>
)} )}
</div>
)}
{/* Event details */} {/* Event details */}
<div className="space-y-1"> <div className="lg:flex lg:items-center lg:gap-4 lg:flex-wrap space-y-1 lg:space-y-0">
{summary?.start && ( {summary?.start && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground"> <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="w-3.5 h-3.5 flex-shrink-0" /> <Clock className="w-3.5 h-3.5 flex-shrink-0" />
<span> <span>
{formatDateTime(summary.start)} {formatDateTime(summary.start)}
{summary.end && ` ${formatDateTime(summary.end)}`} {summary.end && ` ${formatDateTime(summary.end)}`}
</span> </span>
</div>
)}
{summary?.location && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
<span>{summary.location}</span>
</div>
)}
{summary?.organizer && (
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Users className="w-3.5 h-3.5 flex-shrink-0" />
{t('organizer', { name: summary.organizer })}
</span>
)}
{summary && summary.attendeeCount > 0 && (
<span className="text-sm text-muted-foreground">{t('attendees', { count: summary.attendeeCount })}</span>
)}
</div> </div>
)} </div>
{summary?.location && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground"> {/* Right: Info & actor messages on large screens */}
<MapPin className="w-3.5 h-3.5 flex-shrink-0" /> <div className="lg:flex-shrink-0 lg:text-right lg:max-w-xs mt-2.5 lg:mt-0 space-y-1">
<span>{summary.location}</span> {bannerInfo && (
</div> <p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
)}
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
{summary?.organizer && (
<span className="flex items-center gap-1.5">
<Users className="w-3.5 h-3.5 flex-shrink-0" />
{t('organizer', { name: summary.organizer })}
</span>
)} )}
{summary && summary.attendeeCount > 0 && ( {actorMessage && (
<span>{t('attendees', { count: summary.attendeeCount })}</span> <p className="text-xs text-muted-foreground">{actorMessage}</p>
)}
{actorSummary?.participationComment && (
<p className="text-xs text-muted-foreground italic">
{t('actor_note', { comment: actorSummary.participationComment })}
</p>
)} )}
</div> </div>
</div> </div>
@@ -840,23 +858,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
</div> </div>
)} )}
{/* Info text */}
{bannerInfo && (
<p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
)}
{/* Actor message */}
{actorMessage && (
<p className="text-xs text-muted-foreground">{actorMessage}</p>
)}
{/* Actor comment */}
{actorSummary?.participationComment && (
<p className="text-xs text-muted-foreground italic">
{t('actor_note', { comment: actorSummary.participationComment })}
</p>
)}
{/* Proposed changes */} {/* Proposed changes */}
{proposedChanges.length > 0 && ( {proposedChanges.length > 0 && (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs"> <div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs">
+134 -28
View File
@@ -34,9 +34,8 @@ import { RichTextEditor } from "@/components/email/rich-text-editor";
/** Strip HTML tags and decode entities to get a plain-text version */ /** Strip HTML tags and decode entities to get a plain-text version */
function htmlToPlainText(html: string): string { function htmlToPlainText(html: string): string {
const tmp = document.createElement('div'); const doc = new DOMParser().parseFromString(html, 'text/html');
tmp.innerHTML = html; return doc.body.textContent || '';
return tmp.textContent || tmp.innerText || '';
} }
export interface ComposerDraftData { export interface ComposerDraftData {
@@ -104,6 +103,8 @@ export function EmailComposer({
const timeFormat = useSettingsStore((state) => state.timeFormat); const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextMode = useSettingsStore((state) => state.plainTextMode); const plainTextMode = useSettingsStore((state) => state.plainTextMode);
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity); const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
// Initialize with reply/forward data if provided // Initialize with reply/forward data if provided
const getInitialTo = () => { const getInitialTo = () => {
@@ -213,6 +214,8 @@ export function EmailComposer({
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null); const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
const [smimePassphraseInput, setSmimePassphraseInput] = useState(''); const [smimePassphraseInput, setSmimePassphraseInput] = useState('');
const [smimePassphraseError, setSmimePassphraseError] = useState(''); const [smimePassphraseError, setSmimePassphraseError] = useState('');
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
const saveTemplateModalRef = useFocusTrap({ const saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate, isActive: showSaveAsTemplate,
@@ -226,6 +229,12 @@ export function EmailComposer({
restoreFocus: true, restoreFocus: true,
}); });
const attachmentWarningRef = useFocusTrap({
isActive: showAttachmentWarning,
onEscape: () => setShowAttachmentWarning(false),
restoreFocus: true,
});
const { client } = useAuthStore(); const { client } = useAuthStore();
const identities = useIdentityStore((s) => s.identities); const identities = useIdentityStore((s) => s.identities);
const primaryIdentity = identities[0] ?? null; const primaryIdentity = identities[0] ?? null;
@@ -332,6 +341,17 @@ export function EmailComposer({
return () => window.removeEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, []); }, []);
// Auto-focus the To field when composing a new email or forwarding
useEffect(() => {
if (mode === 'forward' || mode === 'compose') {
// Small delay to ensure the input is rendered
const timer = setTimeout(() => {
toInputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}
}, [mode]);
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]); const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null); const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1); const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
@@ -339,10 +359,26 @@ export function EmailComposer({
const toInputRef = useRef<HTMLInputElement>(null); const toInputRef = useRef<HTMLInputElement>(null);
const ccInputRef = useRef<HTMLInputElement>(null); const ccInputRef = useRef<HTMLInputElement>(null);
const bccInputRef = useRef<HTMLInputElement>(null); const bccInputRef = useRef<HTMLInputElement>(null);
const subjectInputRef = useRef<HTMLInputElement>(null);
const bodyRef = useRef<HTMLTextAreaElement>(null);
const editorContainerRef = useRef<HTMLDivElement>(null);
const toDropdownRef = useRef<HTMLDivElement>(null); const toDropdownRef = useRef<HTMLDivElement>(null);
const ccDropdownRef = useRef<HTMLDivElement>(null); const ccDropdownRef = useRef<HTMLDivElement>(null);
const bccDropdownRef = useRef<HTMLDivElement>(null); const bccDropdownRef = useRef<HTMLDivElement>(null);
const focusSubject = useCallback(() => {
subjectInputRef.current?.focus();
}, []);
const focusBody = useCallback(() => {
if (plainTextMode) {
bodyRef.current?.focus();
} else {
const proseMirror = editorContainerRef.current?.querySelector('.ProseMirror') as HTMLElement | null;
proseMirror?.focus();
}
}, [plainTextMode]);
const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => { const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
if (autocompleteTimeoutRef.current) { if (autocompleteTimeoutRef.current) {
clearTimeout(autocompleteTimeoutRef.current); clearTimeout(autocompleteTimeoutRef.current);
@@ -506,17 +542,18 @@ export function EmailComposer({
} }
}, [client, t]); }, [client, t]);
const handleImageUpload = useCallback(async (file: File): Promise<string | null> => { const handleImageUpload = useCallback((file: File): Promise<string | null> => {
if (!client) return null; return new Promise((resolve) => {
try { const reader = new FileReader();
const { blobId } = await client.uploadBlob(file); reader.onload = (e) => resolve((e.target?.result as string) ?? null);
return await client.fetchBlobAsObjectUrl(blobId, file.name, file.type); reader.onerror = () => {
} catch (error) { debug.error(`Failed to read inline image ${file.name}`);
debug.error(`Failed to upload inline image ${file.name}:`, error); toast.error(t('upload_failed', { filename: file.name }));
toast.error(t('upload_failed', { filename: file.name })); resolve(null);
return null; };
} reader.readAsDataURL(file);
}, [client, t]); });
}, [t]);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files) return; if (!event.target.files) return;
@@ -696,7 +733,7 @@ export function EmailComposer({
return undefined; return undefined;
}; };
const handleSend = async () => { const handleSend = async (skipAttachmentCheck = false) => {
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean); const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean); const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
@@ -715,6 +752,21 @@ export function EmailComposer({
return; return;
} }
// Attachment reminder check
if (!skipAttachmentCheck && attachmentReminderEnabled) {
const hasAttachments = attachments.some(att => att.blobId && !att.uploading && !att.error);
if (!hasAttachments) {
const bodyText = htmlToPlainText(body);
const searchText = `${subject} ${bodyText}`.toLowerCase();
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
if (matched) {
setAttachmentWarningKeyword(matched);
setShowAttachmentWarning(true);
return;
}
}
}
let finalDraftId = draftId; let finalDraftId = draftId;
if (saveTimeoutRef.current) { if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current); clearTimeout(saveTimeoutRef.current);
@@ -975,7 +1027,7 @@ export function EmailComposer({
</div> </div>
{/* Mobile: send button in header */} {/* Mobile: send button in header */}
<Button <Button
onClick={handleSend} onClick={() => handleSend()}
disabled={!canSend} disabled={!canSend}
title={getSendTooltip()} title={getSendTooltip()}
size="sm" size="sm"
@@ -1071,6 +1123,7 @@ export function EmailComposer({
onInsertAutocomplete={insertAutocomplete} onInsertAutocomplete={insertAutocomplete}
validationError={validationErrors.to} validationError={validationErrors.to}
validationMessage={t('validation.recipient_required')} validationMessage={t('validation.recipient_required')}
onTab={focusSubject}
/> />
<div className="flex gap-0.5 shrink-0"> <div className="flex gap-0.5 shrink-0">
<Button <Button
@@ -1140,6 +1193,7 @@ export function EmailComposer({
<div className="flex items-center gap-2 px-4 py-2.5"> <div className="flex items-center gap-2 px-4 py-2.5">
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span> <span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
<Input <Input
ref={subjectInputRef}
type="text" type="text"
placeholder={t('subject_placeholder')} placeholder={t('subject_placeholder')}
value={subject} value={subject}
@@ -1147,6 +1201,12 @@ export function EmailComposer({
setSubject(e.target.value); setSubject(e.target.value);
if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false })); if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false }));
}} }}
onKeyDown={(e) => {
if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
focusBody();
}
}}
className={cn( className={cn(
"flex-1 border-0 focus-visible:ring-0 h-8 px-0 text-sm", "flex-1 border-0 focus-visible:ring-0 h-8 px-0 text-sm",
validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400" validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400"
@@ -1159,6 +1219,7 @@ export function EmailComposer({
{/* Body */} {/* Body */}
{plainTextMode ? ( {plainTextMode ? (
<textarea <textarea
ref={bodyRef}
value={body} value={body}
onChange={(e) => { onChange={(e) => {
setBody(e.target.value); setBody(e.target.value);
@@ -1173,16 +1234,18 @@ export function EmailComposer({
aria-invalid={validationErrors.body || undefined} aria-invalid={validationErrors.body || undefined}
/> />
) : ( ) : (
<RichTextEditor <div ref={editorContainerRef}>
content={body} <RichTextEditor
onChange={(html) => { content={body}
setBody(html); onChange={(html) => {
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false })); setBody(html);
}} if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
onImageUpload={handleImageUpload} }}
placeholder={t('body_placeholder')} onImageUpload={handleImageUpload}
hasError={validationErrors.body} placeholder={t('body_placeholder')}
/> hasError={validationErrors.body}
/>
</div>
)} )}
{plainTextMode ? ( {plainTextMode ? (
@@ -1329,7 +1392,7 @@ export function EmailComposer({
{t('discard')} {t('discard')}
</button> </button>
<Button <Button
onClick={handleSend} onClick={() => handleSend()}
disabled={!canSend} disabled={!canSend}
title={getSendTooltip()} title={getSendTooltip()}
className="hidden md:inline-flex" className="hidden md:inline-flex"
@@ -1429,6 +1492,36 @@ export function EmailComposer({
</div> </div>
)} )}
{showAttachmentWarning && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
onClick={() => setShowAttachmentWarning(false)}
>
<div
ref={attachmentWarningRef}
role="alertdialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
>
<div className="p-6">
<h2 className="text-lg font-semibold text-foreground">{t('forgot_attachment.title')}</h2>
<p className="mt-2 text-sm text-muted-foreground">
{t('forgot_attachment.message', { keyword: attachmentWarningKeyword })}
</p>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
{t('forgot_attachment.back')}
</Button>
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true); }}>
{t('forgot_attachment.send_anyway')}
</Button>
</div>
</div>
</div>
)}
{showCloseDialog && ( {showCloseDialog && (
<div <div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150" className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
@@ -1514,6 +1607,7 @@ function RecipientChipInput({
onInsertAutocomplete, onInsertAutocomplete,
validationError, validationError,
validationMessage, validationMessage,
onTab,
}: { }: {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
@@ -1530,6 +1624,7 @@ function RecipientChipInput({
onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void; onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void;
validationError?: boolean; validationError?: boolean;
validationMessage?: string; validationMessage?: string;
onTab?: () => void;
}) { }) {
const allParts = value.split(',').map(s => s.trim()).filter(Boolean); const allParts = value.split(',').map(s => s.trim()).filter(Boolean);
const hasTrailingComma = value.trimEnd().endsWith(','); const hasTrailingComma = value.trimEnd().endsWith(',');
@@ -1563,7 +1658,18 @@ function RecipientChipInput({
if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) { if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) {
if (e.key !== 'Tab') e.preventDefault(); if (e.key !== 'Tab') e.preventDefault();
commitCurrentInput(); commitCurrentInput();
setTimeout(() => inputRef.current?.focus(), 0); if (e.key === 'Tab' && onTab) {
e.preventDefault();
setTimeout(() => onTab(), 0);
} else {
setTimeout(() => inputRef.current?.focus(), 0);
}
return;
}
if (e.key === 'Tab' && !e.shiftKey && onTab) {
e.preventDefault();
onTab();
return; return;
} }
+30 -26
View File
@@ -89,17 +89,18 @@ const getMailboxIcon = (role?: string) => {
} }
}; };
// Get current label/color from email keywords (supports both $label: and legacy $color:) // Get all active label/color tag IDs from email keywords
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => { const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return null; if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) { for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
return key.startsWith("$label:") tags.push(
? key.slice("$label:".length) key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
: key.slice("$color:".length); );
} }
} }
return null; return tags;
}; };
export function EmailContextMenu({ export function EmailContextMenu({
@@ -137,7 +138,7 @@ export function EmailContextMenu({
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const isDraft = email.keywords?.['$draft'] === true; const isDraft = email.keywords?.['$draft'] === true;
const currentColor = getCurrentColor(email.keywords); const currentColors = getCurrentColors(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1; const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk'; const isInJunkFolder = currentMailboxRole === 'junk';
@@ -306,24 +307,27 @@ export function EmailContextMenu({
{/* Set tag submenu - only for single email */} {/* Set tag submenu - only for single email */}
{!showBatchActions && ( {!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}> <ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
{colorOptions.map((option) => ( {colorOptions.map((option) => {
<button const isActive = currentColors.includes(option.value);
key={option.value} return (
role="menuitem" <button
onClick={() => handleAction(() => onSetColorTag?.(option.value))} key={option.value}
className={cn( role="menuitem"
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer", onClick={() => handleAction(() => onSetColorTag?.(option.value))}
currentColor === option.value && "bg-accent font-medium" className={cn(
)} "w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
> isActive && "bg-accent font-medium"
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} /> )}
<span className="flex-1">{option.name}</span> >
{currentColor === option.value && ( <span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" /> <span className="flex-1">{option.name}</span>
)} {isActive && (
</button> <Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
))} )}
{currentColor && ( </button>
);
})}
{currentColors.length > 0 && (
<> <>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
+1 -1
View File
@@ -94,7 +94,7 @@ export function EmailHoverActions({
onToggleStar?.(); onToggleStar?.();
break; break;
case "markRead": case "markRead":
onMarkAsRead?.(!isUnread); onMarkAsRead?.(isUnread);
break; break;
case "archive": case "archive":
onArchive?.(); onArchive?.();
+20 -13
View File
@@ -15,7 +15,7 @@ import { useLongPress } from "@/hooks/use-long-press";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { EmailIdentityBadge } from "./email-identity-badge"; import { EmailIdentityBadge } from "./email-identity-badge";
import { EmailHoverActions } from "./email-hover-actions"; import { EmailHoverActions } from "./email-hover-actions";
import { getEmailColorTag } from "@/lib/thread-utils"; import { getEmailColorTags } from "@/lib/thread-utils";
interface EmailListItemProps { interface EmailListItemProps {
email: Email; email: Email;
@@ -32,7 +32,7 @@ interface EmailListItemProps {
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) { export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
const t = useTranslations('email_viewer'); const t = useTranslations('email_viewer');
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore(); const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -44,13 +44,18 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const isImportant = email.keywords?.["$important"]; const isImportant = email.keywords?.["$important"];
const isAnswered = email.keywords?.$answered; const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded; const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0]; // In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tag using keyword definitions from settings // Resolve color tags using keyword definitions from settings
const colorTagId = getEmailColorTag(email.keywords); const colorTagIds = getEmailColorTags(email.keywords);
const keywordDef = colorTagId ? emailKeywords.find(k => k.id === colorTagId) : null; const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
// Use first tag for background coloring
const keywordDef = keywordDefs[0] ?? null;
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
// Drag and drop functionality // Drag and drop functionality
@@ -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" />} {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( <span className={cn(
'text-xs tabular-nums', 'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground' isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
@@ -246,15 +253,15 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 flex-shrink-0"> <div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && ( {keywordDefs.map((kd) => (
<span className={cn( <span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full", "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")} /> <span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{keywordDef.label} {kd.label}
</span> </span>
)} ))}
<span className={cn( <span className={cn(
"text-xs tabular-nums", "text-xs tabular-nums",
isUnread isUnread
+157 -109
View File
@@ -8,7 +8,7 @@ import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/ema
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime } from "@/lib/utils"; import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
import { import {
Reply, Reply,
@@ -193,16 +193,17 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
return 'Attachment'; return 'Attachment';
}; };
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => { const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return null; if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) { for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
return key.startsWith("$label:") tags.push(
? key.slice("$label:".length) key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
: key.slice("$color:".length); );
} }
} }
return null; return tags;
}; };
// Helper function to format recipients with contextual display // Helper function to format recipients with contextual display
@@ -887,6 +888,9 @@ export function EmailViewer({
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition); const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
@@ -933,7 +937,8 @@ export function EmailViewer({
const moveMenuRef = useRef<HTMLDivElement>(null); const moveMenuRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null); const toolbarRef = useRef<HTMLDivElement>(null);
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set()); const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
const currentColor = getCurrentColor(email?.keywords); const currentColors = getCurrentColors(email?.keywords);
const currentColor = currentColors[0] ?? null;
// S/MIME state // S/MIME state
const [smimeStatus, setSmimeStatus] = useState<SmimeStatus | null>(null); const [smimeStatus, setSmimeStatus] = useState<SmimeStatus | null>(null);
@@ -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(() => { useEffect(() => {
// Mark as read when email is viewed, respecting the delay setting // Mark as read when email is viewed, respecting the delay setting
if (!email || email.keywords?.$seen || !onMarkAsRead) { if (!email || !onMarkAsRead) {
return;
}
// Already read — record that so manual unread toggle won't re-trigger auto-mark
if (email.keywords?.$seen) {
autoMarkedEmailRef.current = email.id;
return;
}
// Don't re-trigger if we already auto-marked this email (user may have toggled it back to unread)
if (autoMarkedEmailRef.current === email.id) {
return; return;
} }
@@ -1167,17 +1190,18 @@ export function EmailViewer({
// Instant mark // Instant mark
if (markAsReadDelay === 0) { if (markAsReadDelay === 0) {
autoMarkedEmailRef.current = email.id;
onMarkAsRead(email.id, true); onMarkAsRead(email.id, true);
return; return;
} }
// Delayed mark // Delayed mark
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
autoMarkedEmailRef.current = email.id;
onMarkAsRead(email.id, true); onMarkAsRead(email.id, true);
}, markAsReadDelay); }, markAsReadDelay);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
// eslint-disable-next-line react-hooks/exhaustive-deps -- email?.id changes when email changes, which is the intended trigger
}, [email?.id, email?.keywords?.$seen, onMarkAsRead]); }, [email?.id, email?.keywords?.$seen, onMarkAsRead]);
// Reset external content permission and quick reply when email changes // Reset external content permission and quick reply when email changes
@@ -1820,12 +1844,12 @@ export function EmailViewer({
const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type)); const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type));
if (!tnefAtt?.blobId) { if (!tnefAtt?.blobId) {
debug.log('TNEF: No winmail.dat attachment found in email', email?.id); debug.log('email', 'TNEF: No winmail.dat attachment found in email', email?.id);
return; return;
} }
debug.group('TNEF Processing'); debug.group('TNEF Processing', 'email');
debug.log('Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size); debug.log('email', 'Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
// Check if the email already has a usable HTML body with real content // Check if the email already has a usable HTML body with real content
// Outlook often forwards TNEF emails with an HTML body that's just Word // Outlook often forwards TNEF emails with an HTML body that's just Word
@@ -1835,46 +1859,46 @@ export function EmailViewer({
let hasRealHtmlBody = !!htmlValue; let hasRealHtmlBody = !!htmlValue;
if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) { if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) {
hasRealHtmlBody = false; hasRealHtmlBody = false;
debug.log('TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body'); debug.log('email', 'TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body');
} }
if (hasRealHtmlBody) { if (hasRealHtmlBody) {
debug.log('TNEF: Email has real HTML body, will extract attachments only'); debug.log('email', 'TNEF: Email has real HTML body, will extract attachments only');
} else { } else {
debug.log('TNEF: Email has no usable HTML body, proceeding with full TNEF extraction'); debug.log('email', 'TNEF: Email has no usable HTML body, proceeding with full TNEF extraction');
} }
let cancelled = false; let cancelled = false;
async function processTnef() { async function processTnef() {
try { try {
debug.time('TNEF fetch blob'); debug.time('TNEF fetch blob', 'email');
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!); const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
debug.timeEnd('TNEF fetch blob'); debug.timeEnd('TNEF fetch blob', 'email');
debug.log('TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes'); debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
if (cancelled) { if (cancelled) {
debug.log('TNEF: Processing cancelled after fetch'); debug.log('email', 'TNEF: Processing cancelled after fetch');
debug.groupEnd(); debug.groupEnd();
return; return;
} }
if (blobBytes.byteLength === 0) { if (blobBytes.byteLength === 0) {
debug.warn('TNEF: Fetched blob is empty (0 bytes)'); debug.warn('email', 'TNEF: Fetched blob is empty (0 bytes)');
debug.groupEnd(); debug.groupEnd();
return; return;
} }
const tnefData = new Uint8Array(blobBytes); const tnefData = new Uint8Array(blobBytes);
debug.time('TNEF parse'); debug.time('TNEF parse', 'email');
const parsed = parseTnef(tnefData); const parsed = parseTnef(tnefData);
debug.timeEnd('TNEF parse'); debug.timeEnd('TNEF parse', 'email');
if (cancelled) { if (cancelled) {
debug.log('TNEF: Processing cancelled after parse'); debug.log('email', 'TNEF: Processing cancelled after parse');
debug.groupEnd(); debug.groupEnd();
return; return;
} }
debug.log('TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length); debug.log('email', 'TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
if (parsed.htmlBody && !hasRealHtmlBody) { if (parsed.htmlBody && !hasRealHtmlBody) {
setTnefHtml(parsed.htmlBody); setTnefHtml(parsed.htmlBody);
@@ -1884,11 +1908,11 @@ export function EmailViewer({
} }
if (parsed.attachments.length > 0) { if (parsed.attachments.length > 0) {
setTnefAttachments(parsed.attachments); setTnefAttachments(parsed.attachments);
debug.log('TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', ')); debug.log('email', 'TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', '));
} }
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) { if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
debug.warn('TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format'); debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
} }
debug.groupEnd(); debug.groupEnd();
@@ -1926,13 +1950,13 @@ export function EmailViewer({
const hasRealText = !!textValue; const hasRealText = !!textValue;
if (hasRealHtml || hasRealText) { if (hasRealHtml || hasRealText) {
debug.log('Embedded RFC822: Outer email has real body content, not unwrapping'); debug.log('email', 'Embedded RFC822: Outer email has real body content, not unwrapping');
return; return;
} }
debug.group('Embedded RFC822 Unwrapping'); debug.group('Embedded RFC822 Unwrapping', 'email');
debug.log('Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size); debug.log('email', 'Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
debug.log('Outer email body is empty, will unwrap embedded email'); debug.log('email', 'Outer email body is empty, will unwrap embedded email');
let cancelled = false; let cancelled = false;
@@ -1941,7 +1965,7 @@ export function EmailViewer({
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!); const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
if (cancelled) { debug.groupEnd(); return; } if (cancelled) { debug.groupEnd(); return; }
if (blobBytes.byteLength === 0) { if (blobBytes.byteLength === 0) {
debug.warn('Embedded RFC822: Fetched blob is empty'); debug.warn('email', 'Embedded RFC822: Fetched blob is empty');
debug.groupEnd(); debug.groupEnd();
return; return;
} }
@@ -1951,7 +1975,7 @@ export function EmailViewer({
const parsed = await parser.parse(new Uint8Array(blobBytes)); const parsed = await parser.parse(new Uint8Array(blobBytes));
if (cancelled) { debug.groupEnd(); return; } if (cancelled) { debug.groupEnd(); return; }
debug.log('Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)', debug.log('email', 'Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)', ', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
', attachments:', parsed.attachments?.length ?? 0); ', attachments:', parsed.attachments?.length ?? 0);
@@ -1963,7 +1987,7 @@ export function EmailViewer({
} }
if (parsed.attachments && parsed.attachments.length > 0) { if (parsed.attachments && parsed.attachments.length > 0) {
setEmbeddedEmailAttachments(parsed.attachments as PostalMimeAttachment[]); setEmbeddedEmailAttachments(parsed.attachments as PostalMimeAttachment[]);
debug.log('Embedded RFC822 attachments:', parsed.attachments.map( debug.log('email', 'Embedded RFC822 attachments:', parsed.attachments.map(
a => (a.filename || 'unnamed') + ' (' + a.mimeType + ')' a => (a.filename || 'unnamed') + ' (' + a.mimeType + ')'
).join(', ')); ).join(', '));
} }
@@ -2290,9 +2314,11 @@ export function EmailViewer({
// Use shared sanitization config as base (more secure) // Use shared sanitization config as base (more secure)
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG }; const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
// Check if sender is trusted // Check if sender is trusted (localStorage list or address book)
const senderEmail = email.from?.[0]?.email?.toLowerCase(); const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false; const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
// Block external content based on policy: // Block external content based on policy:
// 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted // 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted
@@ -2399,7 +2425,7 @@ export function EmailViewer({
html: '<p style="color: var(--color-muted-foreground);">No content available</p>', html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
isHtml: false isHtml: false
}; };
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, cidBlobUrls]); }, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, isTrustedAddressBookSender, trustedSendersAddressBook, cidBlobUrls]);
// Override email content with S/MIME decrypted content when available // Override email content with S/MIME decrypted content when available
const effectiveEmailContent = useMemo(() => { const effectiveEmailContent = useMemo(() => {
@@ -3036,43 +3062,51 @@ export function EmailViewer({
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }} onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
className={cn( className={cn(
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2", "h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
currentColor && "bg-muted/50" currentColors.length > 0 && "bg-muted/50"
)} )}
title={t('set_color')} title={t('set_color')}
> >
{(() => { {currentColors.length > 0 ? (
const kw = currentColor ? emailKeywords.find(k => k.id === currentColor) : null; <>
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; <span className="flex items-center gap-0.5">
return dotClass ? ( {currentColors.slice(0, 3).map((tagId) => {
<> const kw = emailKeywords.find(k => k.id === tagId);
<span className={cn("w-3 h-3 rounded-full", dotClass)} /> return kw ? <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot)} /> : null;
{showToolbarLabels && <span className="text-xs font-medium text-foreground">{kw!.label}</span>} })}
</> </span>
) : ( {showToolbarLabels && currentColors.length === 1 && (
<> <span className="text-xs font-medium text-foreground">
<Tag className="w-4 h-4 text-muted-foreground" /> {emailKeywords.find(k => k.id === currentColors[0])?.label}
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>} </span>
</> )}
); </>
})()} ) : (
<>
<Tag className="w-4 h-4 text-muted-foreground" />
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
</>
)}
</button> </button>
{tagMenuOpen && ( {tagMenuOpen && (
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10"> <div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
{colorOptions.map((option) => ( {colorOptions.map((option) => {
<button const isActive = currentColors.includes(option.value);
key={option.value} return (
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }} <button
className={cn( key={option.value}
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2", onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
currentColor === option.value && "bg-accent font-medium" className={cn(
)} "w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
> isActive && "bg-accent font-medium"
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} /> )}
<span className="truncate">{option.name}</span> >
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />} <span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
</button> <span className="truncate">{option.name}</span>
))} {isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
{currentColor && ( </button>
);
})}
{currentColors.length > 0 && (
<> <>
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
<button <button
@@ -3280,21 +3314,24 @@ export function EmailViewer({
</button> </button>
{moreMenuSub === 'tag' && ( {moreMenuSub === 'tag' && (
<div className="absolute right-full top-0 mr-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10"> <div className="absolute right-full top-0 mr-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
{colorOptions.map((option) => ( {colorOptions.map((option) => {
<button const isActive = currentColors.includes(option.value);
key={option.value} return (
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }} <button
className={cn( key={option.value}
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2", onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
currentColor === option.value && "bg-accent font-medium" className={cn(
)} "w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
> isActive && "bg-accent font-medium"
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} /> )}
<span className="truncate">{option.name}</span> >
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />} <span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
</button> <span className="truncate">{option.name}</span>
))} {isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
{currentColor && ( </button>
);
})}
{currentColors.length > 0 && (
<> <>
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
<button <button
@@ -3470,21 +3507,24 @@ export function EmailViewer({
<> <>
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div> <div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
{colorOptions.map((option) => ( {colorOptions.map((option) => {
<button const isActive = currentColors.includes(option.value);
key={option.value} return (
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }} <button
className={cn( key={option.value}
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3", onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
currentColor === option.value && "bg-accent font-medium" className={cn(
)} "w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
> isActive && "bg-accent font-medium"
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} /> )}
<span className="truncate">{option.name}</span> >
{currentColor === option.value && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />} <span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
</button> <span className="truncate">{option.name}</span>
))} {isActive && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
{currentColor && ( </button>
);
})}
{currentColors.length > 0 && (
<button <button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }} onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground" className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
@@ -3630,14 +3670,18 @@ export function EmailViewer({
)} /> )} />
</button> </button>
)} )}
{/* Color tag dot */} {/* Color tag dots */}
{currentColor && (() => { {currentColors.length > 0 && (
const kw = emailKeywords.find(k => k.id === currentColor); <span className="flex items-center gap-0.5">
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; {currentColors.map((tagId) => {
return dotClass ? ( const kw = emailKeywords.find(k => k.id === tagId);
<span className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} /> const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
) : null; return dotClass ? (
})()} <span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
) : null;
})}
</span>
)}
{isImportant && ( {isImportant && (
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center"> <span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
{t('important')} {t('important')}
@@ -4507,7 +4551,7 @@ export function EmailViewer({
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
hasCalendarInvitation) && ( hasCalendarInvitation) && (
<div className="border-b border-border bg-muted/30 isolate"> <div className="border-b border-border bg-muted/30 isolate">
<div className="max-w-4xl mx-auto px-6 py-1.5"> <div className="max-w-6xl mx-auto px-6 py-1.5">
<div className="flex flex-col gap-3 isolate"> <div className="flex flex-col gap-3 isolate">
{/* External Content Controls */} {/* External Content Controls */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && ( {hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
@@ -4526,7 +4570,11 @@ export function EmailViewer({
onClick={() => { onClick={() => {
const senderEmail = email.from?.[0]?.email; const senderEmail = email.from?.[0]?.email;
if (senderEmail) { if (senderEmail) {
addTrustedSender(senderEmail); if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
setAllowExternalContent(true); setAllowExternalContent(true);
} }
}} }}
@@ -4874,7 +4922,7 @@ export function EmailViewer({
if (client && supportsSync) { if (client && supportsSync) {
createContact(client, contactData).then(() => toast.success('Contact added')); createContact(client, contactData).then(() => toast.success('Contact added'));
} else { } else {
addLocalContact({ id: `local-${crypto.randomUUID()}`, addressBookIds: {}, ...contactData } as ContactCard); addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
toast.success('Contact added'); toast.success('Contact added');
} }
}} }}
+12
View File
@@ -93,6 +93,18 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
return () => document.removeEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler);
}, [isOpen]); }, [isOpen]);
// Close on scroll so the popover does not float while content moves.
useEffect(() => {
if (!isOpen) return;
const handler = () => handleClose();
document.addEventListener("scroll", handler, true);
window.addEventListener("resize", handler);
return () => {
document.removeEventListener("scroll", handler, true);
window.removeEventListener("resize", handler);
};
}, [isOpen]);
const handleViewContact = () => { const handleViewContact = () => {
if (onViewContact) { if (onViewContact) {
onViewContact(contact ?? null, email); onViewContact(contact ?? null, email);
+1
View File
@@ -118,6 +118,7 @@ export function RichTextEditor({
); );
if (imageFiles.length === 0) return false; if (imageFiles.length === 0) return false;
event.preventDefault(); event.preventDefault();
event.stopPropagation();
for (const file of imageFiles) { for (const file of imageFiles) {
upload(file).then((url) => { upload(file).then((url) => {
if (url) { if (url) {
+7 -1
View File
@@ -55,7 +55,13 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
// Signature status // Signature status
if (status.isSigned) { if (status.isSigned) {
if (status.signatureValid === true) { if (status.signatureValid === true) {
if (status.signerEmailMatch === false) { if (status.selfSigned) {
items.push({
icon: <AlertTriangle className="w-4 h-4" />,
text: t('status_signed_self_signed'),
variant: 'warning',
});
} else if (status.signerEmailMatch === false) {
items.push({ items.push({
icon: <AlertTriangle className="w-4 h-4" />, icon: <AlertTriangle className="w-4 h-4" />,
text: t('status_signed_mismatch'), text: t('status_signed_mismatch'),
+13 -2
View File
@@ -31,6 +31,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { isFilePreviewable } from "@/lib/file-preview"; import { isFilePreviewable } from "@/lib/file-preview";
@@ -84,6 +85,10 @@ export function ThreadConversationView({
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const { client } = useAuthStore();
// Track which emails are expanded (most recent by default) // Track which emails are expanded (most recent by default)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()); const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
@@ -164,7 +169,9 @@ export function ThreadConversationView({
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}> <div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
{emails.map((email, index) => { {emails.map((email, index) => {
const senderEmail = email.from?.[0]?.email?.toLowerCase(); const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false; const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
return ( return (
<EmailCard <EmailCard
key={email.id} key={email.id}
@@ -175,7 +182,11 @@ export function ThreadConversationView({
onToggleExpanded={() => toggleExpanded(email.id)} onToggleExpanded={() => toggleExpanded(email.id)}
onAllowExternal={() => toggleAllowExternal(email.id)} onAllowExternal={() => toggleAllowExternal(email.id)}
onTrustSender={senderEmail ? () => { onTrustSender={senderEmail ? () => {
addTrustedSender(senderEmail); if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
toggleAllowExternal(email.id); toggleAllowExternal(email.id);
} : undefined} } : undefined}
onReply={onReply ? () => onReply(email) : undefined} onReply={onReply ? () => onReply(email) : undefined}
+36 -19
View File
@@ -9,7 +9,7 @@ import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSqu
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils"; import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
import { useEmailDrag } from "@/hooks/use-email-drag"; import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press"; import { useLongPress } from "@/hooks/use-long-press";
import { ThreadEmailItem } from "./thread-email-item"; import { ThreadEmailItem } from "./thread-email-item";
@@ -55,8 +55,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered; const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded; const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0]; const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); // In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -64,9 +67,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color and keyword definition from keyword definitions if not passed directly // Resolve color tags using keyword definitions
const tagId = getEmailColorTag(email.keywords); const tagIds = getEmailColorTags(email.keywords);
const resolvedKeywordDef = tagId ? emailKeywords.find(k => k.id === tagId) : null; const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
const resolvedColorTag = (() => { const resolvedColorTag = (() => {
if (colorTag) return colorTag; if (colorTag) return colorTag;
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null; return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
@@ -209,7 +213,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</> </>
)} )}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />} {email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{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( <span className={cn(
'text-xs tabular-nums', 'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground' isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
@@ -252,15 +258,15 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 flex-shrink-0"> <div className="flex items-center gap-1.5 flex-shrink-0">
{resolvedKeywordDef && ( {resolvedKeywordDefs.map((kd) => (
<span className={cn( <span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full", "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")} /> <span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{resolvedKeywordDef.label} {kd.label}
</span> </span>
)} ))}
<span className={cn( <span className={cn(
"text-xs tabular-nums", "text-xs tabular-nums",
isUnread isUnread
@@ -339,7 +345,16 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : ''; const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const displayNames = showRecipient
? Array.from(new Set(
thread.emails.flatMap(e => (e.to ?? []).map(r => r.name || r.email.split('@')[0]))
)).slice(0, 4)
: participantNames;
const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0];
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({ const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
email: latestEmail, email: latestEmail,
@@ -522,8 +537,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{!isFocusedMailLayout && density !== 'extra-compact' && ( {!isFocusedMailLayout && density !== 'extra-compact' && (
<Avatar <Avatar
name={latestEmail.from?.[0]?.name} name={avatarPerson?.name}
email={latestEmail.from?.[0]?.email} email={avatarPerson?.email}
size="md" size="md"
className="flex-shrink-0 shadow-sm" className="flex-shrink-0 shadow-sm"
/> />
@@ -537,7 +552,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
'w-32 shrink-0 truncate text-sm lg:w-44', 'w-32 shrink-0 truncate text-sm lg:w-44',
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80' hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
)}> )}>
{participantNames.join(', ')} {displayNames.join(', ')}
</span> </span>
<span <span
className={cn( className={cn(
@@ -572,7 +587,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</> </>
)} )}
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />} {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( <span className={cn(
'text-xs tabular-nums', 'text-xs tabular-nums',
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground' hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
@@ -591,7 +608,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
? "font-bold text-foreground" ? "font-bold text-foreground"
: "font-medium text-muted-foreground" : "font-medium text-muted-foreground"
)}> )}>
{participantNames.join(", ")} {displayNames.join(", ")}
</span> </span>
<span <span
className={cn( className={cn(
+1 -1
View File
@@ -837,7 +837,7 @@ export function FileBrowser({
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="flex flex-col h-full" className="flex flex-col flex-1 min-h-0"
onClick={handleContainerClick} onClick={handleContainerClick}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
+1
View File
@@ -223,6 +223,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
{!loading && !error && fileType === "pdf" && objectUrl && ( {!loading && !error && fileType === "pdf" && objectUrl && (
<iframe <iframe
src={objectUrl} src={objectUrl}
sandbox="allow-same-origin"
className="w-full max-w-5xl h-full rounded-lg bg-white" className="w-full max-w-5xl h-full rounded-lg bg-white"
title={name} title={name}
/> />
+2 -2
View File
@@ -16,7 +16,7 @@ import type {
FilterActionType, FilterActionType,
} from "@/lib/jmap/sieve-types"; } from "@/lib/jmap/sieve-types";
import type { Mailbox } from "@/lib/jmap/types"; import type { Mailbox } from "@/lib/jmap/types";
import { buildMailboxTree, flattenMailboxTree, type MailboxNode } from "@/lib/utils"; import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
interface FilterRuleModalProps { interface FilterRuleModalProps {
rule?: FilterRule; rule?: FilterRule;
@@ -109,7 +109,7 @@ export function FilterRuleModal({
} }
onSave({ onSave({
id: rule?.id || crypto.randomUUID(), id: rule?.id || generateUUID(),
name: trimmedName, name: trimmedName,
enabled: rule?.enabled ?? true, enabled: rule?.enabled ?? true,
matchType, matchType,
@@ -14,6 +14,10 @@ function useSyncIdentities() {
const syncIdentities = useAuthStore((state) => state.syncIdentities); const syncIdentities = useAuthStore((state) => state.syncIdentities);
return syncIdentities; return syncIdentities;
} }
function useRefreshIdentities() {
return useAuthStore((state) => state.refreshIdentities);
}
import type { Identity, EmailAddress } from '@/lib/jmap/types'; import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { toast } from '@/stores/toast-store'; import { toast } from '@/stores/toast-store';
import { useFocusTrap } from '@/hooks/use-focus-trap'; import { useFocusTrap } from '@/hooks/use-focus-trap';
@@ -49,6 +53,15 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary); const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary);
const syncIdentities = useSyncIdentities(); const syncIdentities = useSyncIdentities();
const refreshIdentitiesFromServer = useRefreshIdentities();
// Refresh identities from server whenever the modal is opened
useEffect(() => {
if (isOpen) {
refreshIdentitiesFromServer();
}
}, [isOpen, refreshIdentitiesFromServer]);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
+5 -8
View File
@@ -17,6 +17,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store"; import { usePolicyStore } from "@/stores/policy-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getInitials } from "@/lib/account-utils"; import { getInitials } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginSlot } from "@/components/plugins/plugin-slot";
@@ -169,6 +170,7 @@ export function NavigationRail({
const sidebarApps = useSettingsStore((s) => s.sidebarApps); const sidebarApps = useSettingsStore((s) => s.sidebarApps);
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList); const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled')); const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : []; const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0; const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
@@ -218,13 +220,8 @@ export function NavigationRail({
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const { client } = useAuthStore.getState(); const headers = getActiveAccountSlotHeaders();
if (!client) return; if (!headers['X-JMAP-Cookie-Slot']) return;
const headers: Record<string, string> = {
'Authorization': client.getAuthHeader(),
'X-JMAP-Server-URL': client.getServerUrl(),
'X-JMAP-Username': client.getUsername(),
};
fetch('/api/admin/stalwart-check', { headers }) fetch('/api/admin/stalwart-check', { headers })
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
@@ -246,7 +243,7 @@ export function NavigationRail({
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread }, { id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar }, { id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" }, { id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false }, { id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false || !filesEnabled },
]; ];
const isSettingsActive = !activeAppId && pathname.startsWith("/settings"); const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
+2 -2
View File
@@ -4,7 +4,7 @@ import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight } from 'lucide-react'; import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight } from 'lucide-react';
import { icons as lucideIcons, type LucideIcon } from 'lucide-react'; import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn, generateUUID } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { IconPicker } from './icon-picker'; import { IconPicker } from './icon-picker';
@@ -207,7 +207,7 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
}); });
const handleCreate = useCallback((data: SidebarAppFormData) => { const handleCreate = useCallback((data: SidebarAppFormData) => {
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const id = `app-${generateUUID()}`;
addSidebarApp({ id, ...data }); addSidebarApp({ id, ...data });
setIsCreating(false); setIsCreating(false);
}, [addSidebarApp]); }, [addSidebarApp]);
-5
View File
@@ -18,7 +18,6 @@ import {
ChevronDown, ChevronDown,
Folder, Folder,
FolderOpen, FolderOpen,
Users,
User, User,
Palmtree, Palmtree,
Settings, Settings,
@@ -59,10 +58,6 @@ interface SidebarProps {
const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean, isShared?: boolean, id?: string) => { const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean, isShared?: boolean, id?: string) => {
const lowerName = name?.toLowerCase() || ""; const lowerName = name?.toLowerCase() || "";
if (id === 'shared-folders-root') {
return isExpanded ? FolderOpen : Users;
}
if (id?.startsWith('shared-account-')) { if (id?.startsWith('shared-account-')) {
return isExpanded ? FolderOpen : User; return isExpanded ? FolderOpen : User;
} }
+8
View File
@@ -6,24 +6,32 @@ import { useLocaleStore } from '@/stores/locale-store';
import enMessages from '@/locales/en/common.json'; import enMessages from '@/locales/en/common.json';
import frMessages from '@/locales/fr/common.json'; import frMessages from '@/locales/fr/common.json';
import jaMessages from '@/locales/ja/common.json'; import jaMessages from '@/locales/ja/common.json';
import koMessages from '@/locales/ko/common.json';
import esMessages from '@/locales/es/common.json'; import esMessages from '@/locales/es/common.json';
import itMessages from '@/locales/it/common.json'; import itMessages from '@/locales/it/common.json';
import deMessages from '@/locales/de/common.json'; import deMessages from '@/locales/de/common.json';
import lvMessages from '@/locales/lv/common.json';
import nlMessages from '@/locales/nl/common.json'; import nlMessages from '@/locales/nl/common.json';
import plMessages from '@/locales/pl/common.json';
import ptMessages from '@/locales/pt/common.json'; import ptMessages from '@/locales/pt/common.json';
import ruMessages from '@/locales/ru/common.json'; import ruMessages from '@/locales/ru/common.json';
import zhMessages from '@/locales/zh/common.json';
// Pre-loaded translations (loaded at build time, not runtime) // Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = { const ALL_MESSAGES = {
en: enMessages, en: enMessages,
fr: frMessages, fr: frMessages,
ja: jaMessages, ja: jaMessages,
ko: koMessages,
es: esMessages, es: esMessages,
it: itMessages, it: itMessages,
de: deMessages, de: deMessages,
lv: lvMessages,
nl: nlMessages, nl: nlMessages,
pl: plMessages,
pt: ptMessages, pt: ptMessages,
ru: ruMessages, ru: ruMessages,
zh: zhMessages,
}; };
interface IntlProviderProps { interface IntlProviderProps {
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { useEffect, useState } from "react";
import { X, Download } from "lucide-react";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
const DISMISSED_KEY = "pwa-install-dismissed";
export function PWAInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
if (localStorage.getItem(DISMISSED_KEY)) return;
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setShowPrompt(true);
};
window.addEventListener("beforeinstallprompt", handler);
return () => {
window.removeEventListener("beforeinstallprompt", handler);
};
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") {
setDeferredPrompt(null);
setShowPrompt(false);
}
};
const handleDismiss = () => {
setShowPrompt(false);
};
const handleDismissForever = () => {
localStorage.setItem(DISMISSED_KEY, "1");
setShowPrompt(false);
};
if (!showPrompt || !deferredPrompt) {
return null;
}
return (
<div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-start gap-3">
<Download className="w-5 h-5 mt-0.5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div>
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
Install Bulwark
</h3>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
Install our app for quick access and offline support.
</p>
</div>
</div>
<button
onClick={handleDismiss}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
aria-label="Dismiss install prompt"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<button
onClick={handleDismiss}
className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
>
Not now
</button>
<button
onClick={handleInstall}
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
>
Install
</button>
</div>
<button
onClick={handleDismissForever}
className="w-full text-xs text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors text-center"
>
Don&apos;t remind me again
</button>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
"use client";
import { useEffect } from "react";
export function ServiceWorkerRegistration() {
useEffect(() => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
return;
}
if (process.env.NODE_ENV !== "production") {
// In development, unregister any previously installed service worker
// and clear its caches so hot reload always serves fresh assets.
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((registration) => registration.unregister());
});
if ("caches" in window) {
caches.keys().then((keys) => {
keys.forEach((key) => caches.delete(key));
});
}
return;
}
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log("Service Worker registered successfully:", registration);
})
.catch((error) => {
console.error("Service Worker registration failed:", error);
});
}, []);
return null;
}
@@ -157,6 +157,7 @@ function DisplayNameSection() {
<Input <Input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder={displayName || t('display_name.placeholder')}
className="w-48" className="w-48"
/> />
<Button <Button
@@ -523,7 +524,7 @@ export function AccountSecuritySettings() {
if (isStalwart === false) { if (isStalwart === false) {
return ( return (
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
<p className="text-sm text-muted-foreground py-4">{t('not_available')}</p> <div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: t('not_available') }} />
</SettingsSection> </SettingsSection>
); );
} }
@@ -0,0 +1,247 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Book, Pencil, Share2, Tag } from "lucide-react";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
import { SettingsSection } from "./settings-section";
import { cn } from "@/lib/utils";
import type { AddressBook } from "@/lib/jmap/types";
function AddressBookEditRow({
initial,
onSave,
onCancel,
isLoading,
}: {
initial: string;
onSave: (name: string) => void;
onCancel: () => void;
isLoading: boolean;
}) {
const t = useTranslations("contacts.address_books");
const tCal = useTranslations("calendar.management");
const [name, setName] = useState(initial);
const isValid = name.trim().length > 0;
return (
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name_label")}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && isValid) onSave(name.trim());
if (e.key === "Escape") onCancel();
}}
className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
disabled={isLoading}
/>
</div>
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => isValid && onSave(name.trim())}
disabled={isLoading || !isValid}
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{tCal("save")}
</button>
<button
onClick={onCancel}
disabled={isLoading}
className="px-3 py-1.5 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{tCal("cancel")}
</button>
</div>
</div>
);
}
export function AddressBookManagementSettings() {
const t = useTranslations("contacts.address_books");
const tContacts = useTranslations("contacts");
const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore();
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, renameKeyword } = useContactStore();
const [editingId, setEditingId] = useState<string | null>(null);
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (client && addressBooks.length === 0) {
fetchAddressBooks(client);
}
}, [client, addressBooks.length, fetchAddressBooks]);
const handleUpdate = async (book: AddressBook, newName: string) => {
if (!client) return;
setIsLoading(true);
try {
await renameAddressBook(client, book, newName);
setEditingId(null);
toast.success(t("renamed"));
} catch {
toast.error(t("rename_failed"));
} finally {
setIsLoading(false);
}
};
// Group: personal first, then by shared account
const personal = addressBooks.filter((b) => !b.isShared);
const sharedGroups = new Map<string, { accountName: string; books: AddressBook[] }>();
for (const book of addressBooks) {
if (!book.isShared || !book.accountId) continue;
const key = book.accountId;
const existing = sharedGroups.get(key);
if (existing) existing.books.push(book);
else sharedGroups.set(key, { accountName: book.accountName || book.accountId, books: [book] });
}
const renderBook = (book: AddressBook) => {
if (editingId === book.id) {
return (
<AddressBookEditRow
key={book.id}
initial={book.name}
onSave={(name) => handleUpdate(book, name)}
onCancel={() => setEditingId(null)}
isLoading={isLoading}
/>
);
}
const canRename = !book.isShared || book.myRights?.mayWrite !== false;
return (
<div
key={book.id}
className={cn(
"flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
)}
>
<Book className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{book.name}</span>
</div>
{book.isDefault && (
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{t("default")}
</span>
)}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{canRename && (
<button
type="button"
onClick={() => setEditingId(book.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t("rename")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
};
// Collect keywords with counts
const keywordCounts: Record<string, number> = {};
for (const c of contacts) {
if (c.kind === "group" || !c.keywords) continue;
for (const [kw, active] of Object.entries(c.keywords)) {
if (active) keywordCounts[kw] = (keywordCounts[kw] || 0) + 1;
}
}
const sortedKeywords = Object.entries(keywordCounts).sort(([a], [b]) => a.localeCompare(b));
const handleRenameKeyword = async (oldKw: string, newKw: string) => {
setIsLoading(true);
try {
await renameKeyword(supportsSync && client ? client : null, oldKw, newKw);
setEditingKeyword(null);
toast.success(tContacts("category_renamed"));
} catch {
toast.error(tContacts("category_rename_failed"));
} finally {
setIsLoading(false);
}
};
return (
<>
<SettingsSection title={tSettings("manage_title")} description={tSettings("manage_description")}>
<div className="space-y-2">
{personal.map(renderBook)}
{Array.from(sharedGroups.entries()).map(([accountId, group]) => (
<div key={accountId} className="mt-4 space-y-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
{t("shared_prefix", { name: group.accountName })}
</h4>
{group.books.map(renderBook)}
</div>
))}
{addressBooks.length === 0 && (
<p className="text-sm text-muted-foreground py-2">{tSettings("no_address_books")}</p>
)}
</div>
</SettingsSection>
<div className="mt-8">
<SettingsSection title={tSettings("categories_title")} description={tSettings("categories_description")}>
<div className="space-y-2">
{sortedKeywords.map(([keyword, count]) => {
if (editingKeyword === keyword) {
return (
<AddressBookEditRow
key={keyword}
initial={keyword}
onSave={(name) => handleRenameKeyword(keyword, name)}
onCancel={() => setEditingKeyword(null)}
isLoading={isLoading}
/>
);
}
return (
<div
key={keyword}
className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
>
<Tag className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{keyword}</span>
</div>
<span className="text-xs text-muted-foreground tabular-nums">{count}</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => setEditingKeyword(keyword)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={tContacts("rename_category")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
{sortedKeywords.length === 0 && (
<p className="text-sm text-muted-foreground py-2">{tSettings("no_categories")}</p>
)}
</div>
</SettingsSection>
</div>
</>
);
}
+84 -1
View File
@@ -7,16 +7,36 @@ import { useConfig } from '@/hooks/use-config';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
import { ExternalLink } from 'lucide-react';
import { SpamSiegeGame } from './spam-siege-game';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
export function AdvancedSettings() { export function AdvancedSettings() {
const t = useTranslations('settings.advanced'); const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const { debugMode, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } = const { debugMode, debugCategories, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
useSettingsStore(); useSettingsStore();
const { settingsSyncEnabled } = useConfig(); const { settingsSyncEnabled } = useConfig();
const [showResetConfirm, setShowResetConfirm] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore(); const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const [showGame, setShowGame] = useState(false);
const logoClickCount = useRef(0);
const logoClickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleLogoClick = () => {
logoClickCount.current++;
if (logoClickTimer.current) clearTimeout(logoClickTimer.current);
if (logoClickCount.current >= 3) {
logoClickCount.current = 0;
setShowGame(true);
} else {
logoClickTimer.current = setTimeout(() => { logoClickCount.current = 0; }, 2000);
}
};
const handleExport = () => { const handleExport = () => {
const settingsJson = exportSettings(); const settingsJson = exportSettings();
@@ -64,6 +84,44 @@ export function AdvancedSettings() {
}; };
return ( return (
<>
{showGame && <SpamSiegeGame onClose={() => setShowGame(false)} />}
{/* About */}
<div className="rounded-lg border border-border bg-card p-5 mb-6">
<div className="flex items-center gap-4">
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About">
<div className="shrink-0">
<img
src="/branding/Bulwark_Logo_Color.svg"
alt="Bulwark"
className="w-12 h-12 object-contain dark:hidden group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
/>
<img
src="/branding/Bulwark_Logo_White.svg"
alt="Bulwark"
className="w-12 h-12 object-contain hidden dark:block group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">
{t('about.title')}
</p>
<p className="text-xs text-muted-foreground group-hover/about:translate-x-0.5 group-active/about:translate-y-px transition-transform">
v{APP_VERSION} <span className="text-muted-foreground/60">({GIT_COMMIT})</span>
</p>
</div>
</button>
<a
href="https://github.com/bulwarkmail/webmail"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
GitHub <ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
{/* Debug Mode */} {/* Debug Mode */}
{!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && ( {!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
@@ -72,6 +130,30 @@ export function AdvancedSettings() {
</SettingItem> </SettingItem>
)} )}
{/* Debug Categories */}
{debugMode && !isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
<div className="ml-4 border-l-2 border-muted pl-4 space-y-1">
<p className="text-xs text-muted-foreground mb-2">{t('debug_categories.description')}</p>
{ALL_DEBUG_CATEGORIES.map((cat) => (
<SettingItem
key={cat.id}
label={t(`debug_categories.${cat.labelKey}`)}
description={t(`debug_categories.${cat.labelKey}_description`)}
>
<ToggleSwitch
checked={debugCategories?.[cat.id] !== false}
onChange={(checked) => {
updateSetting('debugCategories', {
...debugCategories,
[cat.id]: checked,
});
}}
/>
</SettingItem>
))}
</div>
)}
{/* Settings Sync */} {/* Settings Sync */}
{settingsSyncEnabled && ( {settingsSyncEnabled && (
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}> <SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
@@ -122,5 +204,6 @@ export function AdvancedSettings() {
</Button> </Button>
</SettingItem> </SettingItem>
</SettingsSection> </SettingsSection>
</>
); );
} }
@@ -4,6 +4,7 @@ import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useCalendarStore } from '@/stores/calendar-store'; import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { toast } from '@/stores/toast-store'; import { toast } from '@/stores/toast-store';
import { SettingsSection } from './settings-section'; import { SettingsSection } from './settings-section';
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react'; import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
@@ -203,7 +204,10 @@ export function CalendarManagementSettings() {
fetch('/api/caldav/discover', { fetch('/api/caldav/discover', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
...getActiveAccountSlotHeaders(),
},
body: JSON.stringify({ body: JSON.stringify({
accounts: Array.from(accounts.entries()).map(([key, candidates]) => ({ key, candidates })), accounts: Array.from(accounts.entries()).map(([key, candidates]) => ({ key, candidates })),
}), }),
+11
View File
@@ -19,6 +19,7 @@ export function CalendarSettings() {
showWeekNumbers, showWeekNumbers,
enableCalendarTasks, enableCalendarTasks,
showTasksOnCalendar, showTasksOnCalendar,
showBirthdayCalendar,
calendarHoverPreview, calendarHoverPreview,
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
@@ -98,6 +99,16 @@ export function CalendarSettings() {
/> />
</SettingItem> </SettingItem>
<SettingItem
label={t('show_birthday_calendar')}
description={t('show_birthday_calendar_desc')}
>
<ToggleSwitch
checked={showBirthdayCalendar}
onChange={(checked) => updateSetting('showBirthdayCalendar', checked)}
/>
</SettingItem>
{isFeatureEnabled('calendarTasksEnabled') && ( {isFeatureEnabled('calendarTasksEnabled') && (
<> <>
<SettingItem <SettingItem
+74 -2
View File
@@ -11,8 +11,9 @@ import { useEmailStore } from '@/stores/email-store';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal'; import { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react'; import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail, X } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
import { useContactStore } from '@/stores/contact-store';
const MAIL_LAYOUT_PREVIEW_ROWS = [ const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false }, { 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 { const {
markAsReadDelay, markAsReadDelay,
deleteAction, deleteAction,
@@ -129,12 +132,16 @@ export function EmailSettings() {
hoverActionsMode, hoverActionsMode,
hoverActionsCorner, hoverActionsCorner,
trustedSenders, trustedSenders,
trustedSendersAddressBook,
attachmentReminderEnabled,
attachmentReminderKeywords,
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
const { trustedSenderEmails } = useContactStore();
// Get count label for trusted senders button // Get count label for trusted senders button
const getTrustedSendersCount = () => { const getTrustedSendersCount = () => {
const count = trustedSenders.length; const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length;
if (count === 0) return t('trusted_senders.count_zero'); if (count === 0) return t('trusted_senders.count_zero');
if (count === 1) return t('trusted_senders.count_one'); if (count === 1) return t('trusted_senders.count_one');
return t('trusted_senders.count_other', { count }); return t('trusted_senders.count_other', { count });
@@ -337,6 +344,63 @@ export function EmailSettings() {
/> />
</SettingItem> </SettingItem>
{/* Attachment Reminder */}
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
<ToggleSwitch
checked={attachmentReminderEnabled}
onChange={(checked) => updateSetting('attachmentReminderEnabled', checked)}
/>
</SettingItem>
{attachmentReminderEnabled && (
<div className="py-3 border-b border-border space-y-2">
<div>
<label className="text-sm font-medium text-foreground">{t('attachment_reminder.keywords_label')}</label>
<p className="text-xs text-muted-foreground mt-1">{t('attachment_reminder.keywords_description')}</p>
</div>
<div className="flex flex-wrap gap-1.5">
{attachmentReminderKeywords.map((kw) => (
<span key={kw} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-foreground">
{kw}
<button
type="button"
aria-label={t('attachment_reminder.remove')}
onClick={() => updateSetting('attachmentReminderKeywords', attachmentReminderKeywords.filter(k => k !== kw))}
className="text-muted-foreground hover:text-foreground"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
const trimmed = newKeyword.trim().toLowerCase();
if (trimmed && !attachmentReminderKeywords.includes(trimmed)) {
updateSetting('attachmentReminderKeywords', [...attachmentReminderKeywords, trimmed]);
}
setNewKeyword('');
}}
>
<input
type="text"
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
placeholder={t('attachment_reminder.add_placeholder')}
className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
/>
<button
type="submit"
disabled={!newKeyword.trim()}
className="px-3 py-1 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('attachment_reminder.add')}
</button>
</form>
</div>
)}
{/* Quick Hover Actions */} {/* Quick Hover Actions */}
{isFeatureEnabled('hoverActionsConfigEnabled') && ( {isFeatureEnabled('hoverActionsConfigEnabled') && (
<div className="py-3 border-b border-border space-y-3"> <div className="py-3 border-b border-border space-y-3">
@@ -511,6 +575,14 @@ export function EmailSettings() {
</button> </button>
</SettingItem> </SettingItem>
{/* Trusted Senders — address book storage */}
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
<ToggleSwitch
checked={trustedSendersAddressBook}
onChange={(checked) => updateSetting('trustedSendersAddressBook', checked)}
/>
</SettingItem>
{/* Trusted Senders Modal */} {/* Trusted Senders Modal */}
<TrustedSendersModal <TrustedSendersModal
isOpen={showTrustedModal} isOpen={showTrustedModal}
+19 -3
View File
@@ -99,11 +99,17 @@ function IconPicker({ currentIcon, onSelect, onClose }: {
export function FolderSettings() { export function FolderSettings() {
const t = useTranslations('settings.folders'); const t = useTranslations('settings.folders');
const { client } = useAuthStore(); const { client } = useAuthStore();
const { mailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore(); const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore();
const { folderIcons, setFolderIcon } = useSettingsStore(); const { folderIcons, setFolderIcon } = useSettingsStore();
const { isFeatureEnabled } = usePolicyStore(); const { isFeatureEnabled } = usePolicyStore();
const folderIconsAllowed = isFeatureEnabled('folderIconsEnabled'); const folderIconsAllowed = isFeatureEnabled('folderIconsEnabled');
useEffect(() => {
if (client && mailboxes.length === 0) {
fetchMailboxes(client);
}
}, [client, mailboxes.length, fetchMailboxes]);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [creatingParentId, setCreatingParentId] = useState<string | null>(null); const [creatingParentId, setCreatingParentId] = useState<string | null>(null);
const [newFolderName, setNewFolderName] = useState(''); const [newFolderName, setNewFolderName] = useState('');
@@ -200,8 +206,18 @@ export function FolderSettings() {
await deleteMailbox(client, mailboxId); await deleteMailbox(client, mailboxId);
setDeletingId(null); setDeletingId(null);
toast.success(t('folder_deleted')); toast.success(t('folder_deleted'));
} catch { } catch (err: unknown) {
toast.error(t('error_delete')); const jmapType = (err as Error & { jmapType?: string })?.jmapType;
switch (jmapType) {
case 'mailboxHasChild':
toast.error(t('error_delete_has_children'));
break;
case 'mailboxHasEmail':
toast.error(t('error_delete_has_email'));
break;
default:
toast.error(t('error_delete'));
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
+62 -3
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import React, { useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store"; import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
@@ -41,16 +41,39 @@ function KeywordRow({
keyword, keyword,
onEdit, onEdit,
onDelete, onDelete,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
isDragOver,
isDragging,
}: { }: {
keyword: KeywordDefinition; keyword: KeywordDefinition;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onDragStart: () => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: () => void;
onDragEnd: () => void;
isDragOver: boolean;
isDragging: boolean;
}) { }) {
const t = useTranslations("settings.keywords"); const t = useTranslations("settings.keywords");
const palette = KEYWORD_PALETTE[keyword.color]; const palette = KEYWORD_PALETTE[keyword.color];
return ( return (
<div className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"> <div
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
className={cn(
"flex items-center gap-3 py-2.5 px-3 rounded-md border bg-background group transition-opacity",
isDragging ? "opacity-40" : "opacity-100",
isDragOver ? "border-primary" : "border-border"
)}
>
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" /> <GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} /> <div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span> <span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
@@ -166,9 +189,39 @@ export function KeywordSettings() {
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [isMigrating, setIsMigrating] = useState(false); const [isMigrating, setIsMigrating] = useState(false);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const existingIds = emailKeywords.map((k) => k.id); const existingIds = emailKeywords.map((k) => k.id);
const handleDragStart = (index: number) => {
setDragIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (index !== dragOverIndex) setDragOverIndex(index);
};
const handleDrop = (index: number) => {
if (dragIndex === null || dragIndex === index) {
setDragIndex(null);
setDragOverIndex(null);
return;
}
const reordered = [...emailKeywords];
const [moved] = reordered.splice(dragIndex, 1);
reordered.splice(index, 0, moved);
reorderKeywords(reordered);
setDragIndex(null);
setDragOverIndex(null);
};
const handleDragEnd = () => {
setDragIndex(null);
setDragOverIndex(null);
};
const handleAdd = (keyword: KeywordDefinition) => { const handleAdd = (keyword: KeywordDefinition) => {
addKeyword(keyword); addKeyword(keyword);
setIsAdding(false); setIsAdding(false);
@@ -220,7 +273,7 @@ export function KeywordSettings() {
{t("migrating")} {t("migrating")}
</div> </div>
)} )}
{emailKeywords.map((keyword) => {emailKeywords.map((keyword, index) =>
editingId === keyword.id ? ( editingId === keyword.id ? (
<KeywordEditForm <KeywordEditForm
key={keyword.id} key={keyword.id}
@@ -238,6 +291,12 @@ export function KeywordSettings() {
setIsAdding(false); setIsAdding(false);
}} }}
onDelete={() => handleDelete(keyword.id)} onDelete={() => handleDelete(keyword.id)}
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={() => handleDrop(index)}
onDragEnd={handleDragEnd}
isDragOver={dragOverIndex === index && dragIndex !== index}
isDragging={dragIndex === index}
/> />
) )
)} )}
+28 -5
View File
@@ -20,7 +20,7 @@ const STATUS_COLORS: Record<PluginStatus, string> = {
export function PluginsSettings() { export function PluginsSettings() {
const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore(); const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore();
const { isFeatureEnabled, isPluginForceEnabled, fetchPolicy, loaded } = usePolicyStore(); const { isFeatureEnabled, isPluginForceEnabled, isPluginApproved, fetchPolicy, loaded } = usePolicyStore();
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null); const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -75,6 +75,13 @@ export function PluginsSettings() {
return; return;
} }
const requireApproval = isFeatureEnabled('requirePluginApproval');
const isApproved = plugin.adminApproved || plugin.managed || isPluginApproved(plugin.id);
if (!plugin.enabled && requireApproval && !isApproved) {
toast.info(`Plugin "${plugin.name}" requires admin approval before it can be enabled`);
return;
}
if (plugin.enabled) { if (plugin.enabled) {
disablePlugin(plugin.id); disablePlugin(plugin.id);
toast.info(`Plugin "${plugin.name}" disabled`); toast.info(`Plugin "${plugin.name}" disabled`);
@@ -108,20 +115,26 @@ export function PluginsSettings() {
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{plugins.map(plugin => ( {plugins.map(plugin => {
const requireApproval = isFeatureEnabled('requirePluginApproval');
const isApproved = plugin.adminApproved || plugin.managed || isPluginApproved(plugin.id);
const needsApproval = requireApproval && !isApproved;
return (
<PluginCard <PluginCard
key={plugin.id} key={plugin.id}
plugin={plugin} plugin={plugin}
isExpanded={expandedPlugin === plugin.id} isExpanded={expandedPlugin === plugin.id}
isForceEnabled={plugin.forceEnabled || isPluginForceEnabled(plugin.id)} isForceEnabled={plugin.forceEnabled || isPluginForceEnabled(plugin.id)}
isManaged={Boolean(plugin.managed)} isManaged={Boolean(plugin.managed)}
needsApproval={needsApproval}
controlsDisabled={!initialized} controlsDisabled={!initialized}
onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)} onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)}
onToggle={() => handleToggle(plugin)} onToggle={() => handleToggle(plugin)}
onUninstall={() => handleUninstall(plugin)} onUninstall={() => handleUninstall(plugin)}
onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)} onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)}
/> />
))} );
})}
</div> </div>
)} )}
@@ -166,6 +179,7 @@ interface PluginCardProps {
isExpanded: boolean; isExpanded: boolean;
isForceEnabled: boolean; isForceEnabled: boolean;
isManaged: boolean; isManaged: boolean;
needsApproval: boolean;
controlsDisabled: boolean; controlsDisabled: boolean;
onToggleExpand: () => void; onToggleExpand: () => void;
onToggle: () => void; onToggle: () => void;
@@ -173,7 +187,7 @@ interface PluginCardProps {
onUpdateSettings: (settings: Record<string, unknown>) => void; onUpdateSettings: (settings: Record<string, unknown>) => void;
} }
function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDisabled, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) { function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, needsApproval, controlsDisabled, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) {
return ( return (
<div className={cn( <div className={cn(
'rounded-lg border transition-colors', 'rounded-lg border transition-colors',
@@ -197,6 +211,11 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
<Server className="w-2.5 h-2.5" /> Managed <Server className="w-2.5 h-2.5" /> Managed
</span> </span>
)} )}
{needsApproval && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400">
Awaiting approval
</span>
)}
</div> </div>
<div className="flex items-center gap-2 mt-0.5"> <div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">{plugin.author}</span> <span className="text-xs text-muted-foreground">{plugin.author}</span>
@@ -206,7 +225,7 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} disabled={controlsDisabled || isForceEnabled} /> <ToggleSwitch checked={plugin.enabled} onChange={onToggle} disabled={controlsDisabled || isForceEnabled || needsApproval} />
</div> </div>
</div> </div>
@@ -217,6 +236,10 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
<p className="text-xs text-amber-600 dark:text-amber-400">This plugin is forced by an administrator and cannot be disabled or uninstalled.</p> <p className="text-xs text-amber-600 dark:text-amber-400">This plugin is forced by an administrator and cannot be disabled or uninstalled.</p>
)} )}
{needsApproval && (
<p className="text-xs text-orange-600 dark:text-orange-400">This plugin is awaiting admin approval and cannot be enabled until an administrator approves it.</p>
)}
{/* Description */} {/* Description */}
{plugin.description && ( {plugin.description && (
<p className="text-xs text-muted-foreground">{plugin.description}</p> <p className="text-xs text-muted-foreground">{plugin.description}</p>
@@ -11,7 +11,7 @@ import { IconPicker } from "@/components/layout/icon-picker";
import { useSettingsStore, type SidebarApp } from "@/stores/settings-store"; import { useSettingsStore, type SidebarApp } from "@/stores/settings-store";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { cn } from "@/lib/utils"; import { cn, generateUUID } from "@/lib/utils";
interface SidebarAppFormData { interface SidebarAppFormData {
name: string; name: string;
@@ -188,7 +188,7 @@ export function SidebarAppsSettings() {
const draggedIndexRef = useRef<number | null>(null); const draggedIndexRef = useRef<number | null>(null);
const handleAdd = useCallback((data: SidebarAppFormData) => { const handleAdd = useCallback((data: SidebarAppFormData) => {
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const id = `app-${generateUUID()}`;
addSidebarApp({ id, ...data }); addSidebarApp({ id, ...data });
setShowAddForm(false); setShowAddForm(false);
}, [addSidebarApp]); }, [addSidebarApp]);
+387
View File
@@ -0,0 +1,387 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { Shield, Mail, X, AlertTriangle, Trophy, RotateCcw, Inbox, MailCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
const GAME_WIDTH = 400;
const GAME_HEIGHT = 520;
const FORTRESS_Y = GAME_HEIGHT - 48;
const SPAWN_INTERVAL_START = 850;
const SPAWN_INTERVAL_MIN = 320;
const GAME_DURATION = 30;
const ENEMY_SPEED_START = 1.2;
const ENEMY_SPEED_INCREASE = 0.04;
interface Enemy {
id: number;
x: number;
y: number;
speed: number;
type: "spam" | "phishing" | "legit";
}
type GameState = "idle" | "playing" | "won" | "lost";
export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
const [gameState, setGameState] = useState<GameState>("idle");
const [enemies, setEnemies] = useState<Enemy[]>([]);
const [score, setScore] = useState(0);
const [timeLeft, setTimeLeft] = useState(GAME_DURATION);
const [shieldHealth, setShieldHealth] = useState(3);
const [hitEffects, setHitEffects] = useState<{ id: number; x: number; y: number; color: string }[]>([]);
const [destroyEffects, setDestroyEffects] = useState<{ id: number; x: number; y: number }[]>([]);
const [deliverEffects, setDeliverEffects] = useState<{ id: number; x: number; y: number }[]>([]);
const nextId = useRef(0);
const animFrameRef = useRef<number>(0);
const lastTimeRef = useRef<number>(0);
const spawnTimerRef = useRef<number>(0);
const gameStateRef = useRef<GameState>("idle");
const elapsedRef = useRef(0);
const destroyedRef = useRef(new Set<number>());
useEffect(() => {
gameStateRef.current = gameState;
}, [gameState]);
const startGame = useCallback(() => {
setGameState("playing");
setEnemies([]);
setScore(0);
setTimeLeft(GAME_DURATION);
setShieldHealth(3);
setHitEffects([]);
setDestroyEffects([]);
setDeliverEffects([]);
nextId.current = 0;
spawnTimerRef.current = 0;
elapsedRef.current = 0;
destroyedRef.current = new Set();
lastTimeRef.current = performance.now();
}, []);
const spawnEnemy = useCallback(() => {
const id = nextId.current++;
const rand = Math.random();
const type = rand > 0.7 ? "legit" : rand > 0.45 ? "phishing" : "spam";
const x = 20 + Math.random() * (GAME_WIDTH - 60);
const elapsed = elapsedRef.current;
const speed = ENEMY_SPEED_START + (elapsed / 1000) * ENEMY_SPEED_INCREASE;
setEnemies((prev) => [...prev, { id, x, y: -30, speed, type }]);
}, []);
const handleHover = useCallback((enemy: Enemy) => {
if (destroyedRef.current.has(enemy.id)) return;
destroyedRef.current.add(enemy.id);
if (enemy.type === "legit") {
// Penalty for blocking legit mail
setShieldHealth((prev) => {
const nh = prev - 1;
if (nh <= 0) setGameState("lost");
return Math.max(0, nh);
});
setScore((prev) => Math.max(0, prev - 15));
const effectId = nextId.current++;
setHitEffects((p) => [...p, { id: effectId, x: enemy.x, y: enemy.y, color: "rgba(34, 197, 94, 0.5)" }]);
setTimeout(() => setHitEffects((p) => p.filter((h) => h.id !== effectId)), 500);
} else {
setScore((prev) => prev + 10);
const effectId = nextId.current++;
setDestroyEffects((prev) => [...prev, { id: effectId, x: enemy.x, y: enemy.y }]);
setTimeout(() => setDestroyEffects((prev) => prev.filter((e) => e.id !== effectId)), 400);
}
setEnemies((prev) => prev.filter((e) => e.id !== enemy.id));
}, []);
// Game loop
useEffect(() => {
if (gameState !== "playing") return;
const tick = (now: number) => {
if (gameStateRef.current !== "playing") return;
const dt = now - lastTimeRef.current;
lastTimeRef.current = now;
elapsedRef.current += dt;
// Timer
const newTimeLeft = GAME_DURATION - Math.floor(elapsedRef.current / 1000);
setTimeLeft(Math.max(0, newTimeLeft));
if (newTimeLeft <= 0) {
setGameState("won");
return;
}
// Spawn
spawnTimerRef.current += dt;
const spawnInterval = Math.max(
SPAWN_INTERVAL_MIN,
SPAWN_INTERVAL_START - (elapsedRef.current / 1000) * 35
);
if (spawnTimerRef.current >= spawnInterval) {
spawnTimerRef.current = 0;
spawnEnemy();
}
// Move enemies
setEnemies((prev) => {
const next: Enemy[] = [];
let spamBreached = false;
for (const e of prev) {
const ny = e.y + e.speed * (dt / 16);
if (ny >= FORTRESS_Y) {
if (e.type === "legit") {
// Legit mail delivered — bonus
setScore((s) => s + 5);
const effectId = nextId.current++;
setDeliverEffects((p) => [...p, { id: effectId, x: e.x, y: FORTRESS_Y }]);
setTimeout(() => setDeliverEffects((p) => p.filter((d) => d.id !== effectId)), 500);
} else {
spamBreached = true;
const effectId = nextId.current++;
setHitEffects((p) => [...p, { id: effectId, x: e.x, y: FORTRESS_Y, color: "rgba(219, 45, 84, 0.3)" }]);
setTimeout(() => setHitEffects((p) => p.filter((h) => h.id !== effectId)), 500);
}
} else {
next.push({ ...e, y: ny });
}
}
if (spamBreached) {
setShieldHealth((prev) => {
const nh = prev - 1;
if (nh <= 0) setGameState("lost");
return Math.max(0, nh);
});
}
return next;
});
animFrameRef.current = requestAnimationFrame(tick);
};
animFrameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(animFrameRef.current);
}, [gameState, spawnEnemy]);
const getEnemyStyle = (type: Enemy["type"]) => {
switch (type) {
case "phishing":
return { bg: "rgba(234, 179, 8, 0.15)", border: "rgba(234, 179, 8, 0.4)", color: "rgb(234, 179, 8)" };
case "legit":
return { bg: "rgba(34, 197, 94, 0.12)", border: "rgba(34, 197, 94, 0.4)", color: "rgb(34, 197, 94)" };
default:
return { bg: "rgba(219, 45, 84, 0.1)", border: "rgba(219, 45, 84, 0.3)", color: "rgb(219, 45, 84)" };
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="relative rounded-xl border border-border bg-card shadow-2xl overflow-hidden select-none"
style={{ width: GAME_WIDTH, maxWidth: "95vw" }}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-card">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4" style={{ color: "rgb(219, 45, 84)" }} />
<span className="text-sm font-semibold text-foreground">Spam Siege</span>
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-muted transition-colors">
<X className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{/* HUD */}
<div className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b border-border text-xs">
<div className="flex items-center gap-3">
<span className="text-muted-foreground">Score: <span className="font-semibold text-foreground">{score}</span></span>
<span className="text-muted-foreground">Time: <span className="font-semibold text-foreground">{timeLeft}s</span></span>
</div>
<div className="flex items-center gap-1">
{[...Array(3)].map((_, i) => (
<Shield
key={i}
className="w-3.5 h-3.5 transition-colors"
style={{ color: i < shieldHealth ? "rgb(219, 45, 84)" : "rgb(100, 100, 100)" }}
fill={i < shieldHealth ? "rgb(219, 45, 84)" : "none"}
strokeWidth={i < shieldHealth ? 0 : 1.5}
/>
))}
</div>
</div>
{/* Game area */}
<div
className="relative bg-background overflow-hidden"
style={{ height: GAME_HEIGHT }}
>
{/* Grid lines for depth */}
<div className="absolute inset-0 opacity-[0.03]" style={{
backgroundImage: "linear-gradient(to bottom, currentColor 1px, transparent 1px), linear-gradient(to right, currentColor 1px, transparent 1px)",
backgroundSize: "40px 40px",
}} />
{/* Fortress wall */}
<div className="absolute left-0 right-0 bottom-0 flex flex-col items-center" style={{ height: GAME_HEIGHT - FORTRESS_Y }}>
<div className="relative w-full">
{/* Shield centered above the line */}
<div className="absolute -top-5 left-1/2 -translate-x-1/2 z-10">
<Shield
className="w-7 h-7 drop-shadow-sm"
style={{ color: shieldHealth > 0 ? "rgb(219, 45, 84)" : "rgb(100, 100, 100)" }}
fill={shieldHealth > 0 ? "rgba(219, 45, 84, 0.2)" : "none"}
/>
</div>
{/* Solid line */}
<div
className="h-[2px] w-full"
style={{ backgroundColor: shieldHealth > 0 ? "rgba(219, 45, 84, 0.35)" : "rgba(100, 100, 100, 0.3)" }}
/>
</div>
{/* Subtle gradient fill below */}
<div
className="flex-1 w-full"
style={{
background: shieldHealth > 0
? "linear-gradient(to bottom, rgba(219, 45, 84, 0.06), transparent)"
: "linear-gradient(to bottom, rgba(100, 100, 100, 0.04), transparent)",
}}
/>
</div>
{/* Enemies */}
{enemies.map((e) => {
const style = getEnemyStyle(e.type);
return (
<div
key={e.id}
className="absolute flex items-center justify-center w-8 h-8 rounded-md transition-transform"
style={{
left: e.x,
top: e.y,
backgroundColor: style.bg,
border: `1px solid ${style.border}`,
}}
onMouseEnter={() => handleHover(e)}
>
{e.type === "phishing" ? (
<AlertTriangle className="w-4 h-4" style={{ color: style.color }} />
) : e.type === "legit" ? (
<MailCheck className="w-4 h-4" style={{ color: style.color }} />
) : (
<Mail className="w-4 h-4" style={{ color: style.color }} />
)}
</div>
);
})}
{/* Destroy effects */}
{destroyEffects.map((e) => (
<div
key={e.id}
className="absolute pointer-events-none animate-ping"
style={{ left: e.x + 4, top: e.y + 4 }}
>
<X className="w-5 h-5 text-muted-foreground/50" />
</div>
))}
{/* Deliver effects (legit mail arrived) */}
{deliverEffects.map((e) => (
<div
key={e.id}
className="absolute pointer-events-none animate-ping"
style={{ left: e.x + 4, top: e.y - 8 }}
>
<Inbox className="w-5 h-5" style={{ color: "rgb(34, 197, 94)" }} />
</div>
))}
{/* Hit effects on fortress */}
{hitEffects.map((e) => (
<div
key={e.id}
className="absolute pointer-events-none"
style={{ left: e.x, top: e.y - 10 }}
>
<div className="w-6 h-6 rounded-full animate-ping" style={{ backgroundColor: e.color }} />
</div>
))}
{/* Idle overlay */}
{gameState === "idle" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
<Shield className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} fill="rgba(219, 45, 84, 0.1)" />
<div className="text-center">
<p className="text-base font-semibold text-foreground">Spam Siege</p>
<p className="text-xs text-muted-foreground mt-1.5 max-w-[280px] leading-relaxed">
Hover over threats to block them. Let legitimate mail through. Survive {GAME_DURATION} seconds.
</p>
<div className="flex items-center justify-center gap-4 mt-3 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Mail className="w-3 h-3" style={{ color: "rgb(219, 45, 84)" }} /> Spam
</span>
<span className="inline-flex items-center gap-1">
<AlertTriangle className="w-3 h-3" style={{ color: "rgb(234, 179, 8)" }} /> Phishing
</span>
<span className="inline-flex items-center gap-1">
<MailCheck className="w-3 h-3" style={{ color: "rgb(34, 197, 94)" }} /> Legit
</span>
</div>
</div>
<Button size="sm" onClick={startGame} className="mt-1 text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
<Shield className="w-3.5 h-3.5 mr-1.5" />
Defend
</Button>
</div>
)}
{/* Won overlay */}
{gameState === "won" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
<Trophy className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} />
<div className="text-center">
<p className="text-base font-semibold text-foreground">Fortress Secured</p>
<p className="text-xs text-muted-foreground mt-1">
Score: <span className="font-semibold text-foreground">{score}</span>
</p>
</div>
<div className="flex gap-2 mt-1">
<Button size="sm" variant="outline" onClick={onClose}>
Close
</Button>
<Button size="sm" onClick={startGame} className="text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
Again
</Button>
</div>
</div>
)}
{/* Lost overlay */}
{gameState === "lost" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
<Shield className="w-14 h-14 text-muted-foreground/40" />
<div className="text-center">
<p className="text-base font-semibold text-foreground">Fortress Breached</p>
<p className="text-xs text-muted-foreground mt-1">
Score: <span className="font-semibold text-foreground">{score}</span>
</p>
</div>
<div className="flex gap-2 mt-1">
<Button size="sm" variant="outline" onClick={onClose}>
Close
</Button>
<Button size="sm" onClick={startGame} className="text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
Retry
</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
+64 -17
View File
@@ -2,9 +2,11 @@
import { useState, useEffect, useRef, useMemo } from "react"; import { useState, useEffect, useRef, useMemo } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { X, ShieldCheck, Search, Trash2, Plus } from "lucide-react"; import { X, ShieldCheck, Search, Trash2, Plus, Loader2 } from "lucide-react";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface TrustedSendersModalProps { interface TrustedSendersModalProps {
@@ -17,22 +19,43 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const { trustedSenders, addTrustedSender, removeTrustedSender } = useSettingsStore(); const { trustedSenders, addTrustedSender, removeTrustedSender, trustedSendersAddressBook } = useSettingsStore();
const {
trustedSenderEmails,
trustedSendersLoaded,
trustedSendersLoading,
loadTrustedSendersBook,
addToTrustedSendersBook,
removeFromTrustedSendersBook,
} = useContactStore();
const { client } = useAuthStore();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [newEmail, setNewEmail] = useState(""); const [newEmail, setNewEmail] = useState("");
const [emailError, setEmailError] = useState(""); const [emailError, setEmailError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
// When address book mode is on, load the book on first open
useEffect(() => {
if (isOpen && trustedSendersAddressBook && client && !trustedSendersLoaded) {
loadTrustedSendersBook(client);
}
}, [isOpen, trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]);
// The active list depends on mode
const activeSenders = trustedSendersAddressBook ? trustedSenderEmails : trustedSenders;
const isLoading = trustedSendersAddressBook && (!trustedSendersLoaded || trustedSendersLoading);
// Filter senders based on search query // Filter senders based on search query
const filteredSenders = useMemo(() => { const filteredSenders = useMemo(() => {
if (!searchQuery.trim()) return trustedSenders; if (!searchQuery.trim()) return activeSenders;
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
return trustedSenders.filter((email) => email.toLowerCase().includes(query)); return activeSenders.filter((email) => email.toLowerCase().includes(query));
}, [trustedSenders, searchQuery]); }, [activeSenders, searchQuery]);
// Show search only when 5+ senders // Show search only when 5+ senders
const showSearch = trustedSenders.length >= 5; const showSearch = activeSenders.length >= 5;
// Close on Escape key // Close on Escape key
useEffect(() => { useEffect(() => {
@@ -90,7 +113,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
return emailRegex.test(email); return emailRegex.test(email);
}; };
const handleAddSender = () => { const handleAddSender = async () => {
const trimmedEmail = newEmail.trim().toLowerCase(); const trimmedEmail = newEmail.trim().toLowerCase();
if (!trimmedEmail) { if (!trimmedEmail) {
@@ -103,15 +126,34 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
return; return;
} }
if (trustedSenders.includes(trimmedEmail)) { if (activeSenders.includes(trimmedEmail)) {
setEmailError(t("already_added")); setEmailError(t("already_added"));
return; return;
} }
addTrustedSender(trimmedEmail); setIsSubmitting(true);
setNewEmail(""); try {
setIsAdding(false); if (trustedSendersAddressBook && client) {
setEmailError(""); await addToTrustedSendersBook(client, trimmedEmail);
} else {
addTrustedSender(trimmedEmail);
}
setNewEmail("");
setIsAdding(false);
setEmailError("");
} catch {
setEmailError(t("save_error"));
} finally {
setIsSubmitting(false);
}
};
const handleRemoveSender = async (email: string) => {
if (trustedSendersAddressBook && client) {
await removeFromTrustedSendersBook(client, email);
} else {
removeTrustedSender(email);
}
}; };
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -170,7 +212,11 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{trustedSenders.length === 0 ? ( {isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : activeSenders.length === 0 ? (
/* Empty State */ /* Empty State */
<div className="flex flex-col items-center justify-center py-12 px-6 text-center"> <div className="flex flex-col items-center justify-center py-12 px-6 text-center">
<ShieldCheck className="w-12 h-12 text-muted-foreground/50 mb-4" /> <ShieldCheck className="w-12 h-12 text-muted-foreground/50 mb-4" />
@@ -209,7 +255,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
{email} {email}
</span> </span>
<button <button
onClick={() => removeTrustedSender(email)} onClick={() => handleRemoveSender(email)}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100" className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
aria-label={`${t("remove")} ${email}`} aria-label={`${t("remove")} ${email}`}
> >
@@ -222,7 +268,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
</div> </div>
{/* Footer - Add sender */} {/* Footer - Add sender */}
{trustedSenders.length > 0 && ( {!isLoading && activeSenders.length > 0 && (
<div className="px-6 py-4 border-t border-border flex-shrink-0"> <div className="px-6 py-4 border-t border-border flex-shrink-0">
{isAdding ? ( {isAdding ? (
<div className="space-y-2"> <div className="space-y-2">
@@ -244,9 +290,10 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
/> />
<button <button
onClick={handleAddSender} onClick={handleAddSender}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors text-sm font-medium" disabled={isSubmitting}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors text-sm font-medium disabled:opacity-50"
> >
{t("add_button")} {isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : t("add_button")}
</button> </button>
</div> </div>
{emailError && ( {emailError && (
+30 -7
View File
@@ -1,10 +1,11 @@
"use client"; "use client";
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback, useMemo, useEffect } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore, getContactPhotoUri } from "@/stores/contact-store"; import { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
import { avatarHooks } from "@/lib/plugin-hooks";
const IS_DEV = process.env.NODE_ENV !== "production"; const IS_DEV = process.env.NODE_ENV !== "production";
@@ -143,10 +144,26 @@ interface AvatarProps {
export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) { export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) {
const [imgError, setImgError] = useState(false); const [imgError, setImgError] = useState(false);
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
const senderFavicons = useSettingsStore((s) => s.senderFavicons); const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const contacts = useContactStore((s) => s.contacts); const contacts = useContactStore((s) => s.contacts);
const { devMode } = useConfig(); const { devMode } = useConfig();
// Ask plugins (e.g. Gravatar) to resolve an avatar URL for this email address.
// Runs whenever email or name changes; resets plugin avatar state on each change.
useEffect(() => {
setPluginAvatarUrl(null);
setPluginAvatarFailed(false);
if (!email || avatarHooks.onAvatarResolve.size === 0) return;
let cancelled = false;
avatarHooks.onAvatarResolve
.transform(null as string | null, { email, name })
.then((url) => { if (!cancelled) setPluginAvatarUrl(url); })
.catch(() => { if (!cancelled) setPluginAvatarFailed(true); });
return () => { cancelled = true; };
}, [email, name]);
// Look up contact photo by email from the contact store // Look up contact photo by email from the contact store
const resolvedContactPhoto = useMemo(() => { const resolvedContactPhoto = useMemo(() => {
if (contactPhotoUri) return contactPhotoUri; if (contactPhotoUri) return contactPhotoUri;
@@ -202,19 +219,25 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className }:
const showFavicon = const showFavicon =
senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed; senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
// Priority: contact photo > custom avatar > profile picture > company favicon > initials // Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null; const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
const imgSrc = !imgError && !domainFailed const imgSrc = !imgError && !domainFailed
? resolvedContactPhoto || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null) ? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
: (resolvedContactPhoto || customAvatar || profilePic || null); : (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
const handleImgError = useCallback(() => { const handleImgError = useCallback(() => {
// If the plugin avatar just failed, mark it and fall through to the next source
if (pluginAvatar && imgSrc === pluginAvatar) {
setPluginAvatarFailed(true);
return;
}
setImgError(true); setImgError(true);
// If this was a favicon URL (not a contact photo, custom avatar or profile pic), remember the domain // If this was a favicon URL (not a contact photo, plugin avatar, custom avatar or profile pic), remember the domain
if (faviconDomain && !resolvedContactPhoto && !customAvatar && !profilePic) { if (faviconDomain && !resolvedContactPhoto && !pluginAvatar && !customAvatar && !profilePic) {
failedFaviconDomains.add(faviconDomain); failedFaviconDomains.add(faviconDomain);
} }
}, [faviconDomain, resolvedContactPhoto, customAvatar, profilePic]); }, [imgSrc, pluginAvatar, faviconDomain, resolvedContactPhoto, customAvatar, profilePic]);
return ( return (
<div <div
+5 -1
View File
@@ -12,12 +12,16 @@ export function LanguageSwitcher({ className }: { className?: string }) {
{ value: 'en', label: 'English' }, { value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' }, { value: 'fr', label: 'Français' },
{ value: 'ja', label: '日本語' }, { value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'es', label: 'Español' }, { value: 'es', label: 'Español' },
{ value: 'it', label: 'Italiano' }, { value: 'it', label: 'Italiano' },
{ value: 'de', label: 'Deutsch' }, { value: 'de', label: 'Deutsch' },
{ value: 'lv', label: 'Latviešu' },
{ value: 'nl', label: 'Nederlands' }, { value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' }, { value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' } { value: 'ru', label: 'Русский' },
{ value: 'zh', label: '简体中文' }
]; ];
return ( return (
+186
View File
@@ -0,0 +1,186 @@
"use client";
import { useEffect, useRef } from "react";
export interface NavSnapshot {
mailboxId: string | null;
emailId: string | null;
threadId: string | null;
composerOpen: boolean;
sidebarOpen: boolean;
}
interface StoredNavState extends NavSnapshot {
navId: number;
}
interface UseBrowserNavigationOptions extends NavSnapshot {
onRestore: (state: NavSnapshot) => void | Promise<void>;
enabled?: boolean;
}
const STATE_KEY = "__mailNav";
let navIdCounter = 0;
function snapshotsEqual(
a: NavSnapshot | undefined | null,
b: NavSnapshot,
): boolean {
if (!a) return false;
return (
a.mailboxId === b.mailboxId &&
a.emailId === b.emailId &&
a.threadId === b.threadId &&
a.composerOpen === b.composerOpen &&
a.sidebarOpen === b.sidebarOpen
);
}
function readStoredState(): StoredNavState | undefined {
if (typeof window === "undefined") return undefined;
const raw = window.history.state as Record<string, unknown> | null;
if (!raw) return undefined;
return raw[STATE_KEY] as StoredNavState | undefined;
}
/**
* Syncs in-app navigation state to the browser history stack so the browser
* back / forward buttons (mouse buttons on desktop, gesture / hardware button
* on mobile) navigate within the mail UI.
*
* - Pushes a new history entry whenever the captured snapshot changes from
* user action.
* - Listens for popstate and calls onRestore so the page can apply the
* previous snapshot (mailbox, email, view, sidebar, conversation thread).
*
* The URL is left untouched so Next.js routes (e.g. /calendar, /settings)
* continue to behave normally.
*/
export function useBrowserNavigation({
mailboxId,
emailId,
threadId,
composerOpen,
sidebarOpen,
onRestore,
enabled = true,
}: UseBrowserNavigationOptions) {
// Counter so overlapping restores don't accidentally clear the flag
// belonging to a later restore.
const popDepthRef = useRef(0);
const isApplyingPopRef = useRef(false);
const restoreRef = useRef(onRestore);
const initializedRef = useRef(false);
// Always keep the latest restore callback in a ref so the popstate
// listener never sees a stale closure.
restoreRef.current = onRestore;
// Install the popstate listener once.
useEffect(() => {
if (typeof window === "undefined") return;
const handlePop = (event: PopStateEvent) => {
const raw = event.state as Record<string, unknown> | null;
const state = raw ? (raw[STATE_KEY] as StoredNavState | undefined) : undefined;
if (!state) return;
// Hold the "applying pop" flag for the entire restore — including any
// async work like fetching email content — so the resulting state
// updates don't trigger a fresh history push that would undo the
// user's back / forward navigation.
popDepthRef.current += 1;
isApplyingPopRef.current = true;
const settle = () => {
popDepthRef.current -= 1;
if (popDepthRef.current === 0) {
// One extra macrotask so React has flushed any state updates
// dispatched at the very end of the restore.
setTimeout(() => {
if (popDepthRef.current === 0) {
isApplyingPopRef.current = false;
}
}, 0);
}
};
let result: void | Promise<void>;
try {
result = restoreRef.current(state);
} catch (error) {
settle();
throw error;
}
if (result && typeof (result as Promise<void>).then === "function") {
(result as Promise<void>).finally(settle);
} else {
settle();
}
};
window.addEventListener("popstate", handlePop);
return () => window.removeEventListener("popstate", handlePop);
}, []);
// Push a new history entry whenever the navigation snapshot changes.
useEffect(() => {
if (!enabled) return;
if (typeof window === "undefined") return;
if (isApplyingPopRef.current) return;
const snapshot: NavSnapshot = {
mailboxId,
emailId,
threadId,
composerOpen,
sidebarOpen,
};
const existing = readStoredState();
if (snapshotsEqual(existing, snapshot)) return;
const stored: StoredNavState = { ...snapshot, navId: ++navIdCounter };
const baseState = (window.history.state ?? {}) as Record<string, unknown>;
const newState = { ...baseState, [STATE_KEY]: stored };
if (!initializedRef.current) {
initializedRef.current = true;
if (emailId || threadId) {
// The app is initializing directly on an email/thread view (e.g. the
// user navigated here from /settings or an external link). Seed a
// "list" history entry first so that the toolbar back button returns
// to the list instead of leaving the app entirely.
const listSnapshot: NavSnapshot = {
mailboxId,
emailId: null,
threadId: null,
composerOpen: false,
sidebarOpen,
};
const listStored: StoredNavState = {
...listSnapshot,
navId: ++navIdCounter,
};
const baseState = (window.history.state ?? {}) as Record<
string,
unknown
>;
window.history.replaceState(
{ ...baseState, [STATE_KEY]: listStored },
"",
);
// Now push the actual email state on top of the synthetic list entry.
window.history.pushState(newState, "");
} else {
// Replace the current entry on the very first run so we don't
// create an extra step the user has to back through to leave the app.
window.history.replaceState(newState, "");
}
} else {
window.history.pushState(newState, "");
}
}, [enabled, mailboxId, emailId, threadId, composerOpen, sidebarOpen]);
}
+31
View File
@@ -0,0 +1,31 @@
'use client';
import { useEffect } from 'react';
import { useAuthStore } from '@/stores/auth-store';
// Re-sync identities every 30 minutes while the app is open
const SYNC_INTERVAL_MS = 30 * 60 * 1000;
export function useIdentitySync() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const refreshIdentities = useAuthStore((s) => s.refreshIdentities);
useEffect(() => {
if (!isAuthenticated) return;
// Sync when the user returns to the tab (e.g. after adding an alias in Stalwart)
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refreshIdentities();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
const interval = setInterval(refreshIdentities, SYNC_INTERVAL_MS);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
clearInterval(interval);
};
}, [isAuthenticated, refreshIdentities]);
}
+1 -8
View File
@@ -78,14 +78,7 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us
const email = currentEmails.find(em => em.id === emailId); const email = currentEmails.find(em => em.id === emailId);
const keywords = { ...(email?.keywords || {}) }; const keywords = { ...(email?.keywords || {}) };
// Remove old label/color keywords // Add the tag without removing existing ones
Object.keys(keywords).forEach(key => {
if (key.startsWith("$label:") || key.startsWith("$color:")) {
keywords[key] = false;
}
});
// Add the new tag
keywords[`$label:${tagId}`] = true; keywords[`$label:${tagId}`] = true;
await client.updateEmailKeywords(emailId, keywords); await client.updateEmailKeywords(emailId, keywords);
+13 -1
View File
@@ -26,15 +26,27 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'ja': case 'ja':
messages = (await import('../locales/ja/common.json')).default; messages = (await import('../locales/ja/common.json')).default;
break; break;
case 'ko':
messages = (await import('../locales/ko/common.json')).default;
break;
case 'lv':
messages = (await import('../locales/lv/common.json')).default;
break;
case 'nl': case 'nl':
messages = (await import('../locales/nl/common.json')).default; messages = (await import('../locales/nl/common.json')).default;
break; break;
case 'pl':
messages = (await import('../locales/pl/common.json')).default;
break;
case 'pt': case 'pt':
messages = (await import('../locales/pt/common.json')).default; messages = (await import('../locales/pt/common.json')).default;
break; break;
case 'ru': case 'ru':
messages = (await import('../locales/ru/common.json')).default; messages = (await import('../locales/ru/common.json')).default;
break; break;
case 'zh':
messages = (await import('../locales/zh/common.json')).default;
break;
default: default:
messages = (await import('../locales/en/common.json')).default; messages = (await import('../locales/en/common.json')).default;
} }
@@ -45,4 +57,4 @@ export default getRequestConfig(async ({ requestLocale }) => {
timeZone: 'Europe/Paris', timeZone: 'Europe/Paris',
now: new Date() now: new Date()
}; };
}); });
+1 -1
View File
@@ -1,7 +1,7 @@
import { defineRouting } from 'next-intl/routing'; import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({ export const routing = defineRouting({
locales: ['en', 'fr', 'de', 'es', 'it', 'ja', 'nl', 'pt', 'ru'], locales: ['en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'zh'],
defaultLocale: 'en', defaultLocale: 'en',
localePrefix: 'never' localePrefix: 'never'
}); });
+32
View File
@@ -1,3 +1,4 @@
import { unlink, writeFileSync } from "fs";
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock NextResponse before importing the route // Mock NextResponse before importing the route
@@ -24,6 +25,7 @@ describe('config API route', () => {
delete process.env.OAUTH_CLIENT_ID; delete process.env.OAUTH_CLIENT_ID;
delete process.env.OAUTH_ISSUER_URL; delete process.env.OAUTH_ISSUER_URL;
delete process.env.SESSION_SECRET; delete process.env.SESSION_SECRET;
delete process.env.SESSION_SECRET_FILE;
delete process.env.SETTINGS_SYNC_ENABLED; delete process.env.SETTINGS_SYNC_ENABLED;
delete process.env.STALWART_FEATURES; delete process.env.STALWART_FEATURES;
delete process.env.DEV_MOCK_JMAP; delete process.env.DEV_MOCK_JMAP;
@@ -128,6 +130,19 @@ describe('config API route', () => {
const config = await getConfig(); const config = await getConfig();
expect(config.rememberMeEnabled).toBe(true);
});
it('should enable rememberMe when SESSION_SECRET_FILE is set', async () => {
writeFileSync('./session-secret', 'test-secret');
process.env.SESSION_SECRET_FILE = './session-secret';
const config = await getConfig();
unlink('./session-secret', (err) => {
if (err) throw err;
});
expect(config.rememberMeEnabled).toBe(true); expect(config.rememberMeEnabled).toBe(true);
}); });
@@ -138,6 +153,23 @@ describe('config API route', () => {
process.env.SESSION_SECRET = 'test-secret'; process.env.SESSION_SECRET = 'test-secret';
const config2 = await getConfig(); const config2 = await getConfig();
expect(config2.settingsSyncEnabled).toBe(true);
});
it('should enable settingsSync only when both SESSION_SECRET_FILE and SETTINGS_SYNC_ENABLED are set', async () => {
process.env.SETTINGS_SYNC_ENABLED = 'true';
const config1 = await getConfig();
expect(config1.settingsSyncEnabled).toBe(false);
writeFileSync('./session-secret', 'test-secret');
process.env.SESSION_SECRET_FILE = './session-secret';
const config2 = await getConfig();
unlink('./session-secret', (err) => {
if (err) throw err;
});
expect(config2.settingsSyncEnabled).toBe(true); expect(config2.settingsSyncEnabled).toBe(true);
}); });
+75 -36
View File
@@ -263,48 +263,37 @@ describe('mailbox deep nesting (depth 4+)', () => {
}); });
}); });
describe('mailbox deduplication side effects on nesting', () => { describe('GitHub #118: duplicate subfolder names cause depth-4 orphaning', () => {
it('should not remove an intermediate parent whose name matches a role mailbox', () => { it('should keep nested folders when a subfolder has the same name as a role mailbox', () => {
// Scenario: A non-role folder named "Sent" is an intermediate parent. // Reporter's exact scenario: two subfolders with the same name.
// The deduplication logic might remove it because it matches the role "sent" mailbox name. // The dedup uses substring matching and removes non-role folders whose name
// matches a role folder — even if they're deep in the tree with children.
const mailboxes = [ const mailboxes = [
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }), makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }), makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
// A user-created folder also named "Sent" that's a child of Inbox // User-created subfolder also named "Sent" nested under Inbox
makeMailbox({ id: 'sent-custom', name: 'Sent', parentId: 'inbox' }), makeMailbox({ id: 'sent-custom', name: 'Sent', parentId: 'inbox' }),
// Child of the custom "Sent" folder // Child of the custom "Sent" folder — becomes orphaned if parent is deduped
makeMailbox({ id: 'sent-child', name: 'Archive', parentId: 'sent-custom' }), makeMailbox({ id: 'sent-child', name: 'Archive', parentId: 'sent-custom' }),
]; ];
const tree = buildMailboxTree(mailboxes); const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree); const flat = flattenMailboxTree(tree);
const rootIds = tree.map(n => n.id);
// sent-custom MUST be kept because it has children — removing it orphans sent-child
const sentCustom = flat.find(n => n.id === 'sent-custom');
expect(sentCustom).toBeDefined();
expect(sentCustom!.depth).toBe(1); // nested under Inbox
// The custom "Sent" (sent-custom) might be filtered by deduplication.
// If it IS removed, then "sent-child" loses its parent and becomes root — BAD.
const sentChild = flat.find(n => n.id === 'sent-child'); const sentChild = flat.find(n => n.id === 'sent-child');
expect(sentChild).toBeDefined(); expect(sentChild).toBeDefined();
expect(sentChild!.depth).toBe(2); // nested under sent-custom
// Check if sent-child is orphaned at root (the bug) expect(rootIds).not.toContain('sent-child'); // must NOT be orphaned at root
const rootIds = tree.map(n => n.id);
const isOrphaned = rootIds.includes('sent-child');
if (isOrphaned) {
// This demonstrates the deduplication bug: removing an intermediate parent
// causes its children to become orphaned at root level
console.warn(
'BUG DETECTED: Deduplication removed intermediate parent "sent-custom", ' +
'orphaning "sent-child" to root level.'
);
}
// Document the current behavior (this test is diagnostic)
// Ideally: sent-child should be nested under sent-custom at depth 2
// If dedup removes sent-custom: sent-child ends up at root with depth 0
expect(sentChild!.depth).toBeGreaterThanOrEqual(0);
}); });
it('should not remove a parent folder whose name is a substring of a role name', () => { it('should keep nested folders when name is substring of a role name', () => {
// Folder named "Draft" (substring of "Drafts" role) used as intermediate parent // "Draft" is a substring of "Drafts" — dedup removes it, orphaning children
const mailboxes = [ const mailboxes = [
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }), makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
makeMailbox({ id: 'drafts-role', name: 'Drafts', role: 'drafts' }), makeMailbox({ id: 'drafts-role', name: 'Drafts', role: 'drafts' }),
@@ -318,16 +307,66 @@ describe('mailbox deduplication side effects on nesting', () => {
const draftChild = flat.find(n => n.id === 'draft-child'); const draftChild = flat.find(n => n.id === 'draft-child');
expect(draftChild).toBeDefined(); expect(draftChild).toBeDefined();
expect(draftChild!.depth).toBe(2);
expect(rootIds).not.toContain('draft-child');
});
const isOrphaned = rootIds.includes('draft-child'); it('should handle the exact reported structure with duplicate names at different depths', () => {
if (isOrphaned) { // Stalwart allows creating subfolders with the same name at different levels.
console.warn( // If any of those names match a role mailbox name, dedup could remove them.
'BUG DETECTED: Deduplication removed "draft-folder" (substring match with "Drafts"), ' + const mailboxes = [
'orphaning "draft-child" to root level.' makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
); makeMailbox({ id: 'trash-role', name: 'Trash', role: 'trash' }),
} makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
makeMailbox({ id: 'bookings', name: 'BOOKINGS', parentId: 'privat' }),
// User created a subfolder named "Trash" under BOOKINGS (e.g. for old bookings)
makeMailbox({ id: 'trash-custom', name: 'Trash', parentId: 'bookings' }),
// Depth 4: child of the custom Trash folder
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'trash-custom' }),
];
expect(draftChild!.depth).toBeGreaterThanOrEqual(0); const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree);
const rootIds = tree.map(n => n.id);
// RESTAURANT must be at depth 4, not orphaned at root
const restaurant = flat.find(n => n.id === 'restaurant');
expect(restaurant).toBeDefined();
expect(restaurant!.depth).toBe(4);
expect(rootIds).not.toContain('restaurant');
// Custom "Trash" must be kept as it has children
const trashCustom = flat.find(n => n.id === 'trash-custom');
expect(trashCustom).toBeDefined();
expect(trashCustom!.depth).toBe(3);
});
it('should only dedup root-level non-role mailboxes that duplicate role mailboxes', () => {
// Dedup should only remove mailboxes that are BOTH:
// 1. At root level (no parentId) — same structural position as role mailbox
// 2. Name-matching a role mailbox
// Nested mailboxes with matching names should always be kept.
const mailboxes = [
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
makeMailbox({ id: 'sent-dup', name: 'Sent Mail' }), // root-level duplicate — OK to remove
makeMailbox({ id: 'proj', name: 'Projects', parentId: 'inbox' }),
makeMailbox({ id: 'sent-nested', name: 'Sent', parentId: 'proj' }), // nested — must keep
makeMailbox({ id: 'report', name: 'Report', parentId: 'sent-nested' }),
];
const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree);
// "Sent Mail" at root (no parentId) can be deduped — that's fine
// But "Sent" nested under Projects must be kept
const sentNested = flat.find(n => n.id === 'sent-nested');
expect(sentNested).toBeDefined();
expect(sentNested!.depth).toBe(2);
const report = flat.find(n => n.id === 'report');
expect(report).toBeDefined();
expect(report!.depth).toBe(3);
}); });
}); });
+43
View File
@@ -148,3 +148,46 @@ describe('toast bridge', () => {
expect(api.toast.warning).toBeInstanceOf(Function); expect(api.toast.warning).toBeInstanceOf(Function);
}); });
}); });
describe('http.post path validation', () => {
function makeApi(permissions: string[] = ['http:post']) {
return createPluginAPI(makePlugin({ permissions }));
}
it('rejects protocol-relative URLs like //evil.example', async () => {
const api = makeApi();
await expect(api.http.post('//evil.example/collect', {})).rejects.toThrow('must start with /api/');
});
it('rejects absolute URLs to other origins', async () => {
const api = makeApi();
await expect(api.http.post('https://evil.example/steal', {})).rejects.toThrow('must start with /api/');
});
it('rejects paths not under /api/', async () => {
const api = makeApi();
await expect(api.http.post('/other/path', {})).rejects.toThrow('must start with /api/');
});
it('rejects paths that use backslash to bypass the check', async () => {
const api = makeApi();
await expect(api.http.post('/api/\\@evil.example', {})).rejects.toThrow();
});
it('throws without http:post permission', async () => {
const api = makeApi([]);
await expect(api.http.post('/api/jitsi', {})).rejects.toThrow('lacks permission');
});
it('accepts a valid /api/ path', async () => {
const api = makeApi();
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ url: 'https://meet.example.com/room' }),
});
const result = await api.http.post('/api/jitsi', { eventTitle: 'test' });
expect(result.ok).toBe(true);
expect(result.data).toEqual({ url: 'https://meet.example.com/room' });
});
});
+106
View File
@@ -375,5 +375,111 @@ describe('expandRecurringEvents', () => {
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00'); const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
expect(result[0].originalId).toBe('evt1'); expect(result[0].originalId).toBe('evt1');
}); });
it('skips server-returned override events that belong to a recurring master', () => {
// Server returns the master event plus override instances with recurrenceId set
const master = makeEvent({
id: 'master1',
uid: 'shared-uid',
start: '2025-01-06T09:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
});
const override1 = makeEvent({
id: 'override1',
uid: 'shared-uid',
start: '2025-01-13T09:00:00',
recurrenceId: '2025-01-13T09:00:00',
recurrenceRules: null,
});
const override2 = makeEvent({
id: 'override2',
uid: 'shared-uid',
start: '2025-01-20T09:00:00',
recurrenceId: '2025-01-20T09:00:00',
recurrenceRules: null,
});
const result = expandRecurringEvents(
[master, override1, override2],
'2025-01-06T00:00:00',
'2025-01-27T00:00:00',
);
// Should have 3 weekly occurrences from master expansion only (Jan 6, 13, 20)
// Override events should be skipped since they share the master's UID
expect(result).toHaveLength(3);
expect(starts(result)).toEqual([
'2025-01-06T09:00:00',
'2025-01-13T09:00:00',
'2025-01-20T09:00:00',
]);
});
it('keeps override events whose UID has no matching master', () => {
// Standalone override event with no master in the batch
const orphanOverride = makeEvent({
id: 'orphan1',
uid: 'orphan-uid',
start: '2025-01-13T09:00:00',
recurrenceId: '2025-01-13T09:00:00',
recurrenceRules: null,
});
const result = expandRecurringEvents(
[orphanOverride],
'2025-01-06T00:00:00',
'2025-01-27T00:00:00',
);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('orphan1');
});
});
// -----------------------------------------------------------------------
// utcStart computation for occurrences
// -----------------------------------------------------------------------
describe('utcStart per occurrence', () => {
it('computes distinct utcStart for each weekly occurrence', () => {
const event = makeEvent({
start: '2026-09-01T12:00:00',
utcStart: '2026-09-01T10:00:00Z',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
});
const result = expand(event, '2026-09-01T00:00:00', '2026-10-01T00:00:00');
const utcStarts = result.map(e => (e as any).utcStart);
// Each occurrence should have a unique utcStart
expect(new Set(utcStarts).size).toBe(result.length);
// First occurrence keeps master's UTC offset relationship
expect(utcStarts[0]).toContain('2026-09-01');
expect(utcStarts[1]).toContain('2026-09-08');
expect(utcStarts[2]).toContain('2026-09-15');
});
it('computes distinct utcEnd for each weekly occurrence', () => {
const event = makeEvent({
start: '2026-09-01T12:00:00',
duration: 'PT1H',
utcStart: '2026-09-01T10:00:00Z',
utcEnd: '2026-09-01T11:00:00Z',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
});
const result = expand(event, '2026-09-01T00:00:00', '2026-10-01T00:00:00');
const utcEnds = result.map(e => (e as any).utcEnd);
// Each occurrence should have a unique utcEnd
expect(new Set(utcEnds).size).toBe(result.length);
expect(utcEnds[0]).toContain('2026-09-01');
expect(utcEnds[1]).toContain('2026-09-08');
expect(utcEnds[2]).toContain('2026-09-15');
});
it('does not set utcStart when master has none', () => {
const event = makeEvent({
start: '2025-01-06T09:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
expect(result[0].utcStart).toBeUndefined();
});
}); });
}); });
+24 -2
View File
@@ -1,6 +1,7 @@
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'; import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
import { readFileEnv } from '@/lib/read-file-env';
import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types'; import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types';
import type { AdminSessionPayload } from './types'; import type { AdminSessionPayload } from './types';
@@ -8,9 +9,17 @@ const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; const IV_LENGTH = 12;
const TAG_LENGTH = 16; const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
function getKey(): Buffer { function getKey(): Buffer {
const secret = process.env.SESSION_SECRET; const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
if (!secret) throw new Error('SESSION_SECRET not configured'); if (!secret) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
`SESSION_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). ` +
`Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
);
}
return createHash('sha256').update(secret).digest(); return createHash('sha256').update(secret).digest();
} }
@@ -116,11 +125,24 @@ export async function clearAdminSessionCookie(): Promise<void> {
/** /**
* Get the client IP from the request headers. * Get the client IP from the request headers.
*
* Proxies typically *append* to X-Forwarded-For, so the last entry
* before our trusted proxy is the most reliable client IP. When a
* single reverse proxy sits in front of the app the rightmost entry
* is the one added by that proxy. We take the rightmost entry to
* avoid trusting attacker-controlled values prepended to the header.
*
* If you run behind multiple trusted proxies, set TRUSTED_PROXY_DEPTH
* to the number of trusted proxies (default 1).
*/ */
export function getClientIP(request: Request): string { export function getClientIP(request: Request): string {
const forwarded = request.headers.get('x-forwarded-for'); const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) { if (forwarded) {
return forwarded.split(',')[0].trim(); const parts = forwarded.split(',').map(s => s.trim()).filter(Boolean);
const depth = Math.max(1, parseInt(process.env.TRUSTED_PROXY_DEPTH || '1', 10));
// Take the entry at position (length - depth), clamped to 0
const index = Math.max(0, parts.length - depth);
return parts[index] || '0.0.0.0';
} }
return request.headers.get('x-real-ip') || '0.0.0.0'; return request.headers.get('x-real-ip') || '0.0.0.0';
} }
+8 -1
View File
@@ -25,6 +25,7 @@ export interface SettingRestriction {
export interface FeatureGates { export interface FeatureGates {
pluginsEnabled: boolean; pluginsEnabled: boolean;
pluginsUploadEnabled: boolean; pluginsUploadEnabled: boolean;
requirePluginApproval: boolean;
themesEnabled: boolean; themesEnabled: boolean;
sidebarAppsEnabled: boolean; sidebarAppsEnabled: boolean;
userThemesEnabled: boolean; userThemesEnabled: boolean;
@@ -37,11 +38,13 @@ export interface FeatureGates {
debugModeEnabled: boolean; debugModeEnabled: boolean;
folderIconsEnabled: boolean; folderIconsEnabled: boolean;
hoverActionsConfigEnabled: boolean; hoverActionsConfigEnabled: boolean;
filesEnabled: boolean;
} }
export const DEFAULT_FEATURE_GATES: FeatureGates = { export const DEFAULT_FEATURE_GATES: FeatureGates = {
pluginsEnabled: true, pluginsEnabled: false,
pluginsUploadEnabled: true, pluginsUploadEnabled: true,
requirePluginApproval: true,
themesEnabled: true, themesEnabled: true,
sidebarAppsEnabled: true, sidebarAppsEnabled: true,
userThemesEnabled: true, userThemesEnabled: true,
@@ -54,6 +57,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
debugModeEnabled: true, debugModeEnabled: true,
folderIconsEnabled: true, folderIconsEnabled: true,
hoverActionsConfigEnabled: true, hoverActionsConfigEnabled: true,
filesEnabled: true,
}; };
export interface ThemePolicy { export interface ThemePolicy {
@@ -78,6 +82,8 @@ export interface SettingsPolicy {
themePolicy: ThemePolicy; themePolicy: ThemePolicy;
/** Plugin IDs that are force-enabled (users cannot disable) */ /** Plugin IDs that are force-enabled (users cannot disable) */
forceEnabledPlugins: string[]; forceEnabledPlugins: string[];
/** Plugin IDs that have been approved by admin (users can enable) */
approvedPlugins: string[];
/** Theme IDs that are force-enabled (users cannot deactivate) */ /** Theme IDs that are force-enabled (users cannot deactivate) */
forceEnabledThemes: string[]; forceEnabledThemes: string[];
} }
@@ -88,6 +94,7 @@ export const DEFAULT_POLICY: SettingsPolicy = {
defaults: {}, defaults: {},
themePolicy: { ...DEFAULT_THEME_POLICY }, themePolicy: { ...DEFAULT_THEME_POLICY },
forceEnabledPlugins: [], forceEnabledPlugins: [],
approvedPlugins: [],
forceEnabledThemes: [], forceEnabledThemes: [],
}; };
+18
View File
@@ -0,0 +1,18 @@
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
export function getActiveAccountSlot(): number | null {
const authState = useAuthStore.getState();
const accountState = useAccountStore.getState();
const activeAccountId = authState.activeAccountId ?? accountState.activeAccountId;
const activeAccount = activeAccountId
? accountState.getAccountById(activeAccountId)
: accountState.getActiveAccount();
return typeof activeAccount?.cookieSlot === 'number' ? activeAccount.cookieSlot : null;
}
export function getActiveAccountSlotHeaders(): Record<string, string> {
const slot = getActiveAccountSlot();
return slot === null ? {} : { 'X-JMAP-Cookie-Slot': String(slot) };
}
+10 -1
View File
@@ -1,13 +1,22 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'; import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
const ALGORITHM = 'aes-256-gcm'; const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; const IV_LENGTH = 12;
const TAG_LENGTH = 16; const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
function getKey(): Buffer { function getKey(): Buffer {
const secret = process.env.SESSION_SECRET; const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
if (!secret) throw new Error('SESSION_SECRET not configured'); if (!secret) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
`SESSION_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). ` +
`Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
);
}
return createHash('sha256').update(secret).digest(); return createHash('sha256').update(secret).digest();
} }
+80
View File
@@ -0,0 +1,80 @@
const VERIFY_TIMEOUT_MS = 10000;
export class JmapAuthVerificationError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'JmapAuthVerificationError';
this.status = status;
}
}
function isSupportedProtocol(protocol: string): boolean {
return protocol === 'http:' || protocol === 'https:';
}
export function normalizeJmapServerUrl(serverUrl: string): string {
let url: URL;
try {
url = new URL(serverUrl);
} catch {
throw new JmapAuthVerificationError('Invalid server URL', 400);
}
if (!isSupportedProtocol(url.protocol)) {
throw new JmapAuthVerificationError('Unsupported server URL protocol', 400);
}
url.hash = '';
url.search = '';
return url.toString().replace(/\/+$/, '');
}
export function validateProxyAuthHeader(authHeader: string): void {
if (!/^(?:Basic|Bearer)\s+\S+$/i.test(authHeader)) {
throw new JmapAuthVerificationError('Invalid Authorization header', 400);
}
}
export async function verifyJmapAuth(serverUrl: string, authHeader: string): Promise<string> {
const normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
validateProxyAuthHeader(authHeader);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
try {
const response = await fetch(`${normalizedServerUrl}/.well-known/jmap`, {
method: 'GET',
headers: { Authorization: authHeader },
signal: controller.signal,
});
if (!response.ok) {
throw new JmapAuthVerificationError(
response.status === 401 || response.status === 403
? 'Authentication failed'
: 'Failed to verify JMAP session',
response.status === 401 || response.status === 403 ? 401 : 502,
);
}
const session = await response.json().catch(() => null) as { apiUrl?: unknown; accounts?: unknown } | null;
if (!session || typeof session.apiUrl !== 'string' || typeof session.accounts !== 'object' || session.accounts === null) {
throw new JmapAuthVerificationError('Invalid JMAP session response', 502);
}
return normalizedServerUrl;
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
throw error;
}
if (error instanceof Error && error.name === 'AbortError') {
throw new JmapAuthVerificationError('JMAP session verification timed out', 504);
}
throw new JmapAuthVerificationError('Failed to verify JMAP session', 502);
} finally {
clearTimeout(timeout);
}
}
+207
View File
@@ -0,0 +1,207 @@
import type { ContactCard, CalendarEvent, Calendar, PartialDate, Timestamp } from '@/lib/jmap/types';
import { getContactDisplayName } from '@/stores/contact-store';
import { format, eachYearOfInterval, parseISO } from 'date-fns';
export const BIRTHDAY_CALENDAR_ID = '__birthday-calendar__';
export const BIRTHDAY_CALENDAR_COLOR = '#eab308'; // Yellow
/**
* Virtual calendar object for the contact birthday calendar.
*/
export function createBirthdayCalendar(name?: string, color?: string): Calendar {
return {
id: BIRTHDAY_CALENDAR_ID,
name: name || 'Birthdays',
description: null,
color: color || BIRTHDAY_CALENDAR_COLOR,
sortOrder: 999,
isSubscribed: true,
isVisible: true,
isDefault: false,
includeInAvailability: 'none',
defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
myRights: {
mayReadFreeBusy: true,
mayReadItems: true,
mayWriteAll: false,
mayWriteOwn: false,
mayUpdatePrivate: false,
mayRSVP: false,
mayAdmin: false,
mayDelete: false,
},
};
}
/**
* Extract month and day from an AnniversaryDate.
* Returns null if the date cannot be parsed into month/day.
*/
function parseBirthdayDate(date: string | PartialDate | Timestamp): { month: number; day: number; year?: number } | null {
if (typeof date === 'string') {
// Could be ISO date string like "1990-05-15" or partial "--05-15"
if (date.startsWith('--')) {
// Partial date: --MM-DD
const match = date.match(/^--(\d{2})-(\d{2})$/);
if (match) {
return { month: parseInt(match[1], 10), day: parseInt(match[2], 10) };
}
return null;
}
try {
const parsed = parseISO(date);
if (!isNaN(parsed.getTime())) {
return {
month: parsed.getMonth() + 1,
day: parsed.getDate(),
year: parsed.getFullYear(),
};
}
} catch {
return null;
}
return null;
}
if ('utc' in date && date['@type'] === 'Timestamp') {
// Timestamp type
try {
const parsed = parseISO(date.utc);
if (!isNaN(parsed.getTime())) {
return {
month: parsed.getMonth() + 1,
day: parsed.getDate(),
year: parsed.getFullYear(),
};
}
} catch {
return null;
}
return null;
}
// PartialDate type
const partial = date as PartialDate;
if (partial.month && partial.day) {
return {
month: partial.month,
day: partial.day,
year: partial.year || undefined,
};
}
return null;
}
/**
* Generate virtual CalendarEvent objects from contacts that have birthday anniversaries.
* Events are generated for each year in the given date range.
*/
export function generateBirthdayEvents(
contacts: ContactCard[],
rangeStart: string,
rangeEnd: string,
): CalendarEvent[] {
const events: CalendarEvent[] = [];
const start = parseISO(rangeStart);
const end = parseISO(rangeEnd);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
return events;
}
const years = eachYearOfInterval({ start, end });
// Also include the end date's year if not already covered
const endYear = end.getFullYear();
if (!years.some(y => y.getFullYear() === endYear)) {
years.push(new Date(endYear, 0, 1));
}
for (const contact of contacts) {
if (!contact.anniversaries) continue;
for (const [key, anniversary] of Object.entries(contact.anniversaries)) {
if (anniversary.kind !== 'birth') continue;
const parsed = parseBirthdayDate(anniversary.date);
if (!parsed) continue;
const displayName = getContactDisplayName(contact);
if (!displayName) continue;
for (const yearDate of years) {
const year = yearDate.getFullYear();
// Handle Feb 29 in non-leap years: JS silently rolls over to Mar 1,
// but the ISO eventStart string would be invalid. Clamp to Feb 28.
let occMonth = parsed.month;
let occDay = parsed.day;
const occurrenceDate = new Date(year, occMonth - 1, occDay);
if (occurrenceDate.getMonth() !== occMonth - 1) {
occurrenceDate.setDate(0); // last day of previous month
occMonth = occurrenceDate.getMonth() + 1;
occDay = occurrenceDate.getDate();
}
if (occurrenceDate < start || occurrenceDate > end) continue;
const monthStr = String(occMonth).padStart(2, '0');
const dayStr = String(occDay).padStart(2, '0');
const eventStart = `${year}-${monthStr}-${dayStr}T00:00:00`;
const age = parsed.year ? year - parsed.year : undefined;
const ageText = age && age > 0 ? ` (${age})` : '';
const event: CalendarEvent = {
id: `birthday-${contact.id}-${key}-${year}`,
calendarIds: { [BIRTHDAY_CALENDAR_ID]: true },
isDraft: false,
isOrigin: false,
utcStart: null,
utcEnd: null,
'@type': 'Event',
uid: `birthday-${contact.id}-${key}`,
title: `🎂 ${displayName}${ageText}`,
description: '',
descriptionContentType: 'text/plain',
created: null,
updated: '',
sequence: 0,
start: eventStart,
duration: 'P1D',
timeZone: null,
showWithoutTime: true,
status: 'confirmed',
freeBusyStatus: 'free',
privacy: 'public',
color: null,
keywords: null,
categories: null,
locale: null,
replyTo: null,
organizerCalendarAddress: null,
participants: null,
mayInviteSelf: false,
mayInviteOthers: false,
hideAttendees: false,
recurrenceId: null,
recurrenceIdTimeZone: null,
recurrenceRules: null,
recurrenceOverrides: null,
excludedRecurrenceRules: null,
useDefaultAlerts: false,
alerts: null,
locations: null,
virtualLocations: null,
links: null,
relatedTo: null,
};
events.push(event);
}
}
}
return events;
}
+2 -3
View File
@@ -1,4 +1,5 @@
import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types'; import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
import { generateUUID } from '@/lib/utils';
export interface ParticipantInfo { export interface ParticipantInfo {
id: string; id: string;
@@ -104,9 +105,7 @@ export function buildParticipantMap(
): Record<string, Partial<CalendarParticipant>> { ): Record<string, Partial<CalendarParticipant>> {
const participants: Record<string, Partial<CalendarParticipant>> = {}; const participants: Record<string, Partial<CalendarParticipant>> = {};
const generateId = () => typeof crypto !== 'undefined' && crypto.randomUUID const generateId = () => generateUUID();
? crypto.randomUUID()
: `p-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
participants[generateId()] = { participants[generateId()] = {
'@type': 'Participant', '@type': 'Participant',
+57 -24
View File
@@ -1,25 +1,53 @@
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import type { DebugCategory } from '@/stores/settings-store';
/** /**
* Debug logger that respects the debugMode setting. * Check if debug logging is enabled, optionally for a specific category.
* When a category is provided, both debugMode AND that category must be enabled.
*/
function isEnabled(category?: DebugCategory): boolean {
const state = useSettingsStore.getState();
if (!state.debugMode) return false;
if (!category) return true;
return state.debugCategories?.[category] !== false;
}
/**
* Debug logger that respects the debugMode setting and category filters.
* Use this instead of console.log for conditional debug output. * Use this instead of console.log for conditional debug output.
*
* Each method accepts an optional category as the first argument.
* When a category is provided, the message only logs if that category is enabled
* in Settings > Advanced > Debug Categories.
*
* Usage:
* debug.log('calendar', 'Event created', event); // Only logs when 'calendar' category is on
* debug.log('Uncategorized message'); // Logs whenever debugMode is on
*/ */
export const debug = { export const debug = {
/** /**
* Log a debug message (only when debugMode is enabled) * Log a debug message (only when debugMode is enabled and category is active)
*/ */
log: (...args: unknown[]) => { log: (categoryOrMsg: DebugCategory | unknown, ...args: unknown[]) => {
if (useSettingsStore.getState().debugMode) { if (typeof categoryOrMsg === 'string' && isCategoryKey(categoryOrMsg)) {
console.log('[DEBUG]', ...args); if (isEnabled(categoryOrMsg)) {
console.log(`[DEBUG:${categoryOrMsg}]`, ...args);
}
} else if (isEnabled()) {
console.log('[DEBUG]', categoryOrMsg, ...args);
} }
}, },
/** /**
* Log a warning message (only when debugMode is enabled) * Log a warning message (only when debugMode is enabled and category is active)
*/ */
warn: (...args: unknown[]) => { warn: (categoryOrMsg: DebugCategory | unknown, ...args: unknown[]) => {
if (useSettingsStore.getState().debugMode) { if (typeof categoryOrMsg === 'string' && isCategoryKey(categoryOrMsg)) {
console.warn('[DEBUG]', ...args); if (isEnabled(categoryOrMsg)) {
console.warn(`[DEBUG:${categoryOrMsg}]`, ...args);
}
} else if (isEnabled()) {
console.warn('[DEBUG]', categoryOrMsg, ...args);
} }
}, },
@@ -31,11 +59,11 @@ export const debug = {
}, },
/** /**
* Start a collapsed console group (only when debugMode is enabled) * Start a collapsed console group (only when debugMode is enabled and category is active)
*/ */
group: (label: string) => { group: (label: string, category?: DebugCategory) => {
if (useSettingsStore.getState().debugMode) { if (isEnabled(category)) {
console.group(`[DEBUG] ${label}`); console.group(`[DEBUG${category ? ':' + category : ''}] ${label}`);
} }
}, },
@@ -43,35 +71,40 @@ export const debug = {
* End a console group (only when debugMode is enabled) * End a console group (only when debugMode is enabled)
*/ */
groupEnd: () => { groupEnd: () => {
if (useSettingsStore.getState().debugMode) { if (isEnabled()) {
console.groupEnd(); console.groupEnd();
} }
}, },
/** /**
* Start a performance timer (only when debugMode is enabled) * Start a performance timer (only when debugMode is enabled and category is active)
*/ */
time: (label: string) => { time: (label: string, category?: DebugCategory) => {
if (useSettingsStore.getState().debugMode) { if (isEnabled(category)) {
console.time(`[DEBUG] ${label}`); console.time(`[DEBUG${category ? ':' + category : ''}] ${label}`);
} }
}, },
/** /**
* End a performance timer (only when debugMode is enabled) * End a performance timer (only when debugMode is enabled)
*/ */
timeEnd: (label: string) => { timeEnd: (label: string, category?: DebugCategory) => {
if (useSettingsStore.getState().debugMode) { if (isEnabled(category)) {
console.timeEnd(`[DEBUG] ${label}`); console.timeEnd(`[DEBUG${category ? ':' + category : ''}] ${label}`);
} }
}, },
/** /**
* Log a table (only when debugMode is enabled) * Log a table (only when debugMode is enabled and category is active)
*/ */
table: (data: unknown) => { table: (data: unknown, category?: DebugCategory) => {
if (useSettingsStore.getState().debugMode) { if (isEnabled(category)) {
console.table(data); console.table(data);
} }
} }
}; };
const CATEGORY_KEYS = new Set<string>(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push', 'contacts']);
function isCategoryKey(value: string): value is DebugCategory {
return CATEGORY_KEYS.has(value);
}
+11
View File
@@ -481,6 +481,17 @@ export class DemoJMAPClient implements IJMAPClient {
async getAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; } async getAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
async getAllAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; } async getAllAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
async createAddressBook(name: string): Promise<AddressBook> {
const book: AddressBook = { id: `demo-book-${Date.now()}`, name };
this.data.addressBooks.push(book);
return book;
}
async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>): Promise<void> {
const book = this.data.addressBooks.find(b => b.id === addressBookId);
if (book) Object.assign(book, updates);
}
async getContacts(addressBookId?: string): Promise<ContactCard[]> { async getContacts(addressBookId?: string): Promise<ContactCard[]> {
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]); if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
return [...this.data.contacts]; return [...this.data.contacts];
+3 -2
View File
@@ -11,9 +11,10 @@ export const EMAIL_SANITIZE_CONFIG = {
ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'], ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
ALLOW_DATA_ATTR: false, ALLOW_DATA_ATTR: false,
FORCE_BODY: true, FORCE_BODY: true,
// Allow blob: URIs so authenticated inline images (CID) are not stripped // Allow blob: URIs so authenticated inline images (CID) are not stripped.
// data: is restricted to image/* MIME types to prevent SVG script injection.
// eslint-disable-next-line no-useless-escape // eslint-disable-next-line no-useless-escape
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob|data):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob):|data:image\/|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
FORBID_TAGS: [ FORBID_TAGS: [
'script', 'iframe', 'object', 'embed', 'form', 'script', 'iframe', 'object', 'embed', 'form',
'input', 'button', 'meta', 'link', 'base', 'input', 'button', 'meta', 'link', 'base',
+2
View File
@@ -178,6 +178,8 @@ export interface IJMAPClient {
getContactsAccountId(): string; getContactsAccountId(): string;
getAddressBooks(): Promise<AddressBook[]>; getAddressBooks(): Promise<AddressBook[]>;
getAllAddressBooks(): Promise<AddressBook[]>; getAllAddressBooks(): Promise<AddressBook[]>;
createAddressBook(name: string): Promise<AddressBook>;
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
getContacts(addressBookId?: string): Promise<ContactCard[]>; getContacts(addressBookId?: string): Promise<ContactCard[]>;
getAllContacts(): Promise<ContactCard[]>; getAllContacts(): Promise<ContactCard[]>;
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>; getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
+192 -91
View File
@@ -670,12 +670,12 @@ export class JMAPClient implements IJMAPClient {
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") { if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[]; const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
debug.log(`[JMAP Mailbox] getMailboxes returned ${rawMailboxes.length} mailboxes for account ${this.accountId}`); debug.log('jmap', `[JMAP Mailbox] getMailboxes returned ${rawMailboxes.length} mailboxes for account ${this.accountId}`);
// Warn if response might be truncated // Warn if response might be truncated
const maxObjects = this.getMaxObjectsInGet(); const maxObjects = this.getMaxObjectsInGet();
if (rawMailboxes.length >= maxObjects) { if (rawMailboxes.length >= maxObjects) {
debug.warn( debug.warn('jmap',
`[JMAP Mailbox] Response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` + `[JMAP Mailbox] Response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` +
`Some mailboxes may be missing — nested folders could appear orphaned at root level.` `Some mailboxes may be missing — nested folders could appear orphaned at root level.`
); );
@@ -685,7 +685,7 @@ export class JMAPClient implements IJMAPClient {
const returnedIds = new Set(rawMailboxes.map(mb => mb.id)); const returnedIds = new Set(rawMailboxes.map(mb => mb.id));
const missingParents = rawMailboxes.filter(mb => mb.parentId && !returnedIds.has(mb.parentId)); const missingParents = rawMailboxes.filter(mb => mb.parentId && !returnedIds.has(mb.parentId));
if (missingParents.length > 0) { if (missingParents.length > 0) {
debug.warn( debug.warn('jmap',
`[JMAP Mailbox] ${missingParents.length} mailbox(es) reference parentId not in response (will be orphaned):`, `[JMAP Mailbox] ${missingParents.length} mailbox(es) reference parentId not in response (will be orphaned):`,
missingParents.map(mb => ({ id: mb.id, name: mb.name, parentId: mb.parentId })) missingParents.map(mb => ({ id: mb.id, name: mb.name, parentId: mb.parentId }))
); );
@@ -755,12 +755,12 @@ export class JMAPClient implements IJMAPClient {
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") { if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[]; const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
debug.log(`[JMAP Mailbox] getAllMailboxes: account ${accountId} returned ${rawMailboxes.length} mailboxes (isPrimary: ${isPrimary})`); debug.log('jmap', `[JMAP Mailbox] getAllMailboxes: account ${accountId} returned ${rawMailboxes.length} mailboxes (isPrimary: ${isPrimary})`);
// Warn if response might be truncated // Warn if response might be truncated
const maxObjects = this.getMaxObjectsInGet(); const maxObjects = this.getMaxObjectsInGet();
if (rawMailboxes.length >= maxObjects) { if (rawMailboxes.length >= maxObjects) {
debug.warn( debug.warn('jmap',
`[JMAP Mailbox] Account ${accountId}: response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` + `[JMAP Mailbox] Account ${accountId}: response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` +
`Some mailboxes may be missing.` `Some mailboxes may be missing.`
); );
@@ -1320,7 +1320,10 @@ export class JMAPClient implements IJMAPClient {
const result = response.methodResponses?.[0]?.[1]; const result = response.methodResponses?.[0]?.[1];
if (result?.notDestroyed?.[mailboxId]) { if (result?.notDestroyed?.[mailboxId]) {
throw new Error(`Failed to delete mailbox: ${result.notDestroyed[mailboxId].type || 'unknown error'}`); const err = result.notDestroyed[mailboxId];
const error = new Error(err.description || `Failed to delete mailbox: ${err.type || 'unknown error'}`);
(error as Error & { jmapType?: string }).jmapType = err.type;
throw error;
} }
} }
@@ -1995,7 +1998,7 @@ export class JMAPClient implements IJMAPClient {
lines.push('END:VCALENDAR'); lines.push('END:VCALENDAR');
const icsContent = lines.join('\r\n') + '\r\n'; const icsContent = lines.join('\r\n') + '\r\n';
debug.log('[iMIP] Generated ICS:\n' + icsContent); debug.log('calendar', '[iMIP] Generated ICS:\n' + icsContent);
const statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {
ACCEPTED: 'Accepted', ACCEPTED: 'Accepted',
@@ -2005,7 +2008,7 @@ export class JMAPClient implements IJMAPClient {
const statusLabel = statusLabels[opts.status] || opts.status; const statusLabel = statusLabels[opts.status] || opts.status;
const subject = `${statusLabel}: ${opts.summary || 'Event'}`; const subject = `${statusLabel}: ${opts.summary || 'Event'}`;
debug.log('[iMIP] identityId:', finalIdentityId); debug.log('calendar', '[iMIP] identityId:', finalIdentityId);
const emailId = `imip-reply-${Date.now()}`; const emailId = `imip-reply-${Date.now()}`;
const emailCreate: Record<string, unknown> = { const emailCreate: Record<string, unknown> = {
@@ -2038,12 +2041,12 @@ export class JMAPClient implements IJMAPClient {
}, "1"], }, "1"],
]; ];
debug.log('[iMIP] Sending JMAP request with', methodCalls.length, 'method calls'); debug.log('calendar', '[iMIP] Sending JMAP request with', methodCalls.length, 'method calls');
debug.log('[iMIP] Email create payload:', JSON.stringify(emailCreate, null, 2)); debug.log('calendar', '[iMIP] Email create payload:', JSON.stringify(emailCreate, null, 2));
const response = await this.request(methodCalls); const response = await this.request(methodCalls);
debug.log('[iMIP] JMAP response:', JSON.stringify(response.methodResponses, null, 2)); debug.log('calendar', '[iMIP] JMAP response:', JSON.stringify(response.methodResponses, null, 2));
if (response.methodResponses) { if (response.methodResponses) {
for (const [methodName, result] of response.methodResponses) { for (const [methodName, result] of response.methodResponses) {
@@ -2058,7 +2061,7 @@ export class JMAPClient implements IJMAPClient {
} }
} }
} }
debug.log('[iMIP] sendImipReply completed successfully'); debug.log('calendar', '[iMIP] sendImipReply completed successfully');
} }
/** /**
@@ -2222,7 +2225,7 @@ export class JMAPClient implements IJMAPClient {
async sendImipCancellation(event: CalendarEvent): Promise<void> { async sendImipCancellation(event: CalendarEvent): Promise<void> {
if (!event.participants) return; if (!event.participants) return;
if (event.status && event.status !== 'cancelled') { if (event.status && event.status !== 'cancelled') {
debug.warn('sendImipCancellation called on non-cancelled event, status:', event.status); debug.warn('calendar', 'sendImipCancellation called on non-cancelled event, status:', event.status);
} }
const mailboxes = await this.getMailboxes(); const mailboxes = await this.getMailboxes();
@@ -2815,6 +2818,56 @@ export class JMAPClient implements IJMAPClient {
} }
} }
async createAddressBook(name: string): Promise<AddressBook> {
const accountId = this.getContactsAccountId();
const response = await this.request([
["AddressBook/set", {
accountId,
create: { "new-book": { name } },
}, "0"]
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "AddressBook/set") {
const result = response.methodResponses[0][1];
const created = result.created?.["new-book"];
if (created) {
return { id: created.id, name, ...created } as AddressBook;
}
const err = result.notCreated?.["new-book"];
throw new Error(err?.description || "Failed to create address book");
}
throw new Error("Failed to create address book");
}
async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void> {
const accountId = targetAccountId || this.getContactsAccountId();
// Only forward server-settable properties
const { name, description, sortOrder, isDefault, color } = updates as Record<string, unknown>;
const patch: Record<string, unknown> = {};
if (name !== undefined) patch.name = name;
if (description !== undefined) patch.description = description;
if (sortOrder !== undefined) patch.sortOrder = sortOrder;
if (isDefault !== undefined) patch.isDefault = isDefault;
if (color !== undefined) patch.color = color;
const response = await this.request([
["AddressBook/set", {
accountId,
update: { [addressBookId]: patch },
}, "0"]
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "AddressBook/set") {
const result = response.methodResponses[0][1];
if (result.notUpdated?.[addressBookId]) {
const error = result.notUpdated[addressBookId];
throw new Error(error.description || "Failed to update address book");
}
return;
}
throw new Error("Failed to update address book");
}
private async fetchPaginatedContacts( private async fetchPaginatedContacts(
accountId: string, accountId: string,
filter?: Record<string, unknown>, filter?: Record<string, unknown>,
@@ -3220,33 +3273,48 @@ export class JMAPClient implements IJMAPClient {
async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]> { async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]> {
const accountId = targetAccountId || this.getCalendarsAccountId(); const accountId = targetAccountId || this.getCalendarsAccountId();
const GET_BATCH_SIZE = this.getMaxObjectsInGet();
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 }; const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
if (calendarIds && calendarIds.length > 0) { if (calendarIds && calendarIds.length > 0) {
queryArgs.filter = { inCalendars: calendarIds }; queryArgs.filter = { inCalendars: calendarIds };
} }
const response = await this.request([ // First, query to get all IDs
const queryResponse = await this.request([
["CalendarEvent/query", queryArgs, "0"], ["CalendarEvent/query", queryArgs, "0"],
["CalendarEvent/get", {
accountId,
properties: [...CALENDAR_EVENT_PROPERTIES],
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
}, "1"]
], this.calendarUsing()); ], this.calendarUsing());
// Check for JMAP method-level errors // Check for JMAP method-level errors
if (response.methodResponses?.[0]?.[0] === "error") { if (queryResponse.methodResponses?.[0]?.[0] === "error") {
const error = response.methodResponses[0][1]; const error = queryResponse.methodResponses[0][1];
throw new Error(error?.description || error?.type || "CalendarEvent/query failed"); throw new Error(error?.description || error?.type || "CalendarEvent/query failed");
} }
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") { const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || [];
return ((response.methodResponses[1][1].list || []) as CalendarEvent[]) if (ids.length === 0) return [];
.filter((event) => !isTaskObject(event))
.map((event) => normalizeCalendarEventLike(event)); // Batch the /get calls to stay within server max-objects limit
const allEvents: CalendarEvent[] = [];
for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) {
const batchIds = ids.slice(i, i + GET_BATCH_SIZE);
const getResponse = await this.request([
["CalendarEvent/get", {
accountId,
properties: [...CALENDAR_EVENT_PROPERTIES],
ids: batchIds,
}, "0"]
], this.calendarUsing());
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
const events = (getResponse.methodResponses[0][1].list || []) as CalendarEvent[];
allEvents.push(...events);
}
} }
return [];
return allEvents
.filter((event) => !isTaskObject(event))
.map((event) => normalizeCalendarEventLike(event));
} }
async queryAllCalendarEvents( async queryAllCalendarEvents(
@@ -3311,21 +3379,42 @@ export class JMAPClient implements IJMAPClient {
queryArgs.sort = sort; queryArgs.sort = sort;
} }
const response = await this.request([ const GET_BATCH_SIZE = this.getMaxObjectsInGet();
// First, query to get IDs
const queryResponse = await this.request([
["CalendarEvent/query", queryArgs, "0"], ["CalendarEvent/query", queryArgs, "0"],
["CalendarEvent/get", {
accountId,
properties: [...CALENDAR_EVENT_PROPERTIES],
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
}, "1"]
], this.calendarUsing()); ], this.calendarUsing());
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") { if (queryResponse.methodResponses?.[0]?.[0] === "error") {
return ((response.methodResponses[1][1].list || []) as CalendarEvent[]) const error = queryResponse.methodResponses[0][1];
.filter((event) => !isTaskObject(event)) throw new Error(error?.description || error?.type || "CalendarEvent/query failed");
.map((event) => normalizeCalendarEventLike(event));
} }
return [];
const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || [];
if (ids.length === 0) return [];
// Batch the /get calls to stay within server max-objects limit
const allEvents: CalendarEvent[] = [];
for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) {
const batchIds = ids.slice(i, i + GET_BATCH_SIZE);
const getResponse = await this.request([
["CalendarEvent/get", {
accountId,
properties: [...CALENDAR_EVENT_PROPERTIES],
ids: batchIds,
}, "0"]
], this.calendarUsing());
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
const events = (getResponse.methodResponses[0][1].list || []) as CalendarEvent[];
allEvents.push(...events);
}
}
return allEvents
.filter((event) => !isTaskObject(event))
.map((event) => normalizeCalendarEventLike(event));
} catch (error) { } catch (error) {
console.error('Failed to query calendar events:', error); console.error('Failed to query calendar events:', error);
return []; return [];
@@ -3361,8 +3450,8 @@ export class JMAPClient implements IJMAPClient {
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent; const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent;
cleanRecurrenceRules(cleanEvent as unknown as Record<string, unknown>); cleanRecurrenceRules(cleanEvent as unknown as Record<string, unknown>);
debug.group('CalendarEvent/create'); debug.group('CalendarEvent/create', 'calendar');
debug.log('CalendarEvent/create outgoing payload', { debug.log('calendar', 'CalendarEvent/create outgoing payload', {
accountId, accountId,
sendSchedulingMessages, sendSchedulingMessages,
eventKeys: Object.keys(cleanEvent), eventKeys: Object.keys(cleanEvent),
@@ -3382,40 +3471,40 @@ export class JMAPClient implements IJMAPClient {
["CalendarEvent/set", setArgs, "0"] ["CalendarEvent/set", setArgs, "0"]
], this.calendarUsing()); ], this.calendarUsing());
debug.log('CalendarEvent/create raw set response', response.methodResponses?.[0]?.[1] || null); debug.log('calendar', 'CalendarEvent/create raw set response', response.methodResponses?.[0]?.[1] || null);
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
const result = response.methodResponses[0][1]; const result = response.methodResponses[0][1];
if (result.notCreated?.["new-event"]) { if (result.notCreated?.["new-event"]) {
const error = result.notCreated["new-event"]; const error = result.notCreated["new-event"];
debug.warn('CalendarEvent/create notCreated', error); debug.warn('calendar', 'CalendarEvent/create notCreated', error);
debug.warn('CalendarEvent/create invalid properties', error.properties); debug.warn('calendar', 'CalendarEvent/create invalid properties', error.properties);
debug.warn('CalendarEvent/create sent keys', Object.keys(cleanEvent)); debug.warn('calendar', 'CalendarEvent/create sent keys', Object.keys(cleanEvent));
debug.groupEnd(); debug.groupEnd();
throw new Error(error.description || "Failed to create calendar event"); throw new Error(error.description || "Failed to create calendar event");
} }
const createdId = result.created?.["new-event"]?.id; const createdId = result.created?.["new-event"]?.id;
debug.log('CalendarEvent/create server acknowledged created id', { debug.log('calendar', 'CalendarEvent/create server acknowledged created id', {
createdId, createdId,
created: result.created?.['new-event'] || null, created: result.created?.['new-event'] || null,
}); });
if (createdId) { if (createdId) {
const created = await this.getCalendarEvent(createdId, targetAccountId); const created = await this.getCalendarEvent(createdId, targetAccountId);
debug.log('CalendarEvent/create fetched created event', getCalendarEventDebugSnapshot(created)); debug.log('calendar', 'CalendarEvent/create fetched created event', getCalendarEventDebugSnapshot(created));
if (created?.uid) { if (created?.uid) {
try { try {
const verificationMatches = await this.queryCalendarEvents({ uid: created.uid }, undefined, undefined, targetAccountId); const verificationMatches = await this.queryCalendarEvents({ uid: created.uid }, undefined, undefined, targetAccountId);
debug.log('CalendarEvent/create verification query by uid', { debug.log('calendar', 'CalendarEvent/create verification query by uid', {
uid: created.uid, uid: created.uid,
matchCount: verificationMatches.length, matchCount: verificationMatches.length,
matches: verificationMatches.map((match) => getCalendarEventDebugSnapshot(match)), matches: verificationMatches.map((match) => getCalendarEventDebugSnapshot(match)),
}); });
} catch (verificationError) { } catch (verificationError) {
debug.warn('CalendarEvent/create verification query failed', verificationError); debug.warn('calendar', 'CalendarEvent/create verification query failed', verificationError);
} }
} }
@@ -3424,7 +3513,7 @@ export class JMAPClient implements IJMAPClient {
return created; return created;
} }
debug.warn('CalendarEvent/create server returned created id but CalendarEvent/get returned null', { debug.warn('calendar', 'CalendarEvent/create server returned created id but CalendarEvent/get returned null', {
createdId, createdId,
targetAccountId, targetAccountId,
}); });
@@ -3455,7 +3544,7 @@ export class JMAPClient implements IJMAPClient {
createMap[`new-${i}`] = clean; createMap[`new-${i}`] = clean;
} }
debug.log('CalendarEvent/batchCreate', { count: events.length, accountId }); debug.log('calendar', 'CalendarEvent/batchCreate', { count: events.length, accountId });
const response = await this.request([ const response = await this.request([
["CalendarEvent/set", { accountId, create: createMap }, "0"] ["CalendarEvent/set", { accountId, create: createMap }, "0"]
@@ -3471,7 +3560,7 @@ export class JMAPClient implements IJMAPClient {
if (result.created?.[key]?.id) { if (result.created?.[key]?.id) {
createdIds.push(result.created[key].id); createdIds.push(result.created[key].id);
} else if (result.notCreated?.[key]) { } else if (result.notCreated?.[key]) {
debug.warn(`CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]); debug.warn('calendar', `CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]);
failed.push(key); failed.push(key);
} }
} }
@@ -3496,7 +3585,7 @@ export class JMAPClient implements IJMAPClient {
createdEvents = list.map((e: CalendarEvent) => normalizeCalendarEventLike(e)); createdEvents = list.map((e: CalendarEvent) => normalizeCalendarEventLike(e));
} }
debug.log('CalendarEvent/batchCreate result', { debug.log('calendar', 'CalendarEvent/batchCreate result', {
requested: events.length, requested: events.length,
created: createdEvents.length, created: createdEvents.length,
failed: failed.length, failed: failed.length,
@@ -3513,8 +3602,8 @@ export class JMAPClient implements IJMAPClient {
): Promise<void> { ): Promise<void> {
const accountId = targetAccountId || this.getCalendarsAccountId(); const accountId = targetAccountId || this.getCalendarsAccountId();
// Strip client-only shared fields before sending to JMAP // Strip client-only and server-immutable fields before sending to JMAP
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent; const { id: _id, uid: _uid, '@type': _typ, created: _cr, updated: _up, sequence: _sq, isOrigin: _io, isDraft: _idr, originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent;
cleanRecurrenceRules(cleanUpdates as unknown as Record<string, unknown>); cleanRecurrenceRules(cleanUpdates as unknown as Record<string, unknown>);
const setArgs: Record<string, unknown> = { const setArgs: Record<string, unknown> = {
@@ -3527,7 +3616,7 @@ export class JMAPClient implements IJMAPClient {
setArgs.sendSchedulingMessages = sendSchedulingMessages; setArgs.sendSchedulingMessages = sendSchedulingMessages;
} }
debug.log('CalendarEvent/set update request', { eventId, accountId, cleanUpdateKeys: Object.keys(cleanUpdates), sendSchedulingMessages }); debug.log('calendar', 'CalendarEvent/set update request', { eventId, accountId, cleanUpdateKeys: Object.keys(cleanUpdates), sendSchedulingMessages });
const response = await this.request([ const response = await this.request([
["CalendarEvent/set", setArgs, "0"] ["CalendarEvent/set", setArgs, "0"]
@@ -3549,7 +3638,7 @@ export class JMAPClient implements IJMAPClient {
debug.error('CalendarEvent/set notUpdated', { eventId, error }); debug.error('CalendarEvent/set notUpdated', { eventId, error });
throw new Error(error.description || "Failed to update calendar event"); throw new Error(error.description || "Failed to update calendar event");
} }
debug.log('CalendarEvent/set update success', { eventId, updated: result.updated ? Object.keys(result.updated) : null }); debug.log('calendar', 'CalendarEvent/set update success', { eventId, updated: result.updated ? Object.keys(result.updated) : null });
return; return;
} }
@@ -3600,7 +3689,7 @@ export class JMAPClient implements IJMAPClient {
setArgs.sendSchedulingMessages = sendSchedulingMessages; setArgs.sendSchedulingMessages = sendSchedulingMessages;
} }
debug.log('CalendarEvent/set destroy request', { eventId, accountId, sendSchedulingMessages }); debug.log('calendar', 'CalendarEvent/set destroy request', { eventId, accountId, sendSchedulingMessages });
const response = await this.request([ const response = await this.request([
["CalendarEvent/set", setArgs, "0"] ["CalendarEvent/set", setArgs, "0"]
@@ -3622,7 +3711,7 @@ export class JMAPClient implements IJMAPClient {
debug.error('CalendarEvent/set notDestroyed', { eventId, error }); debug.error('CalendarEvent/set notDestroyed', { eventId, error });
throw new Error(error.description || "Failed to delete calendar event"); throw new Error(error.description || "Failed to delete calendar event");
} }
debug.log('CalendarEvent/set destroy success', { eventId, destroyed: result.destroyed }); debug.log('calendar', 'CalendarEvent/set destroy success', { eventId, destroyed: result.destroyed });
return; return;
} }
@@ -3654,8 +3743,8 @@ export class JMAPClient implements IJMAPClient {
async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> { async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
const accountId = targetAccountId || this.getCalendarsAccountId(); const accountId = targetAccountId || this.getCalendarsAccountId();
debug.group('CalendarTask/fetch'); debug.group('CalendarTask/fetch', 'tasks');
debug.log('CalendarTask/fetch start', { accountId, calendarIds: calendarIds || 'all' }); debug.log('tasks', 'CalendarTask/fetch start', { accountId, calendarIds: calendarIds || 'all' });
try { try {
// Strategy 1: query with types filter (JMAP spec compliant) // Strategy 1: query with types filter (JMAP spec compliant)
@@ -3664,7 +3753,7 @@ export class JMAPClient implements IJMAPClient {
filter.inCalendars = calendarIds; filter.inCalendars = calendarIds;
} }
debug.log('CalendarTask/fetch query filter', filter); debug.log('tasks', 'CalendarTask/fetch query filter', filter);
const response = await this.request([ const response = await this.request([
["CalendarEvent/query", { accountId, filter, limit: 1000 }, "0"], ["CalendarEvent/query", { accountId, filter, limit: 1000 }, "0"],
@@ -3678,13 +3767,13 @@ export class JMAPClient implements IJMAPClient {
const queryResponse = response.methodResponses?.[0]; const queryResponse = response.methodResponses?.[0];
const getResponse = response.methodResponses?.[1]; const getResponse = response.methodResponses?.[1];
debug.log('CalendarTask/fetch query method', queryResponse?.[0]); debug.log('tasks', 'CalendarTask/fetch query method', queryResponse?.[0]);
debug.log('CalendarTask/fetch query result', queryResponse?.[1]); debug.log('tasks', 'CalendarTask/fetch query result', queryResponse?.[1]);
if (queryResponse?.[0] === "error") { if (queryResponse?.[0] === "error") {
debug.warn('CalendarTask/fetch types filter not supported, falling back to full scan', queryResponse[1]); debug.warn('tasks', 'CalendarTask/fetch types filter not supported, falling back to full scan', queryResponse[1]);
const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId); const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId);
debug.log('CalendarTask/fetch fallback returned', tasks.length, 'tasks'); debug.log('tasks', 'CalendarTask/fetch fallback returned', tasks.length, 'tasks');
debug.groupEnd(); debug.groupEnd();
return tasks; return tasks;
} }
@@ -3692,22 +3781,22 @@ export class JMAPClient implements IJMAPClient {
if (getResponse?.[0] === "CalendarEvent/get") { if (getResponse?.[0] === "CalendarEvent/get") {
const list = (getResponse[1].list || []) as CalendarTask[]; const list = (getResponse[1].list || []) as CalendarTask[];
const queryIds = queryResponse?.[1]?.ids || []; const queryIds = queryResponse?.[1]?.ids || [];
debug.log('CalendarTask/fetch query returned', queryIds.length, 'ids:', queryIds); debug.log('calendar', 'CalendarTask/fetch query returned', queryIds.length, 'ids:', queryIds);
debug.log('CalendarTask/fetch get returned', list.length, 'objects'); debug.log('calendar', 'CalendarTask/fetch get returned', list.length, 'objects');
// If the types filter returned 0 results, the server may have silently // If the types filter returned 0 results, the server may have silently
// ignored it (e.g. Stalwart with CalDAV-created VTODOs). Fall back to // ignored it (e.g. Stalwart with CalDAV-created VTODOs). Fall back to
// a full scan so we can detect tasks by their properties. // a full scan so we can detect tasks by their properties.
if (queryIds.length === 0) { if (queryIds.length === 0) {
debug.warn('CalendarTask/fetch types filter returned 0 results, falling back to full scan'); debug.warn('tasks', 'CalendarTask/fetch types filter returned 0 results, falling back to full scan');
const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId); const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId);
debug.log('CalendarTask/fetch fallback returned', tasks.length, 'tasks'); debug.log('tasks', 'CalendarTask/fetch fallback returned', tasks.length, 'tasks');
debug.groupEnd(); debug.groupEnd();
return tasks; return tasks;
} }
list.forEach((task, i) => { list.forEach((task, i) => {
debug.log(`CalendarTask/fetch [${i}]`, { debug.log('tasks', `CalendarTask/fetch [${i}]`, {
id: task.id, id: task.id,
uid: task.uid, uid: task.uid,
'@type': task['@type'], '@type': task['@type'],
@@ -3724,12 +3813,12 @@ export class JMAPClient implements IJMAPClient {
...task, ...task,
'@type': 'Task' as const, '@type': 'Task' as const,
})); }));
debug.log('CalendarTask/fetch complete,', results.length, 'tasks'); debug.log('tasks', 'CalendarTask/fetch complete,', results.length, 'tasks');
debug.groupEnd(); debug.groupEnd();
return results; return results;
} }
debug.warn('CalendarTask/fetch unexpected response shape', response.methodResponses); debug.warn('tasks', 'CalendarTask/fetch unexpected response shape', response.methodResponses);
debug.groupEnd(); debug.groupEnd();
return []; return [];
} catch (error) { } catch (error) {
@@ -3746,7 +3835,7 @@ export class JMAPClient implements IJMAPClient {
*/ */
private async getCalendarTasksFallback(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> { private async getCalendarTasksFallback(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
const accountId = targetAccountId || this.getCalendarsAccountId(); const accountId = targetAccountId || this.getCalendarsAccountId();
debug.log('CalendarTask/fallback using CalendarEvent/get ids:null to fetch all objects'); debug.log('calendar', 'CalendarTask/fallback using CalendarEvent/get ids:null to fetch all objects');
// CalendarEvent/get with ids:null returns ALL calendar objects regardless of @type // CalendarEvent/get with ids:null returns ALL calendar objects regardless of @type
const response = await this.request([ const response = await this.request([
@@ -3758,12 +3847,12 @@ export class JMAPClient implements IJMAPClient {
], this.calendarUsing()); ], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] !== "CalendarEvent/get") { if (response.methodResponses?.[0]?.[0] !== "CalendarEvent/get") {
debug.warn('CalendarTask/fallback unexpected response', response.methodResponses?.[0]); debug.warn('calendar', 'CalendarTask/fallback unexpected response', response.methodResponses?.[0]);
return []; return [];
} }
const allObjects = (response.methodResponses[0][1].list || []) as Record<string, unknown>[]; const allObjects = (response.methodResponses[0][1].list || []) as Record<string, unknown>[];
debug.log('CalendarTask/fallback total calendar objects returned:', allObjects.length); debug.log('tasks', 'CalendarTask/fallback total calendar objects returned:', allObjects.length);
const tasks: CalendarTask[] = []; const tasks: CalendarTask[] = [];
const calendarIdSet = calendarIds ? new Set(calendarIds) : null; const calendarIdSet = calendarIds ? new Set(calendarIds) : null;
@@ -3779,7 +3868,7 @@ export class JMAPClient implements IJMAPClient {
|| ('percentComplete' in obj); || ('percentComplete' in obj);
const isCalDavTask = type !== 'Event' && hasTaskFields; const isCalDavTask = type !== 'Event' && hasTaskFields;
debug.log('CalendarTask/fallback scan', { debug.log('tasks', 'CalendarTask/fallback scan', {
id: obj.id, id: obj.id,
'@type': type, '@type': type,
title: obj.title, title: obj.title,
@@ -3796,7 +3885,7 @@ export class JMAPClient implements IJMAPClient {
if (calendarIdSet) { if (calendarIdSet) {
const objCalendarIds = obj.calendarIds as Record<string, boolean> | undefined; const objCalendarIds = obj.calendarIds as Record<string, boolean> | undefined;
if (objCalendarIds && !Object.keys(objCalendarIds).some(id => calendarIdSet.has(id))) { if (objCalendarIds && !Object.keys(objCalendarIds).some(id => calendarIdSet.has(id))) {
debug.log('CalendarTask/fallback skipping task (not in requested calendars)', obj.id); debug.log('tasks', 'CalendarTask/fallback skipping task (not in requested calendars)', obj.id);
return; return;
} }
} }
@@ -3804,9 +3893,9 @@ export class JMAPClient implements IJMAPClient {
tasks.push({ ...obj, '@type': 'Task' as const } as CalendarTask); tasks.push({ ...obj, '@type': 'Task' as const } as CalendarTask);
}); });
debug.log('CalendarTask/fallback detected', tasks.length, 'tasks'); debug.log('tasks', 'CalendarTask/fallback detected', tasks.length, 'tasks');
tasks.forEach((t, i) => { tasks.forEach((t, i) => {
debug.log(`CalendarTask/fallback [${i}]`, { debug.log('tasks', `CalendarTask/fallback [${i}]`, {
id: t.id, id: t.id,
uid: t.uid, uid: t.uid,
title: t.title, title: t.title,
@@ -3825,9 +3914,9 @@ export class JMAPClient implements IJMAPClient {
const { '@type': _type, ...taskData } = task; const { '@type': _type, ...taskData } = task;
const cleanTask = { ...taskData, '@type': 'Task' }; const cleanTask = { ...taskData, '@type': 'Task' };
debug.group('CalendarTask/create'); debug.group('CalendarTask/create', 'tasks');
debug.log('CalendarTask/create accountId', accountId); debug.log('tasks', 'CalendarTask/create accountId', accountId);
debug.log('CalendarTask/create outgoing payload', cleanTask); debug.log('tasks', 'CalendarTask/create outgoing payload', cleanTask);
const response = await this.request([ const response = await this.request([
["CalendarEvent/set", { ["CalendarEvent/set", {
@@ -3838,27 +3927,27 @@ export class JMAPClient implements IJMAPClient {
], this.calendarUsing()); ], this.calendarUsing());
const result = response.methodResponses?.[0]?.[1]; const result = response.methodResponses?.[0]?.[1];
debug.log('CalendarTask/create raw set response', result); debug.log('tasks', 'CalendarTask/create raw set response', result);
if (result?.notCreated?.["new-task"]) { if (result?.notCreated?.["new-task"]) {
const error = result.notCreated["new-task"]; const error = result.notCreated["new-task"];
debug.warn('CalendarTask/create REJECTED by server', error); debug.warn('tasks', 'CalendarTask/create REJECTED by server', error);
debug.groupEnd(); debug.groupEnd();
throw new Error(error.description || "Failed to create task"); throw new Error(error.description || "Failed to create task");
} }
const createdId = result?.created?.["new-task"]?.id; const createdId = result?.created?.["new-task"]?.id;
const serverCreated = result?.created?.["new-task"]; const serverCreated = result?.created?.["new-task"];
debug.log('CalendarTask/create server acknowledged', { createdId, serverCreated }); debug.log('tasks', 'CalendarTask/create server acknowledged', { createdId, serverCreated });
if (!createdId) { if (!createdId) {
debug.warn('CalendarTask/create no id in server response'); debug.warn('tasks', 'CalendarTask/create no id in server response');
debug.groupEnd(); debug.groupEnd();
throw new Error("Failed to create task — no id returned"); throw new Error("Failed to create task — no id returned");
} }
// Fetch back with task-specific properties // Fetch back with task-specific properties
debug.log('CalendarTask/create re-fetching with task properties', { createdId, properties: [...CALENDAR_TASK_PROPERTIES] }); debug.log('calendar', 'CalendarTask/create re-fetching with task properties', { createdId, properties: [...CALENDAR_TASK_PROPERTIES] });
const getResponse = await this.request([ const getResponse = await this.request([
["CalendarEvent/get", { ["CalendarEvent/get", {
accountId, accountId,
@@ -3870,10 +3959,10 @@ export class JMAPClient implements IJMAPClient {
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") { if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
const list = getResponse.methodResponses[0][1].list || []; const list = getResponse.methodResponses[0][1].list || [];
const notFound = getResponse.methodResponses[0][1].notFound || []; const notFound = getResponse.methodResponses[0][1].notFound || [];
debug.log('CalendarTask/create get response', { found: list.length, notFound }); debug.log('calendar', 'CalendarTask/create get response', { found: list.length, notFound });
if (list[0]) { if (list[0]) {
const created = { ...list[0], '@type': 'Task' as const } as CalendarTask; const created = { ...list[0], '@type': 'Task' as const } as CalendarTask;
debug.log('CalendarTask/create final task object', { debug.log('tasks', 'CalendarTask/create final task object', {
id: created.id, id: created.id,
uid: created.uid, uid: created.uid,
'@type': created['@type'], '@type': created['@type'],
@@ -3889,7 +3978,7 @@ export class JMAPClient implements IJMAPClient {
} }
} }
debug.warn('CalendarTask/create re-fetch returned nothing for id', createdId); debug.warn('tasks', 'CalendarTask/create re-fetch returned nothing for id', createdId);
debug.groupEnd(); debug.groupEnd();
throw new Error("Failed to fetch created task"); throw new Error("Failed to fetch created task");
} }
@@ -4058,7 +4147,9 @@ export class JMAPClient implements IJMAPClient {
async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode> { async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode> {
const accountId = this.getFilesAccountId(); const accountId = this.getFilesAccountId();
const fileProps: Record<string, unknown> = { name, type, blobId, size }; //fall back for long MIME types
const safeType = type.length > 30 ? 'application/octet-stream' : type;
const fileProps: Record<string, unknown> = { name, type: safeType, blobId, size };
if (parentId !== null) { if (parentId !== null) {
fileProps.parentId = parentId; fileProps.parentId = parentId;
} }
@@ -4112,6 +4203,7 @@ export class JMAPClient implements IJMAPClient {
[["FileNode/set", { [["FileNode/set", {
accountId, accountId,
destroy: ids, destroy: ids,
onDestroyRemoveChildren: true,
}, "fns0"]], }, "fns0"]],
this.fileUsing(), this.fileUsing(),
); );
@@ -4120,9 +4212,18 @@ export class JMAPClient implements IJMAPClient {
if (!result || result[0] === "error") { if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/set destroy failed"); throw new Error(result?.[1]?.description || "FileNode/set destroy failed");
} }
const notDestroyedMap: Record<string, { type?: string; description?: string }> = result[1].notDestroyed || {};
const notDestroyedIds = Object.keys(notDestroyedMap);
if (notDestroyedIds.length > 0) {
const firstError = notDestroyedMap[notDestroyedIds[0]];
throw new Error(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`);
}
return { return {
destroyed: result[1].destroyed || [], destroyed: result[1].destroyed || [],
notDestroyed: result[1].notDestroyed ? Object.keys(result[1].notDestroyed) : [], notDestroyed: [],
}; };
} }
+4 -2
View File
@@ -203,12 +203,14 @@ export interface ContactCard {
} }
export interface ContactName { export interface ContactName {
components: NameComponent[]; components?: NameComponent[];
isOrdered?: boolean; isOrdered?: boolean;
full?: string;
defaultSeparator?: string;
} }
export interface NameComponent { export interface NameComponent {
kind: 'given' | 'surname' | 'prefix' | 'suffix' | 'additional' | 'separator' | 'credential'; kind: 'given' | 'surname' | 'prefix' | 'suffix' | 'additional' | 'separator' | 'credential' | 'title' | 'middle' | 'given2' | 'surname2' | 'generation';
value: string; value: string;
} }
+2 -2
View File
@@ -31,7 +31,7 @@ function playFile(file: string) {
const audio = new Audio(file); const audio = new Audio(file);
audio.volume = 0.3; audio.volume = 0.3;
audio.play().catch((e) => { audio.play().catch((e) => {
debug.log('Could not play audio file, falling back to beep:', e); debug.log('push', 'Could not play audio file, falling back to beep:', e);
playBeep(); playBeep();
}); });
} }
@@ -47,6 +47,6 @@ export function playNotificationSound(sound?: NotificationSoundChoice) {
playBeep(); playBeep();
} }
} catch (e) { } catch (e) {
debug.log('Could not play notification sound:', e); debug.log('push', 'Could not play notification sound:', e);
} }
} }
+4 -1
View File
@@ -1,9 +1,12 @@
const COOKIE_SAME_SITE = (process.env.COOKIE_SAME_SITE || 'lax') as 'lax' | 'none' | 'strict'; const COOKIE_SAME_SITE = (process.env.COOKIE_SAME_SITE || 'lax') as 'lax' | 'none' | 'strict';
const COOKIE_SECURE = process.env.COOKIE_SECURE !== undefined
? process.env.COOKIE_SECURE === 'true'
: (COOKIE_SAME_SITE === 'none' || process.env.NODE_ENV === 'production');
export function getCookieOptions() { export function getCookieOptions() {
return { return {
httpOnly: true, httpOnly: true,
secure: COOKIE_SAME_SITE === 'none' || process.env.NODE_ENV === 'production', secure: COOKIE_SECURE,
sameSite: COOKIE_SAME_SITE, sameSite: COOKIE_SAME_SITE,
path: '/', path: '/',
maxAge: 30 * 24 * 60 * 60, maxAge: 30 * 24 * 60 * 60,
+2 -1
View File
@@ -1,8 +1,9 @@
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery'; import { discoverOAuth } from '@/lib/oauth/discovery';
import type { OAuthMetadata } from '@/lib/oauth/discovery'; import type { OAuthMetadata } from '@/lib/oauth/discovery';
import { readFileEnv } from '@/lib/read-file-env';
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || ''; const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
export function getRequiredConfig() { export function getRequiredConfig() {
const clientId = process.env.OAUTH_CLIENT_ID; const clientId = process.env.OAUTH_CLIENT_ID;
+3 -1
View File
@@ -1,4 +1,6 @@
export const OAUTH_SCOPES = 'openid email profile'; const DEFAULT_SCOPES = 'openid email profile';
const EXTRA_SCOPES = process.env.OAUTH_EXTRA_SCOPES || '';
export const OAUTH_SCOPES = process.env.OAUTH_SCOPES || (EXTRA_SCOPES ? `${DEFAULT_SCOPES} ${EXTRA_SCOPES}`.trim() : DEFAULT_SCOPES);
export const REFRESH_TOKEN_COOKIE = 'jmap_rt'; export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */ /** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */

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