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

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+21
View File
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import { getLocale } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import "./globals.css";
const geistSans = Geist({
@@ -20,6 +22,15 @@ export async function generateMetadata(): Promise<Metadata> {
return {
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
description: "Minimalist webmail client using JMAP protocol",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
},
formatDetection: {
telephone: false,
},
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
};
}
@@ -36,6 +47,14 @@ export default async function RootLayout({
return (
<html lang={locale} suppressHydrationWarning>
<head>
<meta name="theme-color" content="#ffffff" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-title"
content={process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail"}
/>
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
{parentOrigin && (
<meta name="parent-origin" content={parentOrigin} />
)}
@@ -63,7 +82,9 @@ export default async function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ServiceWorkerRegistration />
{children}
<PWAInstallPrompt />
</body>
</html>
);
@@ -2,13 +2,14 @@
import { useState, useRef, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2 } from "lucide-react";
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useTaskStore } from "@/stores/task-store";
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { toast } from "@/stores/toast-store";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
@@ -164,6 +165,9 @@ export function CalendarSidebarPanel({
)}
</>
)}
{cal.id === BIRTHDAY_CALENDAR_ID && (
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)}
</button>
{/* Subscription context menu on right-click */}
+1 -1
View File
@@ -88,7 +88,7 @@ function formatDurationDisplay(minutes: number): string {
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
if (!event.alerts) return null;
const first = Object.values(event.alerts)[0];
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
if (!first || !first.trigger || first.trigger["@type"] !== "OffsetTrigger") return null;
const offset = first.trigger.offset;
if (offset === "PT0S") return t("alerts.at_time");
const minMatch = offset.match(/-?PT?(\d+)M$/);
+2 -3
View File
@@ -20,6 +20,7 @@ import {
} from "@/lib/calendar-participants";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils";
export interface PendingEventPreview {
start: Date;
@@ -402,9 +403,7 @@ export function EventModal({
if (!event || !onDuplicate) return;
const start = getEventStartDate(event);
const newStart = addDays(start, 1);
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const newUid = generateUUID();
const data: Partial<CalendarEvent> = {
uid: newUid,
title: event.title,
+5 -19
View File
@@ -304,11 +304,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
if (contact?.addressBookIds) {
const ids = Object.keys(contact.addressBookIds).filter(k => contact.addressBookIds[k]);
if (ids.length > 0) {
// For shared contacts, the addressBookIds uses the original (non-namespaced) id
// but we need the namespaced id to match addressBooks entries
if (contact.isShared && contact.accountId) {
return `${contact.accountId}:${ids[0]}`;
}
// addressBookIds are already namespaced for shared contacts (e.g. "accountId:bookId")
// so we can use them directly to match addressBook entries
return ids[0];
}
}
@@ -914,7 +911,6 @@ function CategoryComboBox({
}) {
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Parse current keywords from comma-separated string
@@ -949,17 +945,6 @@ function CategoryComboBox({
onChange(next);
}, [currentKeywords, onChange]);
// Close dropdown on outside click
useEffect(() => {
if (!isOpen) return;
const handler = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [isOpen]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
@@ -973,7 +958,7 @@ function CategoryComboBox({
};
return (
<div ref={wrapperRef} className="relative">
<div className="relative">
{/* Keyword badges */}
{currentKeywords.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
@@ -1001,6 +986,7 @@ function CategoryComboBox({
value={inputValue}
onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }}
onFocus={() => setIsOpen(true)}
onBlur={() => setIsOpen(false)}
onKeyDown={handleKeyDown}
placeholder={currentKeywords.length === 0 ? placeholder : ""}
/>
@@ -1008,7 +994,7 @@ function CategoryComboBox({
{/* Dropdown */}
{isOpen && (suggestions.length > 0 || canAddNew) && (
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1">
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1" onMouseDown={(e) => e.preventDefault()}>
{suggestions.map(kw => (
<button
key={kw}
+86 -3
View File
@@ -2,7 +2,8 @@
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2 } from "lucide-react";
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
@@ -25,6 +26,8 @@ interface ContactsSidebarProps {
onDeleteGroup?: (groupId: string) => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
onRenameAddressBook?: (addressBook: AddressBook) => void;
onRenameKeyword?: (keyword: string) => void;
className?: string;
}
@@ -58,10 +61,15 @@ export function ContactsSidebar({
onDeleteGroup,
onDropContacts,
onDropContactsToCategory,
onRenameAddressBook,
onRenameKeyword,
className,
}: ContactsSidebarProps) {
const t = useTranslations("contacts");
const router = useRouter();
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
const { contextMenu: bookContextMenu, openContextMenu: openBookContextMenu, closeContextMenu: closeBookContextMenu, menuRef: bookMenuRef } = useContextMenu<AddressBook>();
const { contextMenu: keywordContextMenu, openContextMenu: openKeywordContextMenu, closeContextMenu: closeKeywordContextMenu, menuRef: keywordMenuRef } = useContextMenu<string>();
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
const [showMenu, setShowMenu] = useState(false);
@@ -246,9 +254,10 @@ export function ContactsSidebar({
{/* My Address Books */}
{personalBooks.length > 0 && (
<div className="mt-2">
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection("addressBooks")}
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
className="flex items-center gap-1 flex-1 text-left"
>
{collapsed.addressBooks ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
@@ -259,6 +268,18 @@ export function ContactsSidebar({
{t("address_books.title")}
</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
title={t("address_books.manage")}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
</div>
{!collapsed.addressBooks && personalBooks.map((book) => (
<AddressBookItem
key={book.id}
@@ -267,6 +288,7 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
@@ -362,6 +384,7 @@ export function ContactsSidebar({
isActive={isActive}
onSelect={() => onSelectCategory({ keyword })}
onDropContacts={onDropContactsToCategory}
onContextMenu={onRenameKeyword ? (e) => openKeywordContextMenu(e, keyword) : undefined}
/>
);
})}
@@ -372,9 +395,10 @@ export function ContactsSidebar({
{/* Shared accounts with address books */}
{sharedBookGroups.map((group) => (
<div key={group.accountId} className="mt-2">
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection(`shared-${group.accountId}`)}
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
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" />
@@ -386,6 +410,18 @@ export function ContactsSidebar({
{t("address_books.shared_prefix", { name: group.accountName })}
</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
title={t("address_books.manage")}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
</div>
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
<AddressBookItem
key={book.id}
@@ -394,12 +430,53 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
))}
</div>
{/* Address book context menu */}
{bookContextMenu.data && onRenameAddressBook && (
<ContextMenu
ref={bookMenuRef}
isOpen={bookContextMenu.isOpen}
position={bookContextMenu.position}
onClose={closeBookContextMenu}
>
<ContextMenuItem
icon={Pencil}
label={t("address_books.rename")}
onClick={() => {
const book = bookContextMenu.data!;
closeBookContextMenu();
onRenameAddressBook(book);
}}
/>
</ContextMenu>
)}
{/* Keyword (category) context menu */}
{keywordContextMenu.data && onRenameKeyword && (
<ContextMenu
ref={keywordMenuRef}
isOpen={keywordContextMenu.isOpen}
position={keywordContextMenu.position}
onClose={closeKeywordContextMenu}
>
<ContextMenuItem
icon={Pencil}
label={t("rename_category")}
onClick={() => {
const kw = keywordContextMenu.data!;
closeKeywordContextMenu();
onRenameKeyword(kw);
}}
/>
</ContextMenu>
)}
{/* Group context menu */}
{groupContextMenu.data && (
<ContextMenu
@@ -438,12 +515,14 @@ function CategoryItem({
isActive,
onSelect,
onDropContacts,
onContextMenu,
}: {
keyword: string;
count: number;
isActive: boolean;
onSelect: () => void;
onDropContacts?: (contactIds: string[], keyword: string) => void;
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) {
const [isDragOver, setIsDragOver] = useState(false);
@@ -476,6 +555,7 @@ function CategoryItem({
return (
<button
onClick={onSelect}
onContextMenu={onContextMenu}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -503,12 +583,14 @@ function AddressBookItem({
contactCount,
onSelect,
onDropContacts,
onContextMenu,
}: {
book: AddressBook;
isActive: boolean;
contactCount: number;
onSelect: () => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) {
const [isDragOver, setIsDragOver] = useState(false);
@@ -541,6 +623,7 @@ function AddressBookItem({
return (
<button
onClick={onSelect}
onContextMenu={onContextMenu}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
+22 -21
View File
@@ -754,6 +754,9 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
{/* Content */}
<div className="px-4 py-3 space-y-2.5">
<div className="lg:flex lg:gap-6">
{/* Left: Event info */}
<div className="lg:flex-1 space-y-2.5 min-w-0">
{/* Event title */}
{summary?.title && (
<div className="flex items-start justify-between gap-2">
@@ -772,7 +775,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
)}
{/* 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 && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
@@ -788,15 +791,30 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
<span>{summary.location}</span>
</div>
)}
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
{summary?.organizer && (
<span className="flex items-center gap-1.5">
<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>{t('attendees', { count: summary.attendeeCount })}</span>
<span className="text-sm text-muted-foreground">{t('attendees', { count: summary.attendeeCount })}</span>
)}
</div>
</div>
{/* Right: Info & actor messages on large screens */}
<div className="lg:flex-shrink-0 lg:text-right lg:max-w-xs mt-2.5 lg:mt-0 space-y-1">
{bannerInfo && (
<p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
)}
{actorMessage && (
<p className="text-xs text-muted-foreground">{actorMessage}</p>
)}
{actorSummary?.participationComment && (
<p className="text-xs text-muted-foreground italic">
{t('actor_note', { comment: actorSummary.participationComment })}
</p>
)}
</div>
</div>
@@ -840,23 +858,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
</div>
)}
{/* Info text */}
{bannerInfo && (
<p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
)}
{/* Actor message */}
{actorMessage && (
<p className="text-xs text-muted-foreground">{actorMessage}</p>
)}
{/* Actor comment */}
{actorSummary?.participationComment && (
<p className="text-xs text-muted-foreground italic">
{t('actor_note', { comment: actorSummary.participationComment })}
</p>
)}
{/* Proposed changes */}
{proposedChanges.length > 0 && (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs">
+122 -16
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 */
function htmlToPlainText(html: string): string {
const tmp = document.createElement('div');
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || '';
const doc = new DOMParser().parseFromString(html, 'text/html');
return doc.body.textContent || '';
}
export interface ComposerDraftData {
@@ -104,6 +103,8 @@ export function EmailComposer({
const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
// Initialize with reply/forward data if provided
const getInitialTo = () => {
@@ -213,6 +214,8 @@ export function EmailComposer({
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
const [smimePassphraseInput, setSmimePassphraseInput] = useState('');
const [smimePassphraseError, setSmimePassphraseError] = useState('');
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
const saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate,
@@ -226,6 +229,12 @@ export function EmailComposer({
restoreFocus: true,
});
const attachmentWarningRef = useFocusTrap({
isActive: showAttachmentWarning,
onEscape: () => setShowAttachmentWarning(false),
restoreFocus: true,
});
const { client } = useAuthStore();
const identities = useIdentityStore((s) => s.identities);
const primaryIdentity = identities[0] ?? null;
@@ -332,6 +341,17 @@ export function EmailComposer({
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, []);
// Auto-focus the To field when composing a new email or forwarding
useEffect(() => {
if (mode === 'forward' || mode === 'compose') {
// Small delay to ensure the input is rendered
const timer = setTimeout(() => {
toInputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}
}, [mode]);
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
@@ -339,10 +359,26 @@ export function EmailComposer({
const toInputRef = useRef<HTMLInputElement>(null);
const ccInputRef = useRef<HTMLInputElement>(null);
const bccInputRef = useRef<HTMLInputElement>(null);
const subjectInputRef = useRef<HTMLInputElement>(null);
const bodyRef = useRef<HTMLTextAreaElement>(null);
const editorContainerRef = useRef<HTMLDivElement>(null);
const toDropdownRef = useRef<HTMLDivElement>(null);
const ccDropdownRef = useRef<HTMLDivElement>(null);
const bccDropdownRef = useRef<HTMLDivElement>(null);
const focusSubject = useCallback(() => {
subjectInputRef.current?.focus();
}, []);
const focusBody = useCallback(() => {
if (plainTextMode) {
bodyRef.current?.focus();
} else {
const proseMirror = editorContainerRef.current?.querySelector('.ProseMirror') as HTMLElement | null;
proseMirror?.focus();
}
}, [plainTextMode]);
const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
if (autocompleteTimeoutRef.current) {
clearTimeout(autocompleteTimeoutRef.current);
@@ -506,17 +542,18 @@ export function EmailComposer({
}
}, [client, t]);
const handleImageUpload = useCallback(async (file: File): Promise<string | null> => {
if (!client) return null;
try {
const { blobId } = await client.uploadBlob(file);
return await client.fetchBlobAsObjectUrl(blobId, file.name, file.type);
} catch (error) {
debug.error(`Failed to upload inline image ${file.name}:`, error);
const handleImageUpload = useCallback((file: File): Promise<string | null> => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
reader.onerror = () => {
debug.error(`Failed to read inline image ${file.name}`);
toast.error(t('upload_failed', { filename: file.name }));
return null;
}
}, [client, t]);
resolve(null);
};
reader.readAsDataURL(file);
});
}, [t]);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files) return;
@@ -696,7 +733,7 @@ export function EmailComposer({
return undefined;
};
const handleSend = async () => {
const handleSend = async (skipAttachmentCheck = false) => {
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
@@ -715,6 +752,21 @@ export function EmailComposer({
return;
}
// Attachment reminder check
if (!skipAttachmentCheck && attachmentReminderEnabled) {
const hasAttachments = attachments.some(att => att.blobId && !att.uploading && !att.error);
if (!hasAttachments) {
const bodyText = htmlToPlainText(body);
const searchText = `${subject} ${bodyText}`.toLowerCase();
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
if (matched) {
setAttachmentWarningKeyword(matched);
setShowAttachmentWarning(true);
return;
}
}
}
let finalDraftId = draftId;
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
@@ -975,7 +1027,7 @@ export function EmailComposer({
</div>
{/* Mobile: send button in header */}
<Button
onClick={handleSend}
onClick={() => handleSend()}
disabled={!canSend}
title={getSendTooltip()}
size="sm"
@@ -1071,6 +1123,7 @@ export function EmailComposer({
onInsertAutocomplete={insertAutocomplete}
validationError={validationErrors.to}
validationMessage={t('validation.recipient_required')}
onTab={focusSubject}
/>
<div className="flex gap-0.5 shrink-0">
<Button
@@ -1140,6 +1193,7 @@ export function EmailComposer({
<div className="flex items-center gap-2 px-4 py-2.5">
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
<Input
ref={subjectInputRef}
type="text"
placeholder={t('subject_placeholder')}
value={subject}
@@ -1147,6 +1201,12 @@ export function EmailComposer({
setSubject(e.target.value);
if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false }));
}}
onKeyDown={(e) => {
if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
focusBody();
}
}}
className={cn(
"flex-1 border-0 focus-visible:ring-0 h-8 px-0 text-sm",
validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400"
@@ -1159,6 +1219,7 @@ export function EmailComposer({
{/* Body */}
{plainTextMode ? (
<textarea
ref={bodyRef}
value={body}
onChange={(e) => {
setBody(e.target.value);
@@ -1173,6 +1234,7 @@ export function EmailComposer({
aria-invalid={validationErrors.body || undefined}
/>
) : (
<div ref={editorContainerRef}>
<RichTextEditor
content={body}
onChange={(html) => {
@@ -1183,6 +1245,7 @@ export function EmailComposer({
placeholder={t('body_placeholder')}
hasError={validationErrors.body}
/>
</div>
)}
{plainTextMode ? (
@@ -1329,7 +1392,7 @@ export function EmailComposer({
{t('discard')}
</button>
<Button
onClick={handleSend}
onClick={() => handleSend()}
disabled={!canSend}
title={getSendTooltip()}
className="hidden md:inline-flex"
@@ -1429,6 +1492,36 @@ export function EmailComposer({
</div>
)}
{showAttachmentWarning && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
onClick={() => setShowAttachmentWarning(false)}
>
<div
ref={attachmentWarningRef}
role="alertdialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
>
<div className="p-6">
<h2 className="text-lg font-semibold text-foreground">{t('forgot_attachment.title')}</h2>
<p className="mt-2 text-sm text-muted-foreground">
{t('forgot_attachment.message', { keyword: attachmentWarningKeyword })}
</p>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
{t('forgot_attachment.back')}
</Button>
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true); }}>
{t('forgot_attachment.send_anyway')}
</Button>
</div>
</div>
</div>
)}
{showCloseDialog && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
@@ -1514,6 +1607,7 @@ function RecipientChipInput({
onInsertAutocomplete,
validationError,
validationMessage,
onTab,
}: {
value: string;
onChange: (value: string) => void;
@@ -1530,6 +1624,7 @@ function RecipientChipInput({
onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void;
validationError?: boolean;
validationMessage?: string;
onTab?: () => void;
}) {
const allParts = value.split(',').map(s => s.trim()).filter(Boolean);
const hasTrailingComma = value.trimEnd().endsWith(',');
@@ -1563,7 +1658,18 @@ function RecipientChipInput({
if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) {
if (e.key !== 'Tab') e.preventDefault();
commitCurrentInput();
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;
}
+17 -13
View File
@@ -89,17 +89,18 @@ const getMailboxIcon = (role?: string) => {
}
};
// Get current label/color from email keywords (supports both $label: and legacy $color:)
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
if (!keywords) return null;
// Get all active label/color tag IDs from email keywords
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
return key.startsWith("$label:")
? key.slice("$label:".length)
: key.slice("$color:".length);
tags.push(
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
);
}
}
return null;
return tags;
};
export function EmailContextMenu({
@@ -137,7 +138,7 @@ export function EmailContextMenu({
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isDraft = email.keywords?.['$draft'] === true;
const currentColor = getCurrentColor(email.keywords);
const currentColors = getCurrentColors(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
@@ -306,24 +307,27 @@ export function EmailContextMenu({
{/* Set tag submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
{colorOptions.map((option) => (
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
role="menuitem"
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
className={cn(
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
currentColor === option.value && "bg-accent font-medium"
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 && (
{isActive && (
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
)}
</button>
))}
{currentColor && (
);
})}
{currentColors.length > 0 && (
<>
<ContextMenuSeparator />
<ContextMenuItem
+1 -1
View File
@@ -94,7 +94,7 @@ export function EmailHoverActions({
onToggleStar?.();
break;
case "markRead":
onMarkAsRead?.(!isUnread);
onMarkAsRead?.(isUnread);
break;
case "archive":
onArchive?.();
+20 -13
View File
@@ -15,7 +15,7 @@ import { useLongPress } from "@/hooks/use-long-press";
import { useUIStore } from "@/stores/ui-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { EmailHoverActions } from "./email-hover-actions";
import { getEmailColorTag } from "@/lib/thread-utils";
import { getEmailColorTags } from "@/lib/thread-utils";
interface EmailListItemProps {
email: Email;
@@ -32,7 +32,7 @@ interface EmailListItemProps {
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
const t = useTranslations('email_viewer');
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -44,13 +44,18 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const isImportant = email.keywords?.["$important"];
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0];
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tag using keyword definitions from settings
const colorTagId = getEmailColorTag(email.keywords);
const keywordDef = colorTagId ? emailKeywords.find(k => k.id === colorTagId) : null;
// Resolve color tags using keyword definitions from settings
const colorTagIds = getEmailColorTags(email.keywords);
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
// Use first tag for background coloring
const keywordDef = keywordDefs[0] ?? null;
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
// Drag and drop functionality
@@ -196,7 +201,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
</>
)}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
{keywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
<span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
@@ -246,15 +253,15 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && (
<span className={cn(
{keywordDefs.map((kd) => (
<span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{kd.label}
</span>
)}
))}
<span className={cn(
"text-xs tabular-nums",
isUnread
+116 -68
View File
@@ -8,7 +8,7 @@ import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/ema
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime } from "@/lib/utils";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
import {
Reply,
@@ -193,16 +193,17 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
return 'Attachment';
};
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
if (!keywords) return null;
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
return key.startsWith("$label:")
? key.slice("$label:".length)
: key.slice("$color:".length);
tags.push(
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
);
}
}
return null;
return tags;
};
// Helper function to format recipients with contextual display
@@ -887,6 +888,9 @@ export function EmailViewer({
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
@@ -933,7 +937,8 @@ export function EmailViewer({
const moveMenuRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
const currentColor = getCurrentColor(email?.keywords);
const currentColors = getCurrentColors(email?.keywords);
const currentColor = currentColors[0] ?? null;
// S/MIME state
const [smimeStatus, setSmimeStatus] = useState<SmimeStatus | null>(null);
@@ -1152,9 +1157,27 @@ export function EmailViewer({
}
);
const autoMarkedEmailRef = useRef<string | null>(null);
// Reset auto-mark tracking when email changes
useEffect(() => {
autoMarkedEmailRef.current = null;
}, [email?.id]);
useEffect(() => {
// Mark as read when email is viewed, respecting the delay setting
if (!email || email.keywords?.$seen || !onMarkAsRead) {
if (!email || !onMarkAsRead) {
return;
}
// Already read — record that so manual unread toggle won't re-trigger auto-mark
if (email.keywords?.$seen) {
autoMarkedEmailRef.current = email.id;
return;
}
// Don't re-trigger if we already auto-marked this email (user may have toggled it back to unread)
if (autoMarkedEmailRef.current === email.id) {
return;
}
@@ -1167,17 +1190,18 @@ export function EmailViewer({
// Instant mark
if (markAsReadDelay === 0) {
autoMarkedEmailRef.current = email.id;
onMarkAsRead(email.id, true);
return;
}
// Delayed mark
const timeout = setTimeout(() => {
autoMarkedEmailRef.current = email.id;
onMarkAsRead(email.id, true);
}, markAsReadDelay);
return () => clearTimeout(timeout);
// eslint-disable-next-line react-hooks/exhaustive-deps -- email?.id changes when email changes, which is the intended trigger
}, [email?.id, email?.keywords?.$seen, onMarkAsRead]);
// Reset external content permission and quick reply when email changes
@@ -1820,12 +1844,12 @@ export function EmailViewer({
const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type));
if (!tnefAtt?.blobId) {
debug.log('TNEF: No winmail.dat attachment found in email', email?.id);
debug.log('email', 'TNEF: No winmail.dat attachment found in email', email?.id);
return;
}
debug.group('TNEF Processing');
debug.log('Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
debug.group('TNEF Processing', 'email');
debug.log('email', 'Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
// Check if the email already has a usable HTML body with real content
// Outlook often forwards TNEF emails with an HTML body that's just Word
@@ -1835,46 +1859,46 @@ export function EmailViewer({
let hasRealHtmlBody = !!htmlValue;
if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) {
hasRealHtmlBody = false;
debug.log('TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body');
debug.log('email', 'TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body');
}
if (hasRealHtmlBody) {
debug.log('TNEF: Email has real HTML body, will extract attachments only');
debug.log('email', 'TNEF: Email has real HTML body, will extract attachments only');
} else {
debug.log('TNEF: Email has no usable HTML body, proceeding with full TNEF extraction');
debug.log('email', 'TNEF: Email has no usable HTML body, proceeding with full TNEF extraction');
}
let cancelled = false;
async function processTnef() {
try {
debug.time('TNEF fetch blob');
debug.time('TNEF fetch blob', 'email');
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
debug.timeEnd('TNEF fetch blob');
debug.log('TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
debug.timeEnd('TNEF fetch blob', 'email');
debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
if (cancelled) {
debug.log('TNEF: Processing cancelled after fetch');
debug.log('email', 'TNEF: Processing cancelled after fetch');
debug.groupEnd();
return;
}
if (blobBytes.byteLength === 0) {
debug.warn('TNEF: Fetched blob is empty (0 bytes)');
debug.warn('email', 'TNEF: Fetched blob is empty (0 bytes)');
debug.groupEnd();
return;
}
const tnefData = new Uint8Array(blobBytes);
debug.time('TNEF parse');
debug.time('TNEF parse', 'email');
const parsed = parseTnef(tnefData);
debug.timeEnd('TNEF parse');
debug.timeEnd('TNEF parse', 'email');
if (cancelled) {
debug.log('TNEF: Processing cancelled after parse');
debug.log('email', 'TNEF: Processing cancelled after parse');
debug.groupEnd();
return;
}
debug.log('TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
debug.log('email', 'TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
if (parsed.htmlBody && !hasRealHtmlBody) {
setTnefHtml(parsed.htmlBody);
@@ -1884,11 +1908,11 @@ export function EmailViewer({
}
if (parsed.attachments.length > 0) {
setTnefAttachments(parsed.attachments);
debug.log('TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', '));
debug.log('email', 'TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', '));
}
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
debug.warn('TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
}
debug.groupEnd();
@@ -1926,13 +1950,13 @@ export function EmailViewer({
const hasRealText = !!textValue;
if (hasRealHtml || hasRealText) {
debug.log('Embedded RFC822: Outer email has real body content, not unwrapping');
debug.log('email', 'Embedded RFC822: Outer email has real body content, not unwrapping');
return;
}
debug.group('Embedded RFC822 Unwrapping');
debug.log('Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
debug.log('Outer email body is empty, will unwrap embedded email');
debug.group('Embedded RFC822 Unwrapping', 'email');
debug.log('email', 'Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
debug.log('email', 'Outer email body is empty, will unwrap embedded email');
let cancelled = false;
@@ -1941,7 +1965,7 @@ export function EmailViewer({
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
if (cancelled) { debug.groupEnd(); return; }
if (blobBytes.byteLength === 0) {
debug.warn('Embedded RFC822: Fetched blob is empty');
debug.warn('email', 'Embedded RFC822: Fetched blob is empty');
debug.groupEnd();
return;
}
@@ -1951,7 +1975,7 @@ export function EmailViewer({
const parsed = await parser.parse(new Uint8Array(blobBytes));
if (cancelled) { debug.groupEnd(); return; }
debug.log('Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
debug.log('email', 'Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
', attachments:', parsed.attachments?.length ?? 0);
@@ -1963,7 +1987,7 @@ export function EmailViewer({
}
if (parsed.attachments && parsed.attachments.length > 0) {
setEmbeddedEmailAttachments(parsed.attachments as PostalMimeAttachment[]);
debug.log('Embedded RFC822 attachments:', parsed.attachments.map(
debug.log('email', 'Embedded RFC822 attachments:', parsed.attachments.map(
a => (a.filename || 'unnamed') + ' (' + a.mimeType + ')'
).join(', '));
}
@@ -2290,9 +2314,11 @@ export function EmailViewer({
// Use shared sanitization config as base (more secure)
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
// Check if sender is trusted
// Check if sender is trusted (localStorage list or address book)
const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false;
const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
// Block external content based on policy:
// 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted
@@ -2399,7 +2425,7 @@ export function EmailViewer({
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
isHtml: false
};
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, cidBlobUrls]);
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, isTrustedAddressBookSender, trustedSendersAddressBook, cidBlobUrls]);
// Override email content with S/MIME decrypted content when available
const effectiveEmailContent = useMemo(() => {
@@ -3036,43 +3062,51 @@ export function EmailViewer({
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
className={cn(
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
currentColor && "bg-muted/50"
currentColors.length > 0 && "bg-muted/50"
)}
title={t('set_color')}
>
{(() => {
const kw = currentColor ? emailKeywords.find(k => k.id === currentColor) : null;
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
return dotClass ? (
{currentColors.length > 0 ? (
<>
<span className={cn("w-3 h-3 rounded-full", dotClass)} />
{showToolbarLabels && <span className="text-xs font-medium text-foreground">{kw!.label}</span>}
<span className="flex items-center gap-0.5">
{currentColors.slice(0, 3).map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId);
return kw ? <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot)} /> : null;
})}
</span>
{showToolbarLabels && currentColors.length === 1 && (
<span className="text-xs font-medium text-foreground">
{emailKeywords.find(k => k.id === currentColors[0])?.label}
</span>
)}
</>
) : (
<>
<Tag className="w-4 h-4 text-muted-foreground" />
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
</>
);
})()}
)}
</button>
{tagMenuOpen && (
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
{colorOptions.map((option) => (
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
currentColor === option.value && "bg-accent font-medium"
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" />}
{isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
</button>
))}
{currentColor && (
);
})}
{currentColors.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<button
@@ -3280,21 +3314,24 @@ export function EmailViewer({
</button>
{moreMenuSub === 'tag' && (
<div className="absolute right-full top-0 mr-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
{colorOptions.map((option) => (
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
currentColor === option.value && "bg-accent font-medium"
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" />}
{isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
</button>
))}
{currentColor && (
);
})}
{currentColors.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<button
@@ -3470,21 +3507,24 @@ export function EmailViewer({
<>
<div className="h-px bg-border my-1" />
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
{colorOptions.map((option) => (
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
className={cn(
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
currentColor === option.value && "bg-accent font-medium"
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" />}
{isActive && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
</button>
))}
{currentColor && (
);
})}
{currentColors.length > 0 && (
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
@@ -3630,14 +3670,18 @@ export function EmailViewer({
)} />
</button>
)}
{/* Color tag dot */}
{currentColor && (() => {
const kw = emailKeywords.find(k => k.id === currentColor);
{/* Color tag dots */}
{currentColors.length > 0 && (
<span className="flex items-center gap-0.5">
{currentColors.map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId);
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
return dotClass ? (
<span className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
) : null;
})()}
})}
</span>
)}
{isImportant && (
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
{t('important')}
@@ -4507,7 +4551,7 @@ export function EmailViewer({
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
hasCalendarInvitation) && (
<div className="border-b border-border bg-muted/30 isolate">
<div className="max-w-4xl mx-auto px-6 py-1.5">
<div className="max-w-6xl mx-auto px-6 py-1.5">
<div className="flex flex-col gap-3 isolate">
{/* External Content Controls */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
@@ -4526,7 +4570,11 @@ export function EmailViewer({
onClick={() => {
const senderEmail = email.from?.[0]?.email;
if (senderEmail) {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
setAllowExternalContent(true);
}
}}
@@ -4874,7 +4922,7 @@ export function EmailViewer({
if (client && supportsSync) {
createContact(client, contactData).then(() => toast.success('Contact added'));
} else {
addLocalContact({ id: `local-${crypto.randomUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
toast.success('Contact added');
}
}}
+12
View File
@@ -93,6 +93,18 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
return () => document.removeEventListener("keydown", handler);
}, [isOpen]);
// Close on scroll so the popover does not float while content moves.
useEffect(() => {
if (!isOpen) return;
const handler = () => handleClose();
document.addEventListener("scroll", handler, true);
window.addEventListener("resize", handler);
return () => {
document.removeEventListener("scroll", handler, true);
window.removeEventListener("resize", handler);
};
}, [isOpen]);
const handleViewContact = () => {
if (onViewContact) {
onViewContact(contact ?? null, email);
+1
View File
@@ -118,6 +118,7 @@ export function RichTextEditor({
);
if (imageFiles.length === 0) return false;
event.preventDefault();
event.stopPropagation();
for (const file of imageFiles) {
upload(file).then((url) => {
if (url) {
+7 -1
View File
@@ -55,7 +55,13 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
// Signature status
if (status.isSigned) {
if (status.signatureValid === true) {
if (status.signerEmailMatch === false) {
if (status.selfSigned) {
items.push({
icon: <AlertTriangle className="w-4 h-4" />,
text: t('status_signed_self_signed'),
variant: 'warning',
});
} else if (status.signerEmailMatch === false) {
items.push({
icon: <AlertTriangle className="w-4 h-4" />,
text: t('status_signed_mismatch'),
+12 -1
View File
@@ -31,6 +31,7 @@ import {
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { isFilePreviewable } from "@/lib/file-preview";
@@ -84,6 +85,10 @@ export function ThreadConversationView({
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const { client } = useAuthStore();
// Track which emails are expanded (most recent by default)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
@@ -164,7 +169,9 @@ export function ThreadConversationView({
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
{emails.map((email, index) => {
const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false;
const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
return (
<EmailCard
key={email.id}
@@ -175,7 +182,11 @@ export function ThreadConversationView({
onToggleExpanded={() => toggleExpanded(email.id)}
onAllowExternal={() => toggleAllowExternal(email.id)}
onTrustSender={senderEmail ? () => {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
toggleAllowExternal(email.id);
} : 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 { useUIStore } from "@/stores/ui-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 { useLongPress } from "@/hooks/use-long-press";
import { ThreadEmailItem } from "./thread-email-item";
@@ -55,8 +55,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0];
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -64,9 +67,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color and keyword definition from keyword definitions if not passed directly
const tagId = getEmailColorTag(email.keywords);
const resolvedKeywordDef = tagId ? emailKeywords.find(k => k.id === tagId) : null;
// Resolve color tags using keyword definitions
const tagIds = getEmailColorTags(email.keywords);
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
const resolvedColorTag = (() => {
if (colorTag) return colorTag;
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" />}
{resolvedKeywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || 'bg-gray-400')} />}
{resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
<span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
@@ -252,15 +258,15 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{resolvedKeywordDef && (
<span className={cn(
{resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
{resolvedKeywordDef.label}
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{kd.label}
</span>
)}
))}
<span className={cn(
"text-xs tabular-nums",
isUnread
@@ -339,7 +345,16 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = 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({
email: latestEmail,
@@ -522,8 +537,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{!isFocusedMailLayout && density !== 'extra-compact' && (
<Avatar
name={latestEmail.from?.[0]?.name}
email={latestEmail.from?.[0]?.email}
name={avatarPerson?.name}
email={avatarPerson?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
@@ -537,7 +552,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
'w-32 shrink-0 truncate text-sm lg:w-44',
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
)}>
{participantNames.join(', ')}
{displayNames.join(', ')}
</span>
<span
className={cn(
@@ -572,7 +587,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</>
)}
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
{keywordDef && (
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
)}
<span className={cn(
'text-xs tabular-nums',
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
@@ -591,7 +608,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{participantNames.join(", ")}
{displayNames.join(", ")}
</span>
<span
className={cn(
+1 -1
View File
@@ -837,7 +837,7 @@ export function FileBrowser({
return (
<div
ref={containerRef}
className="flex flex-col h-full"
className="flex flex-col flex-1 min-h-0"
onClick={handleContainerClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
+1
View File
@@ -223,6 +223,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
{!loading && !error && fileType === "pdf" && objectUrl && (
<iframe
src={objectUrl}
sandbox="allow-same-origin"
className="w-full max-w-5xl h-full rounded-lg bg-white"
title={name}
/>
+2 -2
View File
@@ -16,7 +16,7 @@ import type {
FilterActionType,
} from "@/lib/jmap/sieve-types";
import type { Mailbox } from "@/lib/jmap/types";
import { buildMailboxTree, flattenMailboxTree, type MailboxNode } from "@/lib/utils";
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
interface FilterRuleModalProps {
rule?: FilterRule;
@@ -109,7 +109,7 @@ export function FilterRuleModal({
}
onSave({
id: rule?.id || crypto.randomUUID(),
id: rule?.id || generateUUID(),
name: trimmedName,
enabled: rule?.enabled ?? true,
matchType,
@@ -14,6 +14,10 @@ function useSyncIdentities() {
const syncIdentities = useAuthStore((state) => state.syncIdentities);
return syncIdentities;
}
function useRefreshIdentities() {
return useAuthStore((state) => state.refreshIdentities);
}
import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { toast } from '@/stores/toast-store';
import { useFocusTrap } from '@/hooks/use-focus-trap';
@@ -49,6 +53,15 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary);
const syncIdentities = useSyncIdentities();
const refreshIdentitiesFromServer = useRefreshIdentities();
// Refresh identities from server whenever the modal is opened
useEffect(() => {
if (isOpen) {
refreshIdentitiesFromServer();
}
}, [isOpen, refreshIdentitiesFromServer]);
const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
+5 -8
View File
@@ -17,6 +17,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getInitials } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot";
@@ -169,6 +170,7 @@ export function NavigationRail({
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
@@ -218,13 +220,8 @@ export function NavigationRail({
useEffect(() => {
let cancelled = false;
const { client } = useAuthStore.getState();
if (!client) return;
const headers: Record<string, string> = {
'Authorization': client.getAuthHeader(),
'X-JMAP-Server-URL': client.getServerUrl(),
'X-JMAP-Username': client.getUsername(),
};
const headers = getActiveAccountSlotHeaders();
if (!headers['X-JMAP-Cookie-Slot']) return;
fetch('/api/admin/stalwart-check', { headers })
.then(res => res.json())
.then(data => {
@@ -246,7 +243,7 @@ export function NavigationRail({
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
{ 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");
+2 -2
View File
@@ -4,7 +4,7 @@ import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight } from 'lucide-react';
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn, generateUUID } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { IconPicker } from './icon-picker';
@@ -207,7 +207,7 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
});
const handleCreate = useCallback((data: SidebarAppFormData) => {
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const id = `app-${generateUUID()}`;
addSidebarApp({ id, ...data });
setIsCreating(false);
}, [addSidebarApp]);
-5
View File
@@ -18,7 +18,6 @@ import {
ChevronDown,
Folder,
FolderOpen,
Users,
User,
Palmtree,
Settings,
@@ -59,10 +58,6 @@ interface SidebarProps {
const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean, isShared?: boolean, id?: string) => {
const lowerName = name?.toLowerCase() || "";
if (id === 'shared-folders-root') {
return isExpanded ? FolderOpen : Users;
}
if (id?.startsWith('shared-account-')) {
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 frMessages from '@/locales/fr/common.json';
import jaMessages from '@/locales/ja/common.json';
import koMessages from '@/locales/ko/common.json';
import esMessages from '@/locales/es/common.json';
import itMessages from '@/locales/it/common.json';
import deMessages from '@/locales/de/common.json';
import lvMessages from '@/locales/lv/common.json';
import nlMessages from '@/locales/nl/common.json';
import plMessages from '@/locales/pl/common.json';
import ptMessages from '@/locales/pt/common.json';
import ruMessages from '@/locales/ru/common.json';
import zhMessages from '@/locales/zh/common.json';
// Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = {
en: enMessages,
fr: frMessages,
ja: jaMessages,
ko: koMessages,
es: esMessages,
it: itMessages,
de: deMessages,
lv: lvMessages,
nl: nlMessages,
pl: plMessages,
pt: ptMessages,
ru: ruMessages,
zh: zhMessages,
};
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
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={displayName || t('display_name.placeholder')}
className="w-48"
/>
<Button
@@ -523,7 +524,7 @@ export function AccountSecuritySettings() {
if (isStalwart === false) {
return (
<SettingsSection title={t('title')} description={t('description')}>
<p className="text-sm text-muted-foreground py-4">{t('not_available')}</p>
<div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: t('not_available') }} />
</SettingsSection>
);
}
@@ -0,0 +1,247 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Book, Pencil, Share2, Tag } from "lucide-react";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
import { SettingsSection } from "./settings-section";
import { cn } from "@/lib/utils";
import type { AddressBook } from "@/lib/jmap/types";
function AddressBookEditRow({
initial,
onSave,
onCancel,
isLoading,
}: {
initial: string;
onSave: (name: string) => void;
onCancel: () => void;
isLoading: boolean;
}) {
const t = useTranslations("contacts.address_books");
const tCal = useTranslations("calendar.management");
const [name, setName] = useState(initial);
const isValid = name.trim().length > 0;
return (
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name_label")}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && isValid) onSave(name.trim());
if (e.key === "Escape") onCancel();
}}
className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
disabled={isLoading}
/>
</div>
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => isValid && onSave(name.trim())}
disabled={isLoading || !isValid}
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{tCal("save")}
</button>
<button
onClick={onCancel}
disabled={isLoading}
className="px-3 py-1.5 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{tCal("cancel")}
</button>
</div>
</div>
);
}
export function AddressBookManagementSettings() {
const t = useTranslations("contacts.address_books");
const tContacts = useTranslations("contacts");
const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore();
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, renameKeyword } = useContactStore();
const [editingId, setEditingId] = useState<string | null>(null);
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (client && addressBooks.length === 0) {
fetchAddressBooks(client);
}
}, [client, addressBooks.length, fetchAddressBooks]);
const handleUpdate = async (book: AddressBook, newName: string) => {
if (!client) return;
setIsLoading(true);
try {
await renameAddressBook(client, book, newName);
setEditingId(null);
toast.success(t("renamed"));
} catch {
toast.error(t("rename_failed"));
} finally {
setIsLoading(false);
}
};
// Group: personal first, then by shared account
const personal = addressBooks.filter((b) => !b.isShared);
const sharedGroups = new Map<string, { accountName: string; books: AddressBook[] }>();
for (const book of addressBooks) {
if (!book.isShared || !book.accountId) continue;
const key = book.accountId;
const existing = sharedGroups.get(key);
if (existing) existing.books.push(book);
else sharedGroups.set(key, { accountName: book.accountName || book.accountId, books: [book] });
}
const renderBook = (book: AddressBook) => {
if (editingId === book.id) {
return (
<AddressBookEditRow
key={book.id}
initial={book.name}
onSave={(name) => handleUpdate(book, name)}
onCancel={() => setEditingId(null)}
isLoading={isLoading}
/>
);
}
const canRename = !book.isShared || book.myRights?.mayWrite !== false;
return (
<div
key={book.id}
className={cn(
"flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
)}
>
<Book className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{book.name}</span>
</div>
{book.isDefault && (
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{t("default")}
</span>
)}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{canRename && (
<button
type="button"
onClick={() => setEditingId(book.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t("rename")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
};
// Collect keywords with counts
const keywordCounts: Record<string, number> = {};
for (const c of contacts) {
if (c.kind === "group" || !c.keywords) continue;
for (const [kw, active] of Object.entries(c.keywords)) {
if (active) keywordCounts[kw] = (keywordCounts[kw] || 0) + 1;
}
}
const sortedKeywords = Object.entries(keywordCounts).sort(([a], [b]) => a.localeCompare(b));
const handleRenameKeyword = async (oldKw: string, newKw: string) => {
setIsLoading(true);
try {
await renameKeyword(supportsSync && client ? client : null, oldKw, newKw);
setEditingKeyword(null);
toast.success(tContacts("category_renamed"));
} catch {
toast.error(tContacts("category_rename_failed"));
} finally {
setIsLoading(false);
}
};
return (
<>
<SettingsSection title={tSettings("manage_title")} description={tSettings("manage_description")}>
<div className="space-y-2">
{personal.map(renderBook)}
{Array.from(sharedGroups.entries()).map(([accountId, group]) => (
<div key={accountId} className="mt-4 space-y-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
{t("shared_prefix", { name: group.accountName })}
</h4>
{group.books.map(renderBook)}
</div>
))}
{addressBooks.length === 0 && (
<p className="text-sm text-muted-foreground py-2">{tSettings("no_address_books")}</p>
)}
</div>
</SettingsSection>
<div className="mt-8">
<SettingsSection title={tSettings("categories_title")} description={tSettings("categories_description")}>
<div className="space-y-2">
{sortedKeywords.map(([keyword, count]) => {
if (editingKeyword === keyword) {
return (
<AddressBookEditRow
key={keyword}
initial={keyword}
onSave={(name) => handleRenameKeyword(keyword, name)}
onCancel={() => setEditingKeyword(null)}
isLoading={isLoading}
/>
);
}
return (
<div
key={keyword}
className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
>
<Tag className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{keyword}</span>
</div>
<span className="text-xs text-muted-foreground tabular-nums">{count}</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => setEditingKeyword(keyword)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={tContacts("rename_category")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
{sortedKeywords.length === 0 && (
<p className="text-sm text-muted-foreground py-2">{tSettings("no_categories")}</p>
)}
</div>
</SettingsSection>
</div>
</>
);
}
+84 -1
View File
@@ -7,16 +7,36 @@ import { useConfig } from '@/hooks/use-config';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { usePolicyStore } from '@/stores/policy-store';
import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
import { ExternalLink } from 'lucide-react';
import { SpamSiegeGame } from './spam-siege-game';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
export function AdvancedSettings() {
const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common');
const { debugMode, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
const { debugMode, debugCategories, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
useSettingsStore();
const { settingsSyncEnabled } = useConfig();
const [showResetConfirm, setShowResetConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const [showGame, setShowGame] = useState(false);
const logoClickCount = useRef(0);
const logoClickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleLogoClick = () => {
logoClickCount.current++;
if (logoClickTimer.current) clearTimeout(logoClickTimer.current);
if (logoClickCount.current >= 3) {
logoClickCount.current = 0;
setShowGame(true);
} else {
logoClickTimer.current = setTimeout(() => { logoClickCount.current = 0; }, 2000);
}
};
const handleExport = () => {
const settingsJson = exportSettings();
@@ -64,6 +84,44 @@ export function AdvancedSettings() {
};
return (
<>
{showGame && <SpamSiegeGame onClose={() => setShowGame(false)} />}
{/* About */}
<div className="rounded-lg border border-border bg-card p-5 mb-6">
<div className="flex items-center gap-4">
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About">
<div className="shrink-0">
<img
src="/branding/Bulwark_Logo_Color.svg"
alt="Bulwark"
className="w-12 h-12 object-contain dark:hidden group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
/>
<img
src="/branding/Bulwark_Logo_White.svg"
alt="Bulwark"
className="w-12 h-12 object-contain hidden dark:block group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">
{t('about.title')}
</p>
<p className="text-xs text-muted-foreground group-hover/about:translate-x-0.5 group-active/about:translate-y-px transition-transform">
v{APP_VERSION} <span className="text-muted-foreground/60">({GIT_COMMIT})</span>
</p>
</div>
</button>
<a
href="https://github.com/bulwarkmail/webmail"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
GitHub <ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
<SettingsSection title={t('title')} description={t('description')}>
{/* Debug Mode */}
{!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
@@ -72,6 +130,30 @@ export function AdvancedSettings() {
</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 */}
{settingsSyncEnabled && (
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
@@ -122,5 +204,6 @@ export function AdvancedSettings() {
</Button>
</SettingItem>
</SettingsSection>
</>
);
}
@@ -4,6 +4,7 @@ import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { toast } from '@/stores/toast-store';
import { SettingsSection } from './settings-section';
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', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...getActiveAccountSlotHeaders(),
},
body: JSON.stringify({
accounts: Array.from(accounts.entries()).map(([key, candidates]) => ({ key, candidates })),
}),
+11
View File
@@ -19,6 +19,7 @@ export function CalendarSettings() {
showWeekNumbers,
enableCalendarTasks,
showTasksOnCalendar,
showBirthdayCalendar,
calendarHoverPreview,
updateSetting,
} = useSettingsStore();
@@ -98,6 +99,16 @@ export function CalendarSettings() {
/>
</SettingItem>
<SettingItem
label={t('show_birthday_calendar')}
description={t('show_birthday_calendar_desc')}
>
<ToggleSwitch
checked={showBirthdayCalendar}
onChange={(checked) => updateSetting('showBirthdayCalendar', checked)}
/>
</SettingItem>
{isFeatureEnabled('calendarTasksEnabled') && (
<>
<SettingItem
+74 -2
View File
@@ -11,8 +11,9 @@ import { useEmailStore } from '@/stores/email-store';
import { cn } from '@/lib/utils';
import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react';
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail, X } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
import { useContactStore } from '@/stores/contact-store';
const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
@@ -110,6 +111,8 @@ export function EmailSettings() {
}
}, []);
const [newKeyword, setNewKeyword] = useState('');
const {
markAsReadDelay,
deleteAction,
@@ -129,12 +132,16 @@ export function EmailSettings() {
hoverActionsMode,
hoverActionsCorner,
trustedSenders,
trustedSendersAddressBook,
attachmentReminderEnabled,
attachmentReminderKeywords,
updateSetting,
} = useSettingsStore();
const { trustedSenderEmails } = useContactStore();
// Get count label for trusted senders button
const getTrustedSendersCount = () => {
const count = trustedSenders.length;
const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length;
if (count === 0) return t('trusted_senders.count_zero');
if (count === 1) return t('trusted_senders.count_one');
return t('trusted_senders.count_other', { count });
@@ -337,6 +344,63 @@ export function EmailSettings() {
/>
</SettingItem>
{/* Attachment Reminder */}
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
<ToggleSwitch
checked={attachmentReminderEnabled}
onChange={(checked) => updateSetting('attachmentReminderEnabled', checked)}
/>
</SettingItem>
{attachmentReminderEnabled && (
<div className="py-3 border-b border-border space-y-2">
<div>
<label className="text-sm font-medium text-foreground">{t('attachment_reminder.keywords_label')}</label>
<p className="text-xs text-muted-foreground mt-1">{t('attachment_reminder.keywords_description')}</p>
</div>
<div className="flex flex-wrap gap-1.5">
{attachmentReminderKeywords.map((kw) => (
<span key={kw} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-foreground">
{kw}
<button
type="button"
aria-label={t('attachment_reminder.remove')}
onClick={() => updateSetting('attachmentReminderKeywords', attachmentReminderKeywords.filter(k => k !== kw))}
className="text-muted-foreground hover:text-foreground"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
const trimmed = newKeyword.trim().toLowerCase();
if (trimmed && !attachmentReminderKeywords.includes(trimmed)) {
updateSetting('attachmentReminderKeywords', [...attachmentReminderKeywords, trimmed]);
}
setNewKeyword('');
}}
>
<input
type="text"
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
placeholder={t('attachment_reminder.add_placeholder')}
className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
/>
<button
type="submit"
disabled={!newKeyword.trim()}
className="px-3 py-1 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('attachment_reminder.add')}
</button>
</form>
</div>
)}
{/* Quick Hover Actions */}
{isFeatureEnabled('hoverActionsConfigEnabled') && (
<div className="py-3 border-b border-border space-y-3">
@@ -511,6 +575,14 @@ export function EmailSettings() {
</button>
</SettingItem>
{/* Trusted Senders — address book storage */}
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
<ToggleSwitch
checked={trustedSendersAddressBook}
onChange={(checked) => updateSetting('trustedSendersAddressBook', checked)}
/>
</SettingItem>
{/* Trusted Senders Modal */}
<TrustedSendersModal
isOpen={showTrustedModal}
+18 -2
View File
@@ -99,11 +99,17 @@ function IconPicker({ currentIcon, onSelect, onClose }: {
export function FolderSettings() {
const t = useTranslations('settings.folders');
const { client } = useAuthStore();
const { mailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore();
const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore();
const { folderIcons, setFolderIcon } = useSettingsStore();
const { isFeatureEnabled } = usePolicyStore();
const folderIconsAllowed = isFeatureEnabled('folderIconsEnabled');
useEffect(() => {
if (client && mailboxes.length === 0) {
fetchMailboxes(client);
}
}, [client, mailboxes.length, fetchMailboxes]);
const [isCreating, setIsCreating] = useState(false);
const [creatingParentId, setCreatingParentId] = useState<string | null>(null);
const [newFolderName, setNewFolderName] = useState('');
@@ -200,8 +206,18 @@ export function FolderSettings() {
await deleteMailbox(client, mailboxId);
setDeletingId(null);
toast.success(t('folder_deleted'));
} catch {
} catch (err: unknown) {
const jmapType = (err as Error & { jmapType?: string })?.jmapType;
switch (jmapType) {
case 'mailboxHasChild':
toast.error(t('error_delete_has_children'));
break;
case 'mailboxHasEmail':
toast.error(t('error_delete_has_email'));
break;
default:
toast.error(t('error_delete'));
}
} finally {
setIsLoading(false);
}
+62 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import React, { useState } from "react";
import { useTranslations } from "next-intl";
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
@@ -41,16 +41,39 @@ function KeywordRow({
keyword,
onEdit,
onDelete,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
isDragOver,
isDragging,
}: {
keyword: KeywordDefinition;
onEdit: () => void;
onDelete: () => void;
onDragStart: () => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: () => void;
onDragEnd: () => void;
isDragOver: boolean;
isDragging: boolean;
}) {
const t = useTranslations("settings.keywords");
const palette = KEYWORD_PALETTE[keyword.color];
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" />
<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>
@@ -166,9 +189,39 @@ export function KeywordSettings() {
const [editingId, setEditingId] = useState<string | null>(null);
const [isAdding, setIsAdding] = 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 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) => {
addKeyword(keyword);
setIsAdding(false);
@@ -220,7 +273,7 @@ export function KeywordSettings() {
{t("migrating")}
</div>
)}
{emailKeywords.map((keyword) =>
{emailKeywords.map((keyword, index) =>
editingId === keyword.id ? (
<KeywordEditForm
key={keyword.id}
@@ -238,6 +291,12 @@ export function KeywordSettings() {
setIsAdding(false);
}}
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() {
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 [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -75,6 +75,13 @@ export function PluginsSettings() {
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) {
disablePlugin(plugin.id);
toast.info(`Plugin "${plugin.name}" disabled`);
@@ -108,20 +115,26 @@ export function PluginsSettings() {
</div>
) : (
<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
key={plugin.id}
plugin={plugin}
isExpanded={expandedPlugin === plugin.id}
isForceEnabled={plugin.forceEnabled || isPluginForceEnabled(plugin.id)}
isManaged={Boolean(plugin.managed)}
needsApproval={needsApproval}
controlsDisabled={!initialized}
onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)}
onToggle={() => handleToggle(plugin)}
onUninstall={() => handleUninstall(plugin)}
onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)}
/>
))}
);
})}
</div>
)}
@@ -166,6 +179,7 @@ interface PluginCardProps {
isExpanded: boolean;
isForceEnabled: boolean;
isManaged: boolean;
needsApproval: boolean;
controlsDisabled: boolean;
onToggleExpand: () => void;
onToggle: () => void;
@@ -173,7 +187,7 @@ interface PluginCardProps {
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 (
<div className={cn(
'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
</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 className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">{plugin.author}</span>
@@ -206,7 +225,7 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
</div>
<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>
@@ -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>
)}
{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 */}
{plugin.description && (
<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 { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { cn } from "@/lib/utils";
import { cn, generateUUID } from "@/lib/utils";
interface SidebarAppFormData {
name: string;
@@ -188,7 +188,7 @@ export function SidebarAppsSettings() {
const draggedIndexRef = useRef<number | null>(null);
const handleAdd = useCallback((data: SidebarAppFormData) => {
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const id = `app-${generateUUID()}`;
addSidebarApp({ id, ...data });
setShowAddForm(false);
}, [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>
);
}
+60 -13
View File
@@ -2,9 +2,11 @@
import { useState, useEffect, useRef, useMemo } from "react";
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 { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { cn } from "@/lib/utils";
interface TrustedSendersModalProps {
@@ -17,22 +19,43 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
const modalRef = useRef<HTMLDivElement>(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 [isAdding, setIsAdding] = useState(false);
const [newEmail, setNewEmail] = 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
const filteredSenders = useMemo(() => {
if (!searchQuery.trim()) return trustedSenders;
if (!searchQuery.trim()) return activeSenders;
const query = searchQuery.toLowerCase();
return trustedSenders.filter((email) => email.toLowerCase().includes(query));
}, [trustedSenders, searchQuery]);
return activeSenders.filter((email) => email.toLowerCase().includes(query));
}, [activeSenders, searchQuery]);
// Show search only when 5+ senders
const showSearch = trustedSenders.length >= 5;
const showSearch = activeSenders.length >= 5;
// Close on Escape key
useEffect(() => {
@@ -90,7 +113,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
return emailRegex.test(email);
};
const handleAddSender = () => {
const handleAddSender = async () => {
const trimmedEmail = newEmail.trim().toLowerCase();
if (!trimmedEmail) {
@@ -103,15 +126,34 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
return;
}
if (trustedSenders.includes(trimmedEmail)) {
if (activeSenders.includes(trimmedEmail)) {
setEmailError(t("already_added"));
return;
}
setIsSubmitting(true);
try {
if (trustedSendersAddressBook && client) {
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>) => {
@@ -170,7 +212,11 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
{/* Content */}
<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 */
<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" />
@@ -209,7 +255,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
{email}
</span>
<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"
aria-label={`${t("remove")} ${email}`}
>
@@ -222,7 +268,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
</div>
{/* Footer - Add sender */}
{trustedSenders.length > 0 && (
{!isLoading && activeSenders.length > 0 && (
<div className="px-6 py-4 border-t border-border flex-shrink-0">
{isAdding ? (
<div className="space-y-2">
@@ -244,9 +290,10 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
/>
<button
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>
</div>
{emailError && (
+30 -7
View File
@@ -1,10 +1,11 @@
"use client";
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback, useMemo, useEffect } from "react";
import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
import { useConfig } from "@/hooks/use-config";
import { avatarHooks } from "@/lib/plugin-hooks";
const IS_DEV = process.env.NODE_ENV !== "production";
@@ -143,10 +144,26 @@ interface AvatarProps {
export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) {
const [imgError, setImgError] = useState(false);
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const contacts = useContactStore((s) => s.contacts);
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
const resolvedContactPhoto = useMemo(() => {
if (contactPhotoUri) return contactPhotoUri;
@@ -202,19 +219,25 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className }:
const showFavicon =
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 pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
const imgSrc = !imgError && !domainFailed
? resolvedContactPhoto || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
: (resolvedContactPhoto || customAvatar || profilePic || null);
? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
: (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
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);
// If this was a favicon URL (not a contact photo, custom avatar or profile pic), remember the domain
if (faviconDomain && !resolvedContactPhoto && !customAvatar && !profilePic) {
// If this was a favicon URL (not a contact photo, plugin avatar, custom avatar or profile pic), remember the domain
if (faviconDomain && !resolvedContactPhoto && !pluginAvatar && !customAvatar && !profilePic) {
failedFaviconDomains.add(faviconDomain);
}
}, [faviconDomain, resolvedContactPhoto, customAvatar, profilePic]);
}, [imgSrc, pluginAvatar, faviconDomain, resolvedContactPhoto, customAvatar, profilePic]);
return (
<div
+5 -1
View File
@@ -12,12 +12,16 @@ export function LanguageSwitcher({ className }: { className?: string }) {
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'es', label: 'Español' },
{ value: 'it', label: 'Italiano' },
{ value: 'de', label: 'Deutsch' },
{ value: 'lv', label: 'Latviešu' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' }
{ value: 'ru', label: 'Русский' },
{ value: 'zh', label: '简体中文' }
];
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 keywords = { ...(email?.keywords || {}) };
// Remove old label/color keywords
Object.keys(keywords).forEach(key => {
if (key.startsWith("$label:") || key.startsWith("$color:")) {
keywords[key] = false;
}
});
// Add the new tag
// Add the tag without removing existing ones
keywords[`$label:${tagId}`] = true;
await client.updateEmailKeywords(emailId, keywords);
+12
View File
@@ -26,15 +26,27 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'ja':
messages = (await import('../locales/ja/common.json')).default;
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':
messages = (await import('../locales/nl/common.json')).default;
break;
case 'pl':
messages = (await import('../locales/pl/common.json')).default;
break;
case 'pt':
messages = (await import('../locales/pt/common.json')).default;
break;
case 'ru':
messages = (await import('../locales/ru/common.json')).default;
break;
case 'zh':
messages = (await import('../locales/zh/common.json')).default;
break;
default:
messages = (await import('../locales/en/common.json')).default;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { defineRouting } from 'next-intl/routing';
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',
localePrefix: 'never'
});
+32
View File
@@ -1,3 +1,4 @@
import { unlink, writeFileSync } from "fs";
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock NextResponse before importing the route
@@ -24,6 +25,7 @@ describe('config API route', () => {
delete process.env.OAUTH_CLIENT_ID;
delete process.env.OAUTH_ISSUER_URL;
delete process.env.SESSION_SECRET;
delete process.env.SESSION_SECRET_FILE;
delete process.env.SETTINGS_SYNC_ENABLED;
delete process.env.STALWART_FEATURES;
delete process.env.DEV_MOCK_JMAP;
@@ -131,6 +133,19 @@ describe('config API route', () => {
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);
});
it('should enable settingsSync only when both SESSION_SECRET and SETTINGS_SYNC_ENABLED are set', async () => {
process.env.SETTINGS_SYNC_ENABLED = 'true';
const config1 = await getConfig();
@@ -141,6 +156,23 @@ describe('config API route', () => {
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);
});
it('should disable stalwart features when explicitly set to false', async () => {
process.env.STALWART_FEATURES = 'false';
+75 -36
View File
@@ -263,48 +263,37 @@ describe('mailbox deep nesting (depth 4+)', () => {
});
});
describe('mailbox deduplication side effects on nesting', () => {
it('should not remove an intermediate parent whose name matches a role mailbox', () => {
// Scenario: A non-role folder named "Sent" is an intermediate parent.
// The deduplication logic might remove it because it matches the role "sent" mailbox name.
describe('GitHub #118: duplicate subfolder names cause depth-4 orphaning', () => {
it('should keep nested folders when a subfolder has the same name as a role mailbox', () => {
// Reporter's exact scenario: two subfolders with the same 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 = [
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
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' }),
// 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' }),
];
const tree = buildMailboxTree(mailboxes);
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');
expect(sentChild).toBeDefined();
// Check if sent-child is orphaned at root (the bug)
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);
expect(sentChild!.depth).toBe(2); // nested under sent-custom
expect(rootIds).not.toContain('sent-child'); // must NOT be orphaned at root
});
it('should not remove a parent folder whose name is a substring of a role name', () => {
// Folder named "Draft" (substring of "Drafts" role) used as intermediate parent
it('should keep nested folders when name is substring of a role name', () => {
// "Draft" is a substring of "Drafts" — dedup removes it, orphaning children
const mailboxes = [
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
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');
expect(draftChild).toBeDefined();
expect(draftChild!.depth).toBe(2);
expect(rootIds).not.toContain('draft-child');
});
const isOrphaned = rootIds.includes('draft-child');
if (isOrphaned) {
console.warn(
'BUG DETECTED: Deduplication removed "draft-folder" (substring match with "Drafts"), ' +
'orphaning "draft-child" to root level.'
);
}
it('should handle the exact reported structure with duplicate names at different depths', () => {
// Stalwart allows creating subfolders with the same name at different levels.
// If any of those names match a role mailbox name, dedup could remove them.
const mailboxes = [
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);
});
});
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');
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 { NextResponse } from 'next/server';
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 type { AdminSessionPayload } from './types';
@@ -8,9 +9,17 @@ const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
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.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();
}
@@ -116,11 +125,24 @@ export async function clearAdminSessionCookie(): Promise<void> {
/**
* 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 {
const forwarded = request.headers.get('x-forwarded-for');
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';
}
+8 -1
View File
@@ -25,6 +25,7 @@ export interface SettingRestriction {
export interface FeatureGates {
pluginsEnabled: boolean;
pluginsUploadEnabled: boolean;
requirePluginApproval: boolean;
themesEnabled: boolean;
sidebarAppsEnabled: boolean;
userThemesEnabled: boolean;
@@ -37,11 +38,13 @@ export interface FeatureGates {
debugModeEnabled: boolean;
folderIconsEnabled: boolean;
hoverActionsConfigEnabled: boolean;
filesEnabled: boolean;
}
export const DEFAULT_FEATURE_GATES: FeatureGates = {
pluginsEnabled: true,
pluginsEnabled: false,
pluginsUploadEnabled: true,
requirePluginApproval: true,
themesEnabled: true,
sidebarAppsEnabled: true,
userThemesEnabled: true,
@@ -54,6 +57,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
debugModeEnabled: true,
folderIconsEnabled: true,
hoverActionsConfigEnabled: true,
filesEnabled: true,
};
export interface ThemePolicy {
@@ -78,6 +82,8 @@ export interface SettingsPolicy {
themePolicy: ThemePolicy;
/** Plugin IDs that are force-enabled (users cannot disable) */
forceEnabledPlugins: string[];
/** Plugin IDs that have been approved by admin (users can enable) */
approvedPlugins: string[];
/** Theme IDs that are force-enabled (users cannot deactivate) */
forceEnabledThemes: string[];
}
@@ -88,6 +94,7 @@ export const DEFAULT_POLICY: SettingsPolicy = {
defaults: {},
themePolicy: { ...DEFAULT_THEME_POLICY },
forceEnabledPlugins: [],
approvedPlugins: [],
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 { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
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.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();
}
+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 { generateUUID } from '@/lib/utils';
export interface ParticipantInfo {
id: string;
@@ -104,9 +105,7 @@ export function buildParticipantMap(
): Record<string, Partial<CalendarParticipant>> {
const participants: Record<string, Partial<CalendarParticipant>> = {};
const generateId = () => typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `p-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
const generateId = () => generateUUID();
participants[generateId()] = {
'@type': 'Participant',
+57 -24
View File
@@ -1,25 +1,53 @@
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.
*
* 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 = {
/**
* 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[]) => {
if (useSettingsStore.getState().debugMode) {
console.log('[DEBUG]', ...args);
log: (categoryOrMsg: DebugCategory | unknown, ...args: unknown[]) => {
if (typeof categoryOrMsg === 'string' && isCategoryKey(categoryOrMsg)) {
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[]) => {
if (useSettingsStore.getState().debugMode) {
console.warn('[DEBUG]', ...args);
warn: (categoryOrMsg: DebugCategory | unknown, ...args: unknown[]) => {
if (typeof categoryOrMsg === 'string' && isCategoryKey(categoryOrMsg)) {
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) => {
if (useSettingsStore.getState().debugMode) {
console.group(`[DEBUG] ${label}`);
group: (label: string, category?: DebugCategory) => {
if (isEnabled(category)) {
console.group(`[DEBUG${category ? ':' + category : ''}] ${label}`);
}
},
@@ -43,35 +71,40 @@ export const debug = {
* End a console group (only when debugMode is enabled)
*/
groupEnd: () => {
if (useSettingsStore.getState().debugMode) {
if (isEnabled()) {
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) => {
if (useSettingsStore.getState().debugMode) {
console.time(`[DEBUG] ${label}`);
time: (label: string, category?: DebugCategory) => {
if (isEnabled(category)) {
console.time(`[DEBUG${category ? ':' + category : ''}] ${label}`);
}
},
/**
* End a performance timer (only when debugMode is enabled)
*/
timeEnd: (label: string) => {
if (useSettingsStore.getState().debugMode) {
console.timeEnd(`[DEBUG] ${label}`);
timeEnd: (label: string, category?: DebugCategory) => {
if (isEnabled(category)) {
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) => {
if (useSettingsStore.getState().debugMode) {
table: (data: unknown, category?: DebugCategory) => {
if (isEnabled(category)) {
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 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[]> {
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
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'],
ALLOW_DATA_ATTR: false,
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
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: [
'script', 'iframe', 'object', 'embed', 'form',
'input', 'button', 'meta', 'link', 'base',
+2
View File
@@ -178,6 +178,8 @@ export interface IJMAPClient {
getContactsAccountId(): string;
getAddressBooks(): 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[]>;
getAllContacts(): Promise<ContactCard[]>;
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
+187 -86
View File
@@ -670,12 +670,12 @@ export class JMAPClient implements IJMAPClient {
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
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
const maxObjects = this.getMaxObjectsInGet();
if (rawMailboxes.length >= maxObjects) {
debug.warn(
debug.warn('jmap',
`[JMAP Mailbox] Response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` +
`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 missingParents = rawMailboxes.filter(mb => mb.parentId && !returnedIds.has(mb.parentId));
if (missingParents.length > 0) {
debug.warn(
debug.warn('jmap',
`[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 }))
);
@@ -755,12 +755,12 @@ export class JMAPClient implements IJMAPClient {
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
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
const maxObjects = this.getMaxObjectsInGet();
if (rawMailboxes.length >= maxObjects) {
debug.warn(
debug.warn('jmap',
`[JMAP Mailbox] Account ${accountId}: response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` +
`Some mailboxes may be missing.`
);
@@ -1320,7 +1320,10 @@ export class JMAPClient implements IJMAPClient {
const result = response.methodResponses?.[0]?.[1];
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');
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> = {
ACCEPTED: 'Accepted',
@@ -2005,7 +2008,7 @@ export class JMAPClient implements IJMAPClient {
const statusLabel = statusLabels[opts.status] || opts.status;
const subject = `${statusLabel}: ${opts.summary || 'Event'}`;
debug.log('[iMIP] identityId:', finalIdentityId);
debug.log('calendar', '[iMIP] identityId:', finalIdentityId);
const emailId = `imip-reply-${Date.now()}`;
const emailCreate: Record<string, unknown> = {
@@ -2038,12 +2041,12 @@ export class JMAPClient implements IJMAPClient {
}, "1"],
];
debug.log('[iMIP] Sending JMAP request with', methodCalls.length, 'method calls');
debug.log('[iMIP] Email create payload:', JSON.stringify(emailCreate, null, 2));
debug.log('calendar', '[iMIP] Sending JMAP request with', methodCalls.length, 'method calls');
debug.log('calendar', '[iMIP] Email create payload:', JSON.stringify(emailCreate, null, 2));
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) {
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> {
if (!event.participants) return;
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();
@@ -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(
accountId: string,
filter?: Record<string, unknown>,
@@ -3220,34 +3273,49 @@ export class JMAPClient implements IJMAPClient {
async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]> {
const accountId = targetAccountId || this.getCalendarsAccountId();
const GET_BATCH_SIZE = this.getMaxObjectsInGet();
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
if (calendarIds && calendarIds.length > 0) {
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/get", {
accountId,
properties: [...CALENDAR_EVENT_PROPERTIES],
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
}, "1"]
], this.calendarUsing());
// Check for JMAP method-level errors
if (response.methodResponses?.[0]?.[0] === "error") {
const error = response.methodResponses[0][1];
if (queryResponse.methodResponses?.[0]?.[0] === "error") {
const error = queryResponse.methodResponses[0][1];
throw new Error(error?.description || error?.type || "CalendarEvent/query failed");
}
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
return ((response.methodResponses[1][1].list || []) as CalendarEvent[])
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));
}
return [];
}
async queryAllCalendarEvents(
filter: CalendarEventFilter,
@@ -3311,21 +3379,42 @@ export class JMAPClient implements IJMAPClient {
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"],
], this.calendarUsing());
if (queryResponse.methodResponses?.[0]?.[0] === "error") {
const error = queryResponse.methodResponses[0][1];
throw new Error(error?.description || error?.type || "CalendarEvent/query failed");
}
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": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
}, "1"]
ids: batchIds,
}, "0"]
], this.calendarUsing());
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
return ((response.methodResponses[1][1].list || []) as CalendarEvent[])
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));
}
return [];
} catch (error) {
console.error('Failed to query calendar events:', error);
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;
cleanRecurrenceRules(cleanEvent as unknown as Record<string, unknown>);
debug.group('CalendarEvent/create');
debug.log('CalendarEvent/create outgoing payload', {
debug.group('CalendarEvent/create', 'calendar');
debug.log('calendar', 'CalendarEvent/create outgoing payload', {
accountId,
sendSchedulingMessages,
eventKeys: Object.keys(cleanEvent),
@@ -3382,40 +3471,40 @@ export class JMAPClient implements IJMAPClient {
["CalendarEvent/set", setArgs, "0"]
], 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") {
const result = response.methodResponses[0][1];
if (result.notCreated?.["new-event"]) {
const error = result.notCreated["new-event"];
debug.warn('CalendarEvent/create notCreated', error);
debug.warn('CalendarEvent/create invalid properties', error.properties);
debug.warn('CalendarEvent/create sent keys', Object.keys(cleanEvent));
debug.warn('calendar', 'CalendarEvent/create notCreated', error);
debug.warn('calendar', 'CalendarEvent/create invalid properties', error.properties);
debug.warn('calendar', 'CalendarEvent/create sent keys', Object.keys(cleanEvent));
debug.groupEnd();
throw new Error(error.description || "Failed to create calendar event");
}
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,
created: result.created?.['new-event'] || null,
});
if (createdId) {
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) {
try {
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,
matchCount: verificationMatches.length,
matches: verificationMatches.map((match) => getCalendarEventDebugSnapshot(match)),
});
} 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;
}
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,
targetAccountId,
});
@@ -3455,7 +3544,7 @@ export class JMAPClient implements IJMAPClient {
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([
["CalendarEvent/set", { accountId, create: createMap }, "0"]
@@ -3471,7 +3560,7 @@ export class JMAPClient implements IJMAPClient {
if (result.created?.[key]?.id) {
createdIds.push(result.created[key].id);
} 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);
}
}
@@ -3496,7 +3585,7 @@ export class JMAPClient implements IJMAPClient {
createdEvents = list.map((e: CalendarEvent) => normalizeCalendarEventLike(e));
}
debug.log('CalendarEvent/batchCreate result', {
debug.log('calendar', 'CalendarEvent/batchCreate result', {
requested: events.length,
created: createdEvents.length,
failed: failed.length,
@@ -3513,8 +3602,8 @@ export class JMAPClient implements IJMAPClient {
): Promise<void> {
const accountId = targetAccountId || this.getCalendarsAccountId();
// Strip client-only shared fields before sending to JMAP
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent;
// Strip client-only and server-immutable fields before sending to JMAP
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>);
const setArgs: Record<string, unknown> = {
@@ -3527,7 +3616,7 @@ export class JMAPClient implements IJMAPClient {
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([
["CalendarEvent/set", setArgs, "0"]
@@ -3549,7 +3638,7 @@ export class JMAPClient implements IJMAPClient {
debug.error('CalendarEvent/set notUpdated', { eventId, error });
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;
}
@@ -3600,7 +3689,7 @@ export class JMAPClient implements IJMAPClient {
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([
["CalendarEvent/set", setArgs, "0"]
@@ -3622,7 +3711,7 @@ export class JMAPClient implements IJMAPClient {
debug.error('CalendarEvent/set notDestroyed', { eventId, error });
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;
}
@@ -3654,8 +3743,8 @@ export class JMAPClient implements IJMAPClient {
async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
const accountId = targetAccountId || this.getCalendarsAccountId();
debug.group('CalendarTask/fetch');
debug.log('CalendarTask/fetch start', { accountId, calendarIds: calendarIds || 'all' });
debug.group('CalendarTask/fetch', 'tasks');
debug.log('tasks', 'CalendarTask/fetch start', { accountId, calendarIds: calendarIds || 'all' });
try {
// Strategy 1: query with types filter (JMAP spec compliant)
@@ -3664,7 +3753,7 @@ export class JMAPClient implements IJMAPClient {
filter.inCalendars = calendarIds;
}
debug.log('CalendarTask/fetch query filter', filter);
debug.log('tasks', 'CalendarTask/fetch query filter', filter);
const response = await this.request([
["CalendarEvent/query", { accountId, filter, limit: 1000 }, "0"],
@@ -3678,13 +3767,13 @@ export class JMAPClient implements IJMAPClient {
const queryResponse = response.methodResponses?.[0];
const getResponse = response.methodResponses?.[1];
debug.log('CalendarTask/fetch query method', queryResponse?.[0]);
debug.log('CalendarTask/fetch query result', queryResponse?.[1]);
debug.log('tasks', 'CalendarTask/fetch query method', queryResponse?.[0]);
debug.log('tasks', 'CalendarTask/fetch query result', queryResponse?.[1]);
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);
debug.log('CalendarTask/fetch fallback returned', tasks.length, 'tasks');
debug.log('tasks', 'CalendarTask/fetch fallback returned', tasks.length, 'tasks');
debug.groupEnd();
return tasks;
}
@@ -3692,22 +3781,22 @@ export class JMAPClient implements IJMAPClient {
if (getResponse?.[0] === "CalendarEvent/get") {
const list = (getResponse[1].list || []) as CalendarTask[];
const queryIds = queryResponse?.[1]?.ids || [];
debug.log('CalendarTask/fetch query returned', queryIds.length, 'ids:', queryIds);
debug.log('CalendarTask/fetch get returned', list.length, 'objects');
debug.log('calendar', 'CalendarTask/fetch query returned', queryIds.length, 'ids:', queryIds);
debug.log('calendar', 'CalendarTask/fetch get returned', list.length, 'objects');
// If the types filter returned 0 results, the server may have silently
// ignored it (e.g. Stalwart with CalDAV-created VTODOs). Fall back to
// a full scan so we can detect tasks by their properties.
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);
debug.log('CalendarTask/fetch fallback returned', tasks.length, 'tasks');
debug.log('tasks', 'CalendarTask/fetch fallback returned', tasks.length, 'tasks');
debug.groupEnd();
return tasks;
}
list.forEach((task, i) => {
debug.log(`CalendarTask/fetch [${i}]`, {
debug.log('tasks', `CalendarTask/fetch [${i}]`, {
id: task.id,
uid: task.uid,
'@type': task['@type'],
@@ -3724,12 +3813,12 @@ export class JMAPClient implements IJMAPClient {
...task,
'@type': 'Task' as const,
}));
debug.log('CalendarTask/fetch complete,', results.length, 'tasks');
debug.log('tasks', 'CalendarTask/fetch complete,', results.length, 'tasks');
debug.groupEnd();
return results;
}
debug.warn('CalendarTask/fetch unexpected response shape', response.methodResponses);
debug.warn('tasks', 'CalendarTask/fetch unexpected response shape', response.methodResponses);
debug.groupEnd();
return [];
} catch (error) {
@@ -3746,7 +3835,7 @@ export class JMAPClient implements IJMAPClient {
*/
private async getCalendarTasksFallback(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
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
const response = await this.request([
@@ -3758,12 +3847,12 @@ export class JMAPClient implements IJMAPClient {
], this.calendarUsing());
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 [];
}
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 calendarIdSet = calendarIds ? new Set(calendarIds) : null;
@@ -3779,7 +3868,7 @@ export class JMAPClient implements IJMAPClient {
|| ('percentComplete' in obj);
const isCalDavTask = type !== 'Event' && hasTaskFields;
debug.log('CalendarTask/fallback scan', {
debug.log('tasks', 'CalendarTask/fallback scan', {
id: obj.id,
'@type': type,
title: obj.title,
@@ -3796,7 +3885,7 @@ export class JMAPClient implements IJMAPClient {
if (calendarIdSet) {
const objCalendarIds = obj.calendarIds as Record<string, boolean> | undefined;
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;
}
}
@@ -3804,9 +3893,9 @@ export class JMAPClient implements IJMAPClient {
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) => {
debug.log(`CalendarTask/fallback [${i}]`, {
debug.log('tasks', `CalendarTask/fallback [${i}]`, {
id: t.id,
uid: t.uid,
title: t.title,
@@ -3825,9 +3914,9 @@ export class JMAPClient implements IJMAPClient {
const { '@type': _type, ...taskData } = task;
const cleanTask = { ...taskData, '@type': 'Task' };
debug.group('CalendarTask/create');
debug.log('CalendarTask/create accountId', accountId);
debug.log('CalendarTask/create outgoing payload', cleanTask);
debug.group('CalendarTask/create', 'tasks');
debug.log('tasks', 'CalendarTask/create accountId', accountId);
debug.log('tasks', 'CalendarTask/create outgoing payload', cleanTask);
const response = await this.request([
["CalendarEvent/set", {
@@ -3838,27 +3927,27 @@ export class JMAPClient implements IJMAPClient {
], this.calendarUsing());
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"]) {
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();
throw new Error(error.description || "Failed to create task");
}
const createdId = result?.created?.["new-task"]?.id;
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) {
debug.warn('CalendarTask/create no id in server response');
debug.warn('tasks', 'CalendarTask/create no id in server response');
debug.groupEnd();
throw new Error("Failed to create task — no id returned");
}
// 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([
["CalendarEvent/get", {
accountId,
@@ -3870,10 +3959,10 @@ export class JMAPClient implements IJMAPClient {
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
const list = getResponse.methodResponses[0][1].list || [];
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]) {
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,
uid: created.uid,
'@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();
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> {
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) {
fileProps.parentId = parentId;
}
@@ -4112,6 +4203,7 @@ export class JMAPClient implements IJMAPClient {
[["FileNode/set", {
accountId,
destroy: ids,
onDestroyRemoveChildren: true,
}, "fns0"]],
this.fileUsing(),
);
@@ -4120,9 +4212,18 @@ export class JMAPClient implements IJMAPClient {
if (!result || result[0] === "error") {
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 {
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 {
components: NameComponent[];
components?: NameComponent[];
isOrdered?: boolean;
full?: string;
defaultSeparator?: string;
}
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;
}
+2 -2
View File
@@ -31,7 +31,7 @@ function playFile(file: string) {
const audio = new Audio(file);
audio.volume = 0.3;
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();
});
}
@@ -47,6 +47,6 @@ export function playNotificationSound(sound?: NotificationSoundChoice) {
playBeep();
}
} 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_SECURE = process.env.COOKIE_SECURE !== undefined
? process.env.COOKIE_SECURE === 'true'
: (COOKIE_SAME_SITE === 'none' || process.env.NODE_ENV === 'production');
export function getCookieOptions() {
return {
httpOnly: true,
secure: COOKIE_SAME_SITE === 'none' || process.env.NODE_ENV === 'production',
secure: COOKIE_SECURE,
sameSite: COOKIE_SAME_SITE,
path: '/',
maxAge: 30 * 24 * 60 * 60,
+2 -1
View File
@@ -1,8 +1,9 @@
import { logger } from '@/lib/logger';
import { discoverOAuth } 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() {
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';
/** 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