Compare commits

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

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+21
View File
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { getLocale } from "next-intl/server"; import { getLocale } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -20,6 +22,15 @@ export async function generateMetadata(): Promise<Metadata> {
return { return {
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail", title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
description: "Minimalist webmail client using JMAP protocol", description: "Minimalist webmail client using JMAP protocol",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
},
formatDetection: {
telephone: false,
},
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}), ...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
}; };
} }
@@ -36,6 +47,14 @@ export default async function RootLayout({
return ( return (
<html lang={locale} suppressHydrationWarning> <html lang={locale} suppressHydrationWarning>
<head> <head>
<meta name="theme-color" content="#ffffff" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-title"
content={process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail"}
/>
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
{parentOrigin && ( {parentOrigin && (
<meta name="parent-origin" content={parentOrigin} /> <meta name="parent-origin" content={parentOrigin} />
)} )}
@@ -63,7 +82,9 @@ export default async function RootLayout({
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
<ServiceWorkerRegistration />
{children} {children}
<PWAInstallPrompt />
</body> </body>
</html> </html>
); );
@@ -2,13 +2,14 @@
import { useState, useRef, useEffect, useMemo } from "react"; import { useState, useRef, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2 } from "lucide-react"; import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils"; import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types"; import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useTaskStore } from "@/stores/task-store"; import { useTaskStore } from "@/stores/task-store";
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { IJMAPClient } from '@/lib/jmap/client-interface';
@@ -164,6 +165,9 @@ export function CalendarSidebarPanel({
)} )}
</> </>
)} )}
{cal.id === BIRTHDAY_CALENDAR_ID && (
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)}
</button> </button>
{/* Subscription context menu on right-click */} {/* Subscription context menu on right-click */}
+1 -1
View File
@@ -88,7 +88,7 @@ function formatDurationDisplay(minutes: number): string {
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null { function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
if (!event.alerts) return null; if (!event.alerts) return null;
const first = Object.values(event.alerts)[0]; const first = Object.values(event.alerts)[0];
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null; if (!first || !first.trigger || first.trigger["@type"] !== "OffsetTrigger") return null;
const offset = first.trigger.offset; const offset = first.trigger.offset;
if (offset === "PT0S") return t("alerts.at_time"); if (offset === "PT0S") return t("alerts.at_time");
const minMatch = offset.match(/-?PT?(\d+)M$/); const minMatch = offset.match(/-?PT?(\d+)M$/);
+2 -3
View File
@@ -20,6 +20,7 @@ import {
} from "@/lib/calendar-participants"; } from "@/lib/calendar-participants";
import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils";
export interface PendingEventPreview { export interface PendingEventPreview {
start: Date; start: Date;
@@ -402,9 +403,7 @@ export function EventModal({
if (!event || !onDuplicate) return; if (!event || !onDuplicate) return;
const start = getEventStartDate(event); const start = getEventStartDate(event);
const newStart = addDays(start, 1); const newStart = addDays(start, 1);
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID const newUid = generateUUID();
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const data: Partial<CalendarEvent> = { const data: Partial<CalendarEvent> = {
uid: newUid, uid: newUid,
title: event.title, title: event.title,
+5 -19
View File
@@ -304,11 +304,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
if (contact?.addressBookIds) { if (contact?.addressBookIds) {
const ids = Object.keys(contact.addressBookIds).filter(k => contact.addressBookIds[k]); const ids = Object.keys(contact.addressBookIds).filter(k => contact.addressBookIds[k]);
if (ids.length > 0) { if (ids.length > 0) {
// For shared contacts, the addressBookIds uses the original (non-namespaced) id // addressBookIds are already namespaced for shared contacts (e.g. "accountId:bookId")
// but we need the namespaced id to match addressBooks entries // so we can use them directly to match addressBook entries
if (contact.isShared && contact.accountId) {
return `${contact.accountId}:${ids[0]}`;
}
return ids[0]; return ids[0];
} }
} }
@@ -914,7 +911,6 @@ function CategoryComboBox({
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Parse current keywords from comma-separated string // Parse current keywords from comma-separated string
@@ -949,17 +945,6 @@ function CategoryComboBox({
onChange(next); onChange(next);
}, [currentKeywords, onChange]); }, [currentKeywords, onChange]);
// Close dropdown on outside click
useEffect(() => {
if (!isOpen) return;
const handler = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [isOpen]);
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") { if (e.key === "Enter") {
@@ -973,7 +958,7 @@ function CategoryComboBox({
}; };
return ( return (
<div ref={wrapperRef} className="relative"> <div className="relative">
{/* Keyword badges */} {/* Keyword badges */}
{currentKeywords.length > 0 && ( {currentKeywords.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2"> <div className="flex flex-wrap gap-1.5 mb-2">
@@ -1001,6 +986,7 @@ function CategoryComboBox({
value={inputValue} value={inputValue}
onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }} onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }}
onFocus={() => setIsOpen(true)} onFocus={() => setIsOpen(true)}
onBlur={() => setIsOpen(false)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={currentKeywords.length === 0 ? placeholder : ""} placeholder={currentKeywords.length === 0 ? placeholder : ""}
/> />
@@ -1008,7 +994,7 @@ function CategoryComboBox({
{/* Dropdown */} {/* Dropdown */}
{isOpen && (suggestions.length > 0 || canAddNew) && ( {isOpen && (suggestions.length > 0 || canAddNew) && (
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1"> <div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1" onMouseDown={(e) => e.preventDefault()}>
{suggestions.map(kw => ( {suggestions.map(kw => (
<button <button
key={kw} key={kw}
+111 -28
View File
@@ -2,7 +2,8 @@
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react"; import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2 } from "lucide-react"; import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu";
@@ -25,6 +26,8 @@ interface ContactsSidebarProps {
onDeleteGroup?: (groupId: string) => void; onDeleteGroup?: (groupId: string) => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void; onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void; onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
onRenameAddressBook?: (addressBook: AddressBook) => void;
onRenameKeyword?: (keyword: string) => void;
className?: string; className?: string;
} }
@@ -58,10 +61,15 @@ export function ContactsSidebar({
onDeleteGroup, onDeleteGroup,
onDropContacts, onDropContacts,
onDropContactsToCategory, onDropContactsToCategory,
onRenameAddressBook,
onRenameKeyword,
className, className,
}: ContactsSidebarProps) { }: ContactsSidebarProps) {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const router = useRouter();
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>(); const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
const { contextMenu: bookContextMenu, openContextMenu: openBookContextMenu, closeContextMenu: closeBookContextMenu, menuRef: bookMenuRef } = useContextMenu<AddressBook>();
const { contextMenu: keywordContextMenu, openContextMenu: openKeywordContextMenu, closeContextMenu: closeKeywordContextMenu, menuRef: keywordMenuRef } = useContextMenu<string>();
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed); const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
@@ -246,19 +254,32 @@ export function ContactsSidebar({
{/* My Address Books */} {/* My Address Books */}
{personalBooks.length > 0 && ( {personalBooks.length > 0 && (
<div className="mt-2"> <div className="mt-2">
<button <div className="flex items-center px-3 py-1 group">
onClick={() => toggleSection("addressBooks")} <button
className="flex items-center gap-1 px-3 py-1 w-full text-left group" onClick={() => toggleSection("addressBooks")}
> className="flex items-center gap-1 flex-1 text-left"
{collapsed.addressBooks ? ( >
<ChevronRight className="w-3 h-3 text-muted-foreground" /> {collapsed.addressBooks ? (
) : ( <ChevronRight className="w-3 h-3 text-muted-foreground" />
<ChevronDown className="w-3 h-3 text-muted-foreground" /> ) : (
)} <ChevronDown className="w-3 h-3 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> )}
{t("address_books.title")} <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
</span> {t("address_books.title")}
</button> </span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
title={t("address_books.manage")}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
</div>
{!collapsed.addressBooks && personalBooks.map((book) => ( {!collapsed.addressBooks && personalBooks.map((book) => (
<AddressBookItem <AddressBookItem
key={book.id} key={book.id}
@@ -267,6 +288,7 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0} contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })} onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts} onDropContacts={onDropContacts}
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
/> />
))} ))}
</div> </div>
@@ -362,6 +384,7 @@ export function ContactsSidebar({
isActive={isActive} isActive={isActive}
onSelect={() => onSelectCategory({ keyword })} onSelect={() => onSelectCategory({ keyword })}
onDropContacts={onDropContactsToCategory} onDropContacts={onDropContactsToCategory}
onContextMenu={onRenameKeyword ? (e) => openKeywordContextMenu(e, keyword) : undefined}
/> />
); );
})} })}
@@ -372,20 +395,33 @@ export function ContactsSidebar({
{/* Shared accounts with address books */} {/* Shared accounts with address books */}
{sharedBookGroups.map((group) => ( {sharedBookGroups.map((group) => (
<div key={group.accountId} className="mt-2"> <div key={group.accountId} className="mt-2">
<button <div className="flex items-center px-3 py-1 group">
onClick={() => toggleSection(`shared-${group.accountId}`)} <button
className="flex items-center gap-1 px-3 py-1 w-full text-left group" onClick={() => toggleSection(`shared-${group.accountId}`)}
> className="flex items-center gap-1 flex-1 min-w-0 text-left"
{collapsed[`shared-${group.accountId}`] ? ( >
<ChevronRight className="w-3 h-3 text-muted-foreground" /> {collapsed[`shared-${group.accountId}`] ? (
) : ( <ChevronRight className="w-3 h-3 text-muted-foreground" />
<ChevronDown className="w-3 h-3 text-muted-foreground" /> ) : (
)} <ChevronDown className="w-3 h-3 text-muted-foreground" />
<Share2 className="w-3 h-3 text-muted-foreground" /> )}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate"> <Share2 className="w-3 h-3 text-muted-foreground" />
{t("address_books.shared_prefix", { name: group.accountName })} <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
</span> {t("address_books.shared_prefix", { name: group.accountName })}
</button> </span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
title={t("address_books.manage")}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
</div>
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => ( {!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
<AddressBookItem <AddressBookItem
key={book.id} key={book.id}
@@ -394,12 +430,53 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0} contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })} onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts} onDropContacts={onDropContacts}
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
/> />
))} ))}
</div> </div>
))} ))}
</div> </div>
{/* Address book context menu */}
{bookContextMenu.data && onRenameAddressBook && (
<ContextMenu
ref={bookMenuRef}
isOpen={bookContextMenu.isOpen}
position={bookContextMenu.position}
onClose={closeBookContextMenu}
>
<ContextMenuItem
icon={Pencil}
label={t("address_books.rename")}
onClick={() => {
const book = bookContextMenu.data!;
closeBookContextMenu();
onRenameAddressBook(book);
}}
/>
</ContextMenu>
)}
{/* Keyword (category) context menu */}
{keywordContextMenu.data && onRenameKeyword && (
<ContextMenu
ref={keywordMenuRef}
isOpen={keywordContextMenu.isOpen}
position={keywordContextMenu.position}
onClose={closeKeywordContextMenu}
>
<ContextMenuItem
icon={Pencil}
label={t("rename_category")}
onClick={() => {
const kw = keywordContextMenu.data!;
closeKeywordContextMenu();
onRenameKeyword(kw);
}}
/>
</ContextMenu>
)}
{/* Group context menu */} {/* Group context menu */}
{groupContextMenu.data && ( {groupContextMenu.data && (
<ContextMenu <ContextMenu
@@ -438,12 +515,14 @@ function CategoryItem({
isActive, isActive,
onSelect, onSelect,
onDropContacts, onDropContacts,
onContextMenu,
}: { }: {
keyword: string; keyword: string;
count: number; count: number;
isActive: boolean; isActive: boolean;
onSelect: () => void; onSelect: () => void;
onDropContacts?: (contactIds: string[], keyword: string) => void; onDropContacts?: (contactIds: string[], keyword: string) => void;
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) { }) {
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
@@ -476,6 +555,7 @@ function CategoryItem({
return ( return (
<button <button
onClick={onSelect} onClick={onSelect}
onContextMenu={onContextMenu}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
@@ -503,12 +583,14 @@ function AddressBookItem({
contactCount, contactCount,
onSelect, onSelect,
onDropContacts, onDropContacts,
onContextMenu,
}: { }: {
book: AddressBook; book: AddressBook;
isActive: boolean; isActive: boolean;
contactCount: number; contactCount: number;
onSelect: () => void; onSelect: () => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void; onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) { }) {
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
@@ -541,6 +623,7 @@ function AddressBookItem({
return ( return (
<button <button
onClick={onSelect} onClick={onSelect}
onContextMenu={onContextMenu}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
+57 -56
View File
@@ -754,49 +754,67 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
{/* Content */} {/* Content */}
<div className="px-4 py-3 space-y-2.5"> <div className="px-4 py-3 space-y-2.5">
{/* Event title */} <div className="lg:flex lg:gap-6">
{summary?.title && ( {/* Left: Event info */}
<div className="flex items-start justify-between gap-2"> <div className="lg:flex-1 space-y-2.5 min-w-0">
<h3 className={cn( {/* Event title */}
"text-base font-semibold leading-snug", {summary?.title && (
isCancellation ? "line-through text-muted-foreground" : "text-foreground" <div className="flex items-start justify-between gap-2">
)}> <h3 className={cn(
{summary.title} "text-base font-semibold leading-snug",
</h3> isCancellation ? "line-through text-muted-foreground" : "text-foreground"
{parsedEvent?.sequence != null && parsedEvent.sequence > 0 && ( )}>
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground flex-shrink-0 whitespace-nowrap"> {summary.title}
{t('event_updated', { sequence: parsedEvent.sequence })} </h3>
</span> {parsedEvent?.sequence != null && parsedEvent.sequence > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground flex-shrink-0 whitespace-nowrap">
{t('event_updated', { sequence: parsedEvent.sequence })}
</span>
)}
</div>
)} )}
</div>
)}
{/* Event details */} {/* Event details */}
<div className="space-y-1"> <div className="lg:flex lg:items-center lg:gap-4 lg:flex-wrap space-y-1 lg:space-y-0">
{summary?.start && ( {summary?.start && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground"> <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="w-3.5 h-3.5 flex-shrink-0" /> <Clock className="w-3.5 h-3.5 flex-shrink-0" />
<span> <span>
{formatDateTime(summary.start)} {formatDateTime(summary.start)}
{summary.end && ` ${formatDateTime(summary.end)}`} {summary.end && ` ${formatDateTime(summary.end)}`}
</span> </span>
</div>
)}
{summary?.location && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
<span>{summary.location}</span>
</div>
)}
{summary?.organizer && (
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Users className="w-3.5 h-3.5 flex-shrink-0" />
{t('organizer', { name: summary.organizer })}
</span>
)}
{summary && summary.attendeeCount > 0 && (
<span className="text-sm text-muted-foreground">{t('attendees', { count: summary.attendeeCount })}</span>
)}
</div> </div>
)} </div>
{summary?.location && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground"> {/* Right: Info & actor messages on large screens */}
<MapPin className="w-3.5 h-3.5 flex-shrink-0" /> <div className="lg:flex-shrink-0 lg:text-right lg:max-w-xs mt-2.5 lg:mt-0 space-y-1">
<span>{summary.location}</span> {bannerInfo && (
</div> <p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
)}
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
{summary?.organizer && (
<span className="flex items-center gap-1.5">
<Users className="w-3.5 h-3.5 flex-shrink-0" />
{t('organizer', { name: summary.organizer })}
</span>
)} )}
{summary && summary.attendeeCount > 0 && ( {actorMessage && (
<span>{t('attendees', { count: summary.attendeeCount })}</span> <p className="text-xs text-muted-foreground">{actorMessage}</p>
)}
{actorSummary?.participationComment && (
<p className="text-xs text-muted-foreground italic">
{t('actor_note', { comment: actorSummary.participationComment })}
</p>
)} )}
</div> </div>
</div> </div>
@@ -840,23 +858,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
</div> </div>
)} )}
{/* Info text */}
{bannerInfo && (
<p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
)}
{/* Actor message */}
{actorMessage && (
<p className="text-xs text-muted-foreground">{actorMessage}</p>
)}
{/* Actor comment */}
{actorSummary?.participationComment && (
<p className="text-xs text-muted-foreground italic">
{t('actor_note', { comment: actorSummary.participationComment })}
</p>
)}
{/* Proposed changes */} {/* Proposed changes */}
{proposedChanges.length > 0 && ( {proposedChanges.length > 0 && (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs"> <div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs">
+72 -17
View File
@@ -34,9 +34,8 @@ import { RichTextEditor } from "@/components/email/rich-text-editor";
/** Strip HTML tags and decode entities to get a plain-text version */ /** Strip HTML tags and decode entities to get a plain-text version */
function htmlToPlainText(html: string): string { function htmlToPlainText(html: string): string {
const tmp = document.createElement('div'); const doc = new DOMParser().parseFromString(html, 'text/html');
tmp.innerHTML = html; return doc.body.textContent || '';
return tmp.textContent || tmp.innerText || '';
} }
export interface ComposerDraftData { export interface ComposerDraftData {
@@ -104,6 +103,8 @@ export function EmailComposer({
const timeFormat = useSettingsStore((state) => state.timeFormat); const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextMode = useSettingsStore((state) => state.plainTextMode); const plainTextMode = useSettingsStore((state) => state.plainTextMode);
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity); const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
// Initialize with reply/forward data if provided // Initialize with reply/forward data if provided
const getInitialTo = () => { const getInitialTo = () => {
@@ -213,6 +214,8 @@ export function EmailComposer({
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null); const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
const [smimePassphraseInput, setSmimePassphraseInput] = useState(''); const [smimePassphraseInput, setSmimePassphraseInput] = useState('');
const [smimePassphraseError, setSmimePassphraseError] = useState(''); const [smimePassphraseError, setSmimePassphraseError] = useState('');
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
const saveTemplateModalRef = useFocusTrap({ const saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate, isActive: showSaveAsTemplate,
@@ -226,6 +229,12 @@ export function EmailComposer({
restoreFocus: true, restoreFocus: true,
}); });
const attachmentWarningRef = useFocusTrap({
isActive: showAttachmentWarning,
onEscape: () => setShowAttachmentWarning(false),
restoreFocus: true,
});
const { client } = useAuthStore(); const { client } = useAuthStore();
const identities = useIdentityStore((s) => s.identities); const identities = useIdentityStore((s) => s.identities);
const primaryIdentity = identities[0] ?? null; const primaryIdentity = identities[0] ?? null;
@@ -533,17 +542,18 @@ export function EmailComposer({
} }
}, [client, t]); }, [client, t]);
const handleImageUpload = useCallback(async (file: File): Promise<string | null> => { const handleImageUpload = useCallback((file: File): Promise<string | null> => {
if (!client) return null; return new Promise((resolve) => {
try { const reader = new FileReader();
const { blobId } = await client.uploadBlob(file); reader.onload = (e) => resolve((e.target?.result as string) ?? null);
return await client.fetchBlobAsObjectUrl(blobId, file.name, file.type); reader.onerror = () => {
} catch (error) { debug.error(`Failed to read inline image ${file.name}`);
debug.error(`Failed to upload inline image ${file.name}:`, error); toast.error(t('upload_failed', { filename: file.name }));
toast.error(t('upload_failed', { filename: file.name })); resolve(null);
return null; };
} reader.readAsDataURL(file);
}, [client, t]); });
}, [t]);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files) return; if (!event.target.files) return;
@@ -723,7 +733,7 @@ export function EmailComposer({
return undefined; return undefined;
}; };
const handleSend = async () => { const handleSend = async (skipAttachmentCheck = false) => {
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean); const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean); const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
@@ -742,6 +752,21 @@ export function EmailComposer({
return; return;
} }
// Attachment reminder check
if (!skipAttachmentCheck && attachmentReminderEnabled) {
const hasAttachments = attachments.some(att => att.blobId && !att.uploading && !att.error);
if (!hasAttachments) {
const bodyText = htmlToPlainText(body);
const searchText = `${subject} ${bodyText}`.toLowerCase();
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
if (matched) {
setAttachmentWarningKeyword(matched);
setShowAttachmentWarning(true);
return;
}
}
}
let finalDraftId = draftId; let finalDraftId = draftId;
if (saveTimeoutRef.current) { if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current); clearTimeout(saveTimeoutRef.current);
@@ -1002,7 +1027,7 @@ export function EmailComposer({
</div> </div>
{/* Mobile: send button in header */} {/* Mobile: send button in header */}
<Button <Button
onClick={handleSend} onClick={() => handleSend()}
disabled={!canSend} disabled={!canSend}
title={getSendTooltip()} title={getSendTooltip()}
size="sm" size="sm"
@@ -1367,7 +1392,7 @@ export function EmailComposer({
{t('discard')} {t('discard')}
</button> </button>
<Button <Button
onClick={handleSend} onClick={() => handleSend()}
disabled={!canSend} disabled={!canSend}
title={getSendTooltip()} title={getSendTooltip()}
className="hidden md:inline-flex" className="hidden md:inline-flex"
@@ -1467,6 +1492,36 @@ export function EmailComposer({
</div> </div>
)} )}
{showAttachmentWarning && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
onClick={() => setShowAttachmentWarning(false)}
>
<div
ref={attachmentWarningRef}
role="alertdialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
>
<div className="p-6">
<h2 className="text-lg font-semibold text-foreground">{t('forgot_attachment.title')}</h2>
<p className="mt-2 text-sm text-muted-foreground">
{t('forgot_attachment.message', { keyword: attachmentWarningKeyword })}
</p>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
{t('forgot_attachment.back')}
</Button>
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true); }}>
{t('forgot_attachment.send_anyway')}
</Button>
</div>
</div>
</div>
)}
{showCloseDialog && ( {showCloseDialog && (
<div <div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150" className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
+30 -26
View File
@@ -89,17 +89,18 @@ const getMailboxIcon = (role?: string) => {
} }
}; };
// Get current label/color from email keywords (supports both $label: and legacy $color:) // Get all active label/color tag IDs from email keywords
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => { const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return null; if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) { for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
return key.startsWith("$label:") tags.push(
? key.slice("$label:".length) key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
: key.slice("$color:".length); );
} }
} }
return null; return tags;
}; };
export function EmailContextMenu({ export function EmailContextMenu({
@@ -137,7 +138,7 @@ export function EmailContextMenu({
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const isDraft = email.keywords?.['$draft'] === true; const isDraft = email.keywords?.['$draft'] === true;
const currentColor = getCurrentColor(email.keywords); const currentColors = getCurrentColors(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1; const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk'; const isInJunkFolder = currentMailboxRole === 'junk';
@@ -306,24 +307,27 @@ export function EmailContextMenu({
{/* Set tag submenu - only for single email */} {/* Set tag submenu - only for single email */}
{!showBatchActions && ( {!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}> <ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
{colorOptions.map((option) => ( {colorOptions.map((option) => {
<button const isActive = currentColors.includes(option.value);
key={option.value} return (
role="menuitem" <button
onClick={() => handleAction(() => onSetColorTag?.(option.value))} key={option.value}
className={cn( role="menuitem"
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer", onClick={() => handleAction(() => onSetColorTag?.(option.value))}
currentColor === option.value && "bg-accent font-medium" className={cn(
)} "w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
> isActive && "bg-accent font-medium"
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} /> )}
<span className="flex-1">{option.name}</span> >
{currentColor === option.value && ( <span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" /> <span className="flex-1">{option.name}</span>
)} {isActive && (
</button> <Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
))} )}
{currentColor && ( </button>
);
})}
{currentColors.length > 0 && (
<> <>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
+1 -1
View File
@@ -94,7 +94,7 @@ export function EmailHoverActions({
onToggleStar?.(); onToggleStar?.();
break; break;
case "markRead": case "markRead":
onMarkAsRead?.(!isUnread); onMarkAsRead?.(isUnread);
break; break;
case "archive": case "archive":
onArchive?.(); onArchive?.();
+20 -13
View File
@@ -15,7 +15,7 @@ import { useLongPress } from "@/hooks/use-long-press";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { EmailIdentityBadge } from "./email-identity-badge"; import { EmailIdentityBadge } from "./email-identity-badge";
import { EmailHoverActions } from "./email-hover-actions"; import { EmailHoverActions } from "./email-hover-actions";
import { getEmailColorTag } from "@/lib/thread-utils"; import { getEmailColorTags } from "@/lib/thread-utils";
interface EmailListItemProps { interface EmailListItemProps {
email: Email; email: Email;
@@ -32,7 +32,7 @@ interface EmailListItemProps {
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) { export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
const t = useTranslations('email_viewer'); const t = useTranslations('email_viewer');
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore(); const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -44,13 +44,18 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const isImportant = email.keywords?.["$important"]; const isImportant = email.keywords?.["$important"];
const isAnswered = email.keywords?.$answered; const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded; const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0]; // In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tag using keyword definitions from settings // Resolve color tags using keyword definitions from settings
const colorTagId = getEmailColorTag(email.keywords); const colorTagIds = getEmailColorTags(email.keywords);
const keywordDef = colorTagId ? emailKeywords.find(k => k.id === colorTagId) : null; const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
// Use first tag for background coloring
const keywordDef = keywordDefs[0] ?? null;
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
// Drag and drop functionality // Drag and drop functionality
@@ -196,7 +201,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
</> </>
)} )}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />} {email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />} {keywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
<span className={cn( <span className={cn(
'text-xs tabular-nums', 'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground' isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
@@ -246,15 +253,15 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 flex-shrink-0"> <div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && ( {keywordDefs.map((kd) => (
<span className={cn( <span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full", "inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted" KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}> )}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} /> <span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{keywordDef.label} {kd.label}
</span> </span>
)} ))}
<span className={cn( <span className={cn(
"text-xs tabular-nums", "text-xs tabular-nums",
isUnread isUnread
+133 -85
View File
@@ -8,7 +8,7 @@ import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/ema
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime } from "@/lib/utils"; import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
import { import {
Reply, Reply,
@@ -193,16 +193,17 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
return 'Attachment'; return 'Attachment';
}; };
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => { const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return null; if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) { for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
return key.startsWith("$label:") tags.push(
? key.slice("$label:".length) key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
: key.slice("$color:".length); );
} }
} }
return null; return tags;
}; };
// Helper function to format recipients with contextual display // Helper function to format recipients with contextual display
@@ -887,6 +888,9 @@ export function EmailViewer({
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition); const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
@@ -933,7 +937,8 @@ export function EmailViewer({
const moveMenuRef = useRef<HTMLDivElement>(null); const moveMenuRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null); const toolbarRef = useRef<HTMLDivElement>(null);
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set()); const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
const currentColor = getCurrentColor(email?.keywords); const currentColors = getCurrentColors(email?.keywords);
const currentColor = currentColors[0] ?? null;
// S/MIME state // S/MIME state
const [smimeStatus, setSmimeStatus] = useState<SmimeStatus | null>(null); const [smimeStatus, setSmimeStatus] = useState<SmimeStatus | null>(null);
@@ -1152,9 +1157,27 @@ export function EmailViewer({
} }
); );
const autoMarkedEmailRef = useRef<string | null>(null);
// Reset auto-mark tracking when email changes
useEffect(() => {
autoMarkedEmailRef.current = null;
}, [email?.id]);
useEffect(() => { useEffect(() => {
// Mark as read when email is viewed, respecting the delay setting // Mark as read when email is viewed, respecting the delay setting
if (!email || email.keywords?.$seen || !onMarkAsRead) { if (!email || !onMarkAsRead) {
return;
}
// Already read — record that so manual unread toggle won't re-trigger auto-mark
if (email.keywords?.$seen) {
autoMarkedEmailRef.current = email.id;
return;
}
// Don't re-trigger if we already auto-marked this email (user may have toggled it back to unread)
if (autoMarkedEmailRef.current === email.id) {
return; return;
} }
@@ -1167,17 +1190,18 @@ export function EmailViewer({
// Instant mark // Instant mark
if (markAsReadDelay === 0) { if (markAsReadDelay === 0) {
autoMarkedEmailRef.current = email.id;
onMarkAsRead(email.id, true); onMarkAsRead(email.id, true);
return; return;
} }
// Delayed mark // Delayed mark
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
autoMarkedEmailRef.current = email.id;
onMarkAsRead(email.id, true); onMarkAsRead(email.id, true);
}, markAsReadDelay); }, markAsReadDelay);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
// eslint-disable-next-line react-hooks/exhaustive-deps -- email?.id changes when email changes, which is the intended trigger
}, [email?.id, email?.keywords?.$seen, onMarkAsRead]); }, [email?.id, email?.keywords?.$seen, onMarkAsRead]);
// Reset external content permission and quick reply when email changes // Reset external content permission and quick reply when email changes
@@ -2290,9 +2314,11 @@ export function EmailViewer({
// Use shared sanitization config as base (more secure) // Use shared sanitization config as base (more secure)
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG }; const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
// Check if sender is trusted // Check if sender is trusted (localStorage list or address book)
const senderEmail = email.from?.[0]?.email?.toLowerCase(); const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false; const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
// Block external content based on policy: // Block external content based on policy:
// 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted // 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted
@@ -2399,7 +2425,7 @@ export function EmailViewer({
html: '<p style="color: var(--color-muted-foreground);">No content available</p>', html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
isHtml: false isHtml: false
}; };
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, cidBlobUrls]); }, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, isTrustedAddressBookSender, trustedSendersAddressBook, cidBlobUrls]);
// Override email content with S/MIME decrypted content when available // Override email content with S/MIME decrypted content when available
const effectiveEmailContent = useMemo(() => { const effectiveEmailContent = useMemo(() => {
@@ -3036,43 +3062,51 @@ export function EmailViewer({
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }} onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
className={cn( className={cn(
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2", "h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
currentColor && "bg-muted/50" currentColors.length > 0 && "bg-muted/50"
)} )}
title={t('set_color')} title={t('set_color')}
> >
{(() => { {currentColors.length > 0 ? (
const kw = currentColor ? emailKeywords.find(k => k.id === currentColor) : null; <>
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; <span className="flex items-center gap-0.5">
return dotClass ? ( {currentColors.slice(0, 3).map((tagId) => {
<> const kw = emailKeywords.find(k => k.id === tagId);
<span className={cn("w-3 h-3 rounded-full", dotClass)} /> return kw ? <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot)} /> : null;
{showToolbarLabels && <span className="text-xs font-medium text-foreground">{kw!.label}</span>} })}
</> </span>
) : ( {showToolbarLabels && currentColors.length === 1 && (
<> <span className="text-xs font-medium text-foreground">
<Tag className="w-4 h-4 text-muted-foreground" /> {emailKeywords.find(k => k.id === currentColors[0])?.label}
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>} </span>
</> )}
); </>
})()} ) : (
<>
<Tag className="w-4 h-4 text-muted-foreground" />
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
</>
)}
</button> </button>
{tagMenuOpen && ( {tagMenuOpen && (
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10"> <div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
{colorOptions.map((option) => ( {colorOptions.map((option) => {
<button const isActive = currentColors.includes(option.value);
key={option.value} return (
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }} <button
className={cn( key={option.value}
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2", onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
currentColor === option.value && "bg-accent font-medium" className={cn(
)} "w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
> isActive && "bg-accent font-medium"
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} /> )}
<span className="truncate">{option.name}</span> >
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />} <span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
</button> <span className="truncate">{option.name}</span>
))} {isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
{currentColor && ( </button>
);
})}
{currentColors.length > 0 && (
<> <>
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
<button <button
@@ -3280,21 +3314,24 @@ export function EmailViewer({
</button> </button>
{moreMenuSub === 'tag' && ( {moreMenuSub === 'tag' && (
<div className="absolute right-full top-0 mr-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10"> <div className="absolute right-full top-0 mr-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
{colorOptions.map((option) => ( {colorOptions.map((option) => {
<button const isActive = currentColors.includes(option.value);
key={option.value} return (
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }} <button
className={cn( key={option.value}
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2", onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
currentColor === option.value && "bg-accent font-medium" className={cn(
)} "w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
> isActive && "bg-accent font-medium"
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} /> )}
<span className="truncate">{option.name}</span> >
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />} <span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
</button> <span className="truncate">{option.name}</span>
))} {isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
{currentColor && ( </button>
);
})}
{currentColors.length > 0 && (
<> <>
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
<button <button
@@ -3470,21 +3507,24 @@ export function EmailViewer({
<> <>
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div> <div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
{colorOptions.map((option) => ( {colorOptions.map((option) => {
<button const isActive = currentColors.includes(option.value);
key={option.value} return (
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }} <button
className={cn( key={option.value}
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3", onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
currentColor === option.value && "bg-accent font-medium" className={cn(
)} "w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
> isActive && "bg-accent font-medium"
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} /> )}
<span className="truncate">{option.name}</span> >
{currentColor === option.value && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />} <span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
</button> <span className="truncate">{option.name}</span>
))} {isActive && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
{currentColor && ( </button>
);
})}
{currentColors.length > 0 && (
<button <button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }} onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground" className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
@@ -3630,14 +3670,18 @@ export function EmailViewer({
)} /> )} />
</button> </button>
)} )}
{/* Color tag dot */} {/* Color tag dots */}
{currentColor && (() => { {currentColors.length > 0 && (
const kw = emailKeywords.find(k => k.id === currentColor); <span className="flex items-center gap-0.5">
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; {currentColors.map((tagId) => {
return dotClass ? ( const kw = emailKeywords.find(k => k.id === tagId);
<span className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} /> const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
) : null; return dotClass ? (
})()} <span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
) : null;
})}
</span>
)}
{isImportant && ( {isImportant && (
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center"> <span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
{t('important')} {t('important')}
@@ -4507,7 +4551,7 @@ export function EmailViewer({
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
hasCalendarInvitation) && ( hasCalendarInvitation) && (
<div className="border-b border-border bg-muted/30 isolate"> <div className="border-b border-border bg-muted/30 isolate">
<div className="max-w-4xl mx-auto px-6 py-1.5"> <div className="max-w-6xl mx-auto px-6 py-1.5">
<div className="flex flex-col gap-3 isolate"> <div className="flex flex-col gap-3 isolate">
{/* External Content Controls */} {/* External Content Controls */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && ( {hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
@@ -4526,7 +4570,11 @@ export function EmailViewer({
onClick={() => { onClick={() => {
const senderEmail = email.from?.[0]?.email; const senderEmail = email.from?.[0]?.email;
if (senderEmail) { if (senderEmail) {
addTrustedSender(senderEmail); if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
setAllowExternalContent(true); setAllowExternalContent(true);
} }
}} }}
@@ -4874,7 +4922,7 @@ export function EmailViewer({
if (client && supportsSync) { if (client && supportsSync) {
createContact(client, contactData).then(() => toast.success('Contact added')); createContact(client, contactData).then(() => toast.success('Contact added'));
} else { } else {
addLocalContact({ id: `local-${crypto.randomUUID()}`, addressBookIds: {}, ...contactData } as ContactCard); addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
toast.success('Contact added'); toast.success('Contact added');
} }
}} }}
+12
View File
@@ -93,6 +93,18 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
return () => document.removeEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler);
}, [isOpen]); }, [isOpen]);
// Close on scroll so the popover does not float while content moves.
useEffect(() => {
if (!isOpen) return;
const handler = () => handleClose();
document.addEventListener("scroll", handler, true);
window.addEventListener("resize", handler);
return () => {
document.removeEventListener("scroll", handler, true);
window.removeEventListener("resize", handler);
};
}, [isOpen]);
const handleViewContact = () => { const handleViewContact = () => {
if (onViewContact) { if (onViewContact) {
onViewContact(contact ?? null, email); onViewContact(contact ?? null, email);
+1
View File
@@ -118,6 +118,7 @@ export function RichTextEditor({
); );
if (imageFiles.length === 0) return false; if (imageFiles.length === 0) return false;
event.preventDefault(); event.preventDefault();
event.stopPropagation();
for (const file of imageFiles) { for (const file of imageFiles) {
upload(file).then((url) => { upload(file).then((url) => {
if (url) { if (url) {
+13 -2
View File
@@ -31,6 +31,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { isFilePreviewable } from "@/lib/file-preview"; import { isFilePreviewable } from "@/lib/file-preview";
@@ -84,6 +85,10 @@ export function ThreadConversationView({
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const { client } = useAuthStore();
// Track which emails are expanded (most recent by default) // Track which emails are expanded (most recent by default)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()); const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
@@ -164,7 +169,9 @@ export function ThreadConversationView({
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}> <div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
{emails.map((email, index) => { {emails.map((email, index) => {
const senderEmail = email.from?.[0]?.email?.toLowerCase(); const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false; const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
return ( return (
<EmailCard <EmailCard
key={email.id} key={email.id}
@@ -175,7 +182,11 @@ export function ThreadConversationView({
onToggleExpanded={() => toggleExpanded(email.id)} onToggleExpanded={() => toggleExpanded(email.id)}
onAllowExternal={() => toggleAllowExternal(email.id)} onAllowExternal={() => toggleAllowExternal(email.id)}
onTrustSender={senderEmail ? () => { onTrustSender={senderEmail ? () => {
addTrustedSender(senderEmail); if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
toggleAllowExternal(email.id); toggleAllowExternal(email.id);
} : undefined} } : undefined}
onReply={onReply ? () => onReply(email) : undefined} onReply={onReply ? () => onReply(email) : undefined}
+36 -19
View File
@@ -9,7 +9,7 @@ import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSqu
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils"; import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
import { useEmailDrag } from "@/hooks/use-email-drag"; import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press"; import { useLongPress } from "@/hooks/use-long-press";
import { ThreadEmailItem } from "./thread-email-item"; import { ThreadEmailItem } from "./thread-email-item";
@@ -55,8 +55,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered; const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded; const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0]; const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); // In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -64,9 +67,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color and keyword definition from keyword definitions if not passed directly // Resolve color tags using keyword definitions
const tagId = getEmailColorTag(email.keywords); const tagIds = getEmailColorTags(email.keywords);
const resolvedKeywordDef = tagId ? emailKeywords.find(k => k.id === tagId) : null; const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
const resolvedColorTag = (() => { const resolvedColorTag = (() => {
if (colorTag) return colorTag; if (colorTag) return colorTag;
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null; return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
@@ -209,7 +213,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</> </>
)} )}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />} {email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{resolvedKeywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || 'bg-gray-400')} />} {resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
<span className={cn( <span className={cn(
'text-xs tabular-nums', 'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground' isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
@@ -252,15 +258,15 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 flex-shrink-0"> <div className="flex items-center gap-1.5 flex-shrink-0">
{resolvedKeywordDef && ( {resolvedKeywordDefs.map((kd) => (
<span className={cn( <span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full", "inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted" KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}> )}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} /> <span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{resolvedKeywordDef.label} {kd.label}
</span> </span>
)} ))}
<span className={cn( <span className={cn(
"text-xs tabular-nums", "text-xs tabular-nums",
isUnread isUnread
@@ -339,7 +345,16 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : ''; const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const displayNames = showRecipient
? Array.from(new Set(
thread.emails.flatMap(e => (e.to ?? []).map(r => r.name || r.email.split('@')[0]))
)).slice(0, 4)
: participantNames;
const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0];
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({ const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
email: latestEmail, email: latestEmail,
@@ -522,8 +537,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{!isFocusedMailLayout && density !== 'extra-compact' && ( {!isFocusedMailLayout && density !== 'extra-compact' && (
<Avatar <Avatar
name={latestEmail.from?.[0]?.name} name={avatarPerson?.name}
email={latestEmail.from?.[0]?.email} email={avatarPerson?.email}
size="md" size="md"
className="flex-shrink-0 shadow-sm" className="flex-shrink-0 shadow-sm"
/> />
@@ -537,7 +552,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
'w-32 shrink-0 truncate text-sm lg:w-44', 'w-32 shrink-0 truncate text-sm lg:w-44',
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80' hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
)}> )}>
{participantNames.join(', ')} {displayNames.join(', ')}
</span> </span>
<span <span
className={cn( className={cn(
@@ -572,7 +587,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</> </>
)} )}
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />} {hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />} {keywordDef && (
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
)}
<span className={cn( <span className={cn(
'text-xs tabular-nums', 'text-xs tabular-nums',
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground' hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
@@ -591,7 +608,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
? "font-bold text-foreground" ? "font-bold text-foreground"
: "font-medium text-muted-foreground" : "font-medium text-muted-foreground"
)}> )}>
{participantNames.join(", ")} {displayNames.join(", ")}
</span> </span>
<span <span
className={cn( className={cn(
+1 -1
View File
@@ -837,7 +837,7 @@ export function FileBrowser({
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="flex flex-col h-full" className="flex flex-col flex-1 min-h-0"
onClick={handleContainerClick} onClick={handleContainerClick}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
+2 -2
View File
@@ -16,7 +16,7 @@ import type {
FilterActionType, FilterActionType,
} from "@/lib/jmap/sieve-types"; } from "@/lib/jmap/sieve-types";
import type { Mailbox } from "@/lib/jmap/types"; import type { Mailbox } from "@/lib/jmap/types";
import { buildMailboxTree, flattenMailboxTree, type MailboxNode } from "@/lib/utils"; import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
interface FilterRuleModalProps { interface FilterRuleModalProps {
rule?: FilterRule; rule?: FilterRule;
@@ -109,7 +109,7 @@ export function FilterRuleModal({
} }
onSave({ onSave({
id: rule?.id || crypto.randomUUID(), id: rule?.id || generateUUID(),
name: trimmedName, name: trimmedName,
enabled: rule?.enabled ?? true, enabled: rule?.enabled ?? true,
matchType, matchType,
@@ -14,6 +14,10 @@ function useSyncIdentities() {
const syncIdentities = useAuthStore((state) => state.syncIdentities); const syncIdentities = useAuthStore((state) => state.syncIdentities);
return syncIdentities; return syncIdentities;
} }
function useRefreshIdentities() {
return useAuthStore((state) => state.refreshIdentities);
}
import type { Identity, EmailAddress } from '@/lib/jmap/types'; import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { toast } from '@/stores/toast-store'; import { toast } from '@/stores/toast-store';
import { useFocusTrap } from '@/hooks/use-focus-trap'; import { useFocusTrap } from '@/hooks/use-focus-trap';
@@ -49,6 +53,15 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary); const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary);
const syncIdentities = useSyncIdentities(); const syncIdentities = useSyncIdentities();
const refreshIdentitiesFromServer = useRefreshIdentities();
// Refresh identities from server whenever the modal is opened
useEffect(() => {
if (isOpen) {
refreshIdentitiesFromServer();
}
}, [isOpen, refreshIdentitiesFromServer]);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
+2 -2
View File
@@ -4,7 +4,7 @@ import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight } from 'lucide-react'; import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight } from 'lucide-react';
import { icons as lucideIcons, type LucideIcon } from 'lucide-react'; import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn, generateUUID } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { IconPicker } from './icon-picker'; import { IconPicker } from './icon-picker';
@@ -207,7 +207,7 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
}); });
const handleCreate = useCallback((data: SidebarAppFormData) => { const handleCreate = useCallback((data: SidebarAppFormData) => {
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const id = `app-${generateUUID()}`;
addSidebarApp({ id, ...data }); addSidebarApp({ id, ...data });
setIsCreating(false); setIsCreating(false);
}, [addSidebarApp]); }, [addSidebarApp]);
-5
View File
@@ -18,7 +18,6 @@ import {
ChevronDown, ChevronDown,
Folder, Folder,
FolderOpen, FolderOpen,
Users,
User, User,
Palmtree, Palmtree,
Settings, Settings,
@@ -59,10 +58,6 @@ interface SidebarProps {
const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean, isShared?: boolean, id?: string) => { const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean, isShared?: boolean, id?: string) => {
const lowerName = name?.toLowerCase() || ""; const lowerName = name?.toLowerCase() || "";
if (id === 'shared-folders-root') {
return isExpanded ? FolderOpen : Users;
}
if (id?.startsWith('shared-account-')) { if (id?.startsWith('shared-account-')) {
return isExpanded ? FolderOpen : User; return isExpanded ? FolderOpen : User;
} }
+8
View File
@@ -6,24 +6,32 @@ import { useLocaleStore } from '@/stores/locale-store';
import enMessages from '@/locales/en/common.json'; import enMessages from '@/locales/en/common.json';
import frMessages from '@/locales/fr/common.json'; import frMessages from '@/locales/fr/common.json';
import jaMessages from '@/locales/ja/common.json'; import jaMessages from '@/locales/ja/common.json';
import koMessages from '@/locales/ko/common.json';
import esMessages from '@/locales/es/common.json'; import esMessages from '@/locales/es/common.json';
import itMessages from '@/locales/it/common.json'; import itMessages from '@/locales/it/common.json';
import deMessages from '@/locales/de/common.json'; import deMessages from '@/locales/de/common.json';
import lvMessages from '@/locales/lv/common.json';
import nlMessages from '@/locales/nl/common.json'; import nlMessages from '@/locales/nl/common.json';
import plMessages from '@/locales/pl/common.json';
import ptMessages from '@/locales/pt/common.json'; import ptMessages from '@/locales/pt/common.json';
import ruMessages from '@/locales/ru/common.json'; import ruMessages from '@/locales/ru/common.json';
import zhMessages from '@/locales/zh/common.json';
// Pre-loaded translations (loaded at build time, not runtime) // Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = { const ALL_MESSAGES = {
en: enMessages, en: enMessages,
fr: frMessages, fr: frMessages,
ja: jaMessages, ja: jaMessages,
ko: koMessages,
es: esMessages, es: esMessages,
it: itMessages, it: itMessages,
de: deMessages, de: deMessages,
lv: lvMessages,
nl: nlMessages, nl: nlMessages,
pl: plMessages,
pt: ptMessages, pt: ptMessages,
ru: ruMessages, ru: ruMessages,
zh: zhMessages,
}; };
interface IntlProviderProps { interface IntlProviderProps {
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { useEffect, useState } from "react";
import { X, Download } from "lucide-react";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
const DISMISSED_KEY = "pwa-install-dismissed";
export function PWAInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
if (localStorage.getItem(DISMISSED_KEY)) return;
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setShowPrompt(true);
};
window.addEventListener("beforeinstallprompt", handler);
return () => {
window.removeEventListener("beforeinstallprompt", handler);
};
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") {
setDeferredPrompt(null);
setShowPrompt(false);
}
};
const handleDismiss = () => {
setShowPrompt(false);
};
const handleDismissForever = () => {
localStorage.setItem(DISMISSED_KEY, "1");
setShowPrompt(false);
};
if (!showPrompt || !deferredPrompt) {
return null;
}
return (
<div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-start gap-3">
<Download className="w-5 h-5 mt-0.5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div>
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
Install Bulwark
</h3>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
Install our app for quick access and offline support.
</p>
</div>
</div>
<button
onClick={handleDismiss}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
aria-label="Dismiss install prompt"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<button
onClick={handleDismiss}
className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
>
Not now
</button>
<button
onClick={handleInstall}
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
>
Install
</button>
</div>
<button
onClick={handleDismissForever}
className="w-full text-xs text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors text-center"
>
Don&apos;t remind me again
</button>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
"use client";
import { useEffect } from "react";
export function ServiceWorkerRegistration() {
useEffect(() => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
return;
}
if (process.env.NODE_ENV !== "production") {
// In development, unregister any previously installed service worker
// and clear its caches so hot reload always serves fresh assets.
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((registration) => registration.unregister());
});
if ("caches" in window) {
caches.keys().then((keys) => {
keys.forEach((key) => caches.delete(key));
});
}
return;
}
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log("Service Worker registered successfully:", registration);
})
.catch((error) => {
console.error("Service Worker registration failed:", error);
});
}, []);
return null;
}
@@ -157,6 +157,7 @@ function DisplayNameSection() {
<Input <Input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder={displayName || t('display_name.placeholder')}
className="w-48" className="w-48"
/> />
<Button <Button
@@ -523,7 +524,7 @@ export function AccountSecuritySettings() {
if (isStalwart === false) { if (isStalwart === false) {
return ( return (
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
<p className="text-sm text-muted-foreground py-4">{t('not_available')}</p> <div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: t('not_available') }} />
</SettingsSection> </SettingsSection>
); );
} }
@@ -0,0 +1,247 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Book, Pencil, Share2, Tag } from "lucide-react";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
import { SettingsSection } from "./settings-section";
import { cn } from "@/lib/utils";
import type { AddressBook } from "@/lib/jmap/types";
function AddressBookEditRow({
initial,
onSave,
onCancel,
isLoading,
}: {
initial: string;
onSave: (name: string) => void;
onCancel: () => void;
isLoading: boolean;
}) {
const t = useTranslations("contacts.address_books");
const tCal = useTranslations("calendar.management");
const [name, setName] = useState(initial);
const isValid = name.trim().length > 0;
return (
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name_label")}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && isValid) onSave(name.trim());
if (e.key === "Escape") onCancel();
}}
className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
disabled={isLoading}
/>
</div>
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => isValid && onSave(name.trim())}
disabled={isLoading || !isValid}
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{tCal("save")}
</button>
<button
onClick={onCancel}
disabled={isLoading}
className="px-3 py-1.5 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{tCal("cancel")}
</button>
</div>
</div>
);
}
export function AddressBookManagementSettings() {
const t = useTranslations("contacts.address_books");
const tContacts = useTranslations("contacts");
const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore();
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, renameKeyword } = useContactStore();
const [editingId, setEditingId] = useState<string | null>(null);
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (client && addressBooks.length === 0) {
fetchAddressBooks(client);
}
}, [client, addressBooks.length, fetchAddressBooks]);
const handleUpdate = async (book: AddressBook, newName: string) => {
if (!client) return;
setIsLoading(true);
try {
await renameAddressBook(client, book, newName);
setEditingId(null);
toast.success(t("renamed"));
} catch {
toast.error(t("rename_failed"));
} finally {
setIsLoading(false);
}
};
// Group: personal first, then by shared account
const personal = addressBooks.filter((b) => !b.isShared);
const sharedGroups = new Map<string, { accountName: string; books: AddressBook[] }>();
for (const book of addressBooks) {
if (!book.isShared || !book.accountId) continue;
const key = book.accountId;
const existing = sharedGroups.get(key);
if (existing) existing.books.push(book);
else sharedGroups.set(key, { accountName: book.accountName || book.accountId, books: [book] });
}
const renderBook = (book: AddressBook) => {
if (editingId === book.id) {
return (
<AddressBookEditRow
key={book.id}
initial={book.name}
onSave={(name) => handleUpdate(book, name)}
onCancel={() => setEditingId(null)}
isLoading={isLoading}
/>
);
}
const canRename = !book.isShared || book.myRights?.mayWrite !== false;
return (
<div
key={book.id}
className={cn(
"flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
)}
>
<Book className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{book.name}</span>
</div>
{book.isDefault && (
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{t("default")}
</span>
)}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{canRename && (
<button
type="button"
onClick={() => setEditingId(book.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t("rename")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
};
// Collect keywords with counts
const keywordCounts: Record<string, number> = {};
for (const c of contacts) {
if (c.kind === "group" || !c.keywords) continue;
for (const [kw, active] of Object.entries(c.keywords)) {
if (active) keywordCounts[kw] = (keywordCounts[kw] || 0) + 1;
}
}
const sortedKeywords = Object.entries(keywordCounts).sort(([a], [b]) => a.localeCompare(b));
const handleRenameKeyword = async (oldKw: string, newKw: string) => {
setIsLoading(true);
try {
await renameKeyword(supportsSync && client ? client : null, oldKw, newKw);
setEditingKeyword(null);
toast.success(tContacts("category_renamed"));
} catch {
toast.error(tContacts("category_rename_failed"));
} finally {
setIsLoading(false);
}
};
return (
<>
<SettingsSection title={tSettings("manage_title")} description={tSettings("manage_description")}>
<div className="space-y-2">
{personal.map(renderBook)}
{Array.from(sharedGroups.entries()).map(([accountId, group]) => (
<div key={accountId} className="mt-4 space-y-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
{t("shared_prefix", { name: group.accountName })}
</h4>
{group.books.map(renderBook)}
</div>
))}
{addressBooks.length === 0 && (
<p className="text-sm text-muted-foreground py-2">{tSettings("no_address_books")}</p>
)}
</div>
</SettingsSection>
<div className="mt-8">
<SettingsSection title={tSettings("categories_title")} description={tSettings("categories_description")}>
<div className="space-y-2">
{sortedKeywords.map(([keyword, count]) => {
if (editingKeyword === keyword) {
return (
<AddressBookEditRow
key={keyword}
initial={keyword}
onSave={(name) => handleRenameKeyword(keyword, name)}
onCancel={() => setEditingKeyword(null)}
isLoading={isLoading}
/>
);
}
return (
<div
key={keyword}
className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
>
<Tag className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{keyword}</span>
</div>
<span className="text-xs text-muted-foreground tabular-nums">{count}</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => setEditingKeyword(keyword)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={tContacts("rename_category")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
{sortedKeywords.length === 0 && (
<p className="text-sm text-muted-foreground py-2">{tSettings("no_categories")}</p>
)}
</div>
</SettingsSection>
</div>
</>
);
}
+58
View File
@@ -8,6 +8,11 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store'; import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
import { ExternalLink } from 'lucide-react';
import { SpamSiegeGame } from './spam-siege-game';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
export function AdvancedSettings() { export function AdvancedSettings() {
const t = useTranslations('settings.advanced'); const t = useTranslations('settings.advanced');
@@ -18,6 +23,20 @@ export function AdvancedSettings() {
const [showResetConfirm, setShowResetConfirm] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore(); const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const [showGame, setShowGame] = useState(false);
const logoClickCount = useRef(0);
const logoClickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleLogoClick = () => {
logoClickCount.current++;
if (logoClickTimer.current) clearTimeout(logoClickTimer.current);
if (logoClickCount.current >= 3) {
logoClickCount.current = 0;
setShowGame(true);
} else {
logoClickTimer.current = setTimeout(() => { logoClickCount.current = 0; }, 2000);
}
};
const handleExport = () => { const handleExport = () => {
const settingsJson = exportSettings(); const settingsJson = exportSettings();
@@ -65,6 +84,44 @@ export function AdvancedSettings() {
}; };
return ( return (
<>
{showGame && <SpamSiegeGame onClose={() => setShowGame(false)} />}
{/* About */}
<div className="rounded-lg border border-border bg-card p-5 mb-6">
<div className="flex items-center gap-4">
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About">
<div className="shrink-0">
<img
src="/branding/Bulwark_Logo_Color.svg"
alt="Bulwark"
className="w-12 h-12 object-contain dark:hidden group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
/>
<img
src="/branding/Bulwark_Logo_White.svg"
alt="Bulwark"
className="w-12 h-12 object-contain hidden dark:block group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">
{t('about.title')}
</p>
<p className="text-xs text-muted-foreground group-hover/about:translate-x-0.5 group-active/about:translate-y-px transition-transform">
v{APP_VERSION} <span className="text-muted-foreground/60">({GIT_COMMIT})</span>
</p>
</div>
</button>
<a
href="https://github.com/bulwarkmail/webmail"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
GitHub <ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
{/* Debug Mode */} {/* Debug Mode */}
{!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && ( {!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
@@ -147,5 +204,6 @@ export function AdvancedSettings() {
</Button> </Button>
</SettingItem> </SettingItem>
</SettingsSection> </SettingsSection>
</>
); );
} }
+11
View File
@@ -19,6 +19,7 @@ export function CalendarSettings() {
showWeekNumbers, showWeekNumbers,
enableCalendarTasks, enableCalendarTasks,
showTasksOnCalendar, showTasksOnCalendar,
showBirthdayCalendar,
calendarHoverPreview, calendarHoverPreview,
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
@@ -98,6 +99,16 @@ export function CalendarSettings() {
/> />
</SettingItem> </SettingItem>
<SettingItem
label={t('show_birthday_calendar')}
description={t('show_birthday_calendar_desc')}
>
<ToggleSwitch
checked={showBirthdayCalendar}
onChange={(checked) => updateSetting('showBirthdayCalendar', checked)}
/>
</SettingItem>
{isFeatureEnabled('calendarTasksEnabled') && ( {isFeatureEnabled('calendarTasksEnabled') && (
<> <>
<SettingItem <SettingItem
+74 -2
View File
@@ -11,8 +11,9 @@ import { useEmailStore } from '@/stores/email-store';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal'; import { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react'; import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail, X } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
import { useContactStore } from '@/stores/contact-store';
const MAIL_LAYOUT_PREVIEW_ROWS = [ const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false }, { sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
@@ -110,6 +111,8 @@ export function EmailSettings() {
} }
}, []); }, []);
const [newKeyword, setNewKeyword] = useState('');
const { const {
markAsReadDelay, markAsReadDelay,
deleteAction, deleteAction,
@@ -129,12 +132,16 @@ export function EmailSettings() {
hoverActionsMode, hoverActionsMode,
hoverActionsCorner, hoverActionsCorner,
trustedSenders, trustedSenders,
trustedSendersAddressBook,
attachmentReminderEnabled,
attachmentReminderKeywords,
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
const { trustedSenderEmails } = useContactStore();
// Get count label for trusted senders button // Get count label for trusted senders button
const getTrustedSendersCount = () => { const getTrustedSendersCount = () => {
const count = trustedSenders.length; const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length;
if (count === 0) return t('trusted_senders.count_zero'); if (count === 0) return t('trusted_senders.count_zero');
if (count === 1) return t('trusted_senders.count_one'); if (count === 1) return t('trusted_senders.count_one');
return t('trusted_senders.count_other', { count }); return t('trusted_senders.count_other', { count });
@@ -337,6 +344,63 @@ export function EmailSettings() {
/> />
</SettingItem> </SettingItem>
{/* Attachment Reminder */}
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
<ToggleSwitch
checked={attachmentReminderEnabled}
onChange={(checked) => updateSetting('attachmentReminderEnabled', checked)}
/>
</SettingItem>
{attachmentReminderEnabled && (
<div className="py-3 border-b border-border space-y-2">
<div>
<label className="text-sm font-medium text-foreground">{t('attachment_reminder.keywords_label')}</label>
<p className="text-xs text-muted-foreground mt-1">{t('attachment_reminder.keywords_description')}</p>
</div>
<div className="flex flex-wrap gap-1.5">
{attachmentReminderKeywords.map((kw) => (
<span key={kw} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-foreground">
{kw}
<button
type="button"
aria-label={t('attachment_reminder.remove')}
onClick={() => updateSetting('attachmentReminderKeywords', attachmentReminderKeywords.filter(k => k !== kw))}
className="text-muted-foreground hover:text-foreground"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
const trimmed = newKeyword.trim().toLowerCase();
if (trimmed && !attachmentReminderKeywords.includes(trimmed)) {
updateSetting('attachmentReminderKeywords', [...attachmentReminderKeywords, trimmed]);
}
setNewKeyword('');
}}
>
<input
type="text"
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
placeholder={t('attachment_reminder.add_placeholder')}
className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
/>
<button
type="submit"
disabled={!newKeyword.trim()}
className="px-3 py-1 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('attachment_reminder.add')}
</button>
</form>
</div>
)}
{/* Quick Hover Actions */} {/* Quick Hover Actions */}
{isFeatureEnabled('hoverActionsConfigEnabled') && ( {isFeatureEnabled('hoverActionsConfigEnabled') && (
<div className="py-3 border-b border-border space-y-3"> <div className="py-3 border-b border-border space-y-3">
@@ -511,6 +575,14 @@ export function EmailSettings() {
</button> </button>
</SettingItem> </SettingItem>
{/* Trusted Senders — address book storage */}
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
<ToggleSwitch
checked={trustedSendersAddressBook}
onChange={(checked) => updateSetting('trustedSendersAddressBook', checked)}
/>
</SettingItem>
{/* Trusted Senders Modal */} {/* Trusted Senders Modal */}
<TrustedSendersModal <TrustedSendersModal
isOpen={showTrustedModal} isOpen={showTrustedModal}
+19 -3
View File
@@ -99,11 +99,17 @@ function IconPicker({ currentIcon, onSelect, onClose }: {
export function FolderSettings() { export function FolderSettings() {
const t = useTranslations('settings.folders'); const t = useTranslations('settings.folders');
const { client } = useAuthStore(); const { client } = useAuthStore();
const { mailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore(); const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore();
const { folderIcons, setFolderIcon } = useSettingsStore(); const { folderIcons, setFolderIcon } = useSettingsStore();
const { isFeatureEnabled } = usePolicyStore(); const { isFeatureEnabled } = usePolicyStore();
const folderIconsAllowed = isFeatureEnabled('folderIconsEnabled'); const folderIconsAllowed = isFeatureEnabled('folderIconsEnabled');
useEffect(() => {
if (client && mailboxes.length === 0) {
fetchMailboxes(client);
}
}, [client, mailboxes.length, fetchMailboxes]);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [creatingParentId, setCreatingParentId] = useState<string | null>(null); const [creatingParentId, setCreatingParentId] = useState<string | null>(null);
const [newFolderName, setNewFolderName] = useState(''); const [newFolderName, setNewFolderName] = useState('');
@@ -200,8 +206,18 @@ export function FolderSettings() {
await deleteMailbox(client, mailboxId); await deleteMailbox(client, mailboxId);
setDeletingId(null); setDeletingId(null);
toast.success(t('folder_deleted')); toast.success(t('folder_deleted'));
} catch { } catch (err: unknown) {
toast.error(t('error_delete')); const jmapType = (err as Error & { jmapType?: string })?.jmapType;
switch (jmapType) {
case 'mailboxHasChild':
toast.error(t('error_delete_has_children'));
break;
case 'mailboxHasEmail':
toast.error(t('error_delete_has_email'));
break;
default:
toast.error(t('error_delete'));
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
+62 -3
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import React, { useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store"; import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
@@ -41,16 +41,39 @@ function KeywordRow({
keyword, keyword,
onEdit, onEdit,
onDelete, onDelete,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
isDragOver,
isDragging,
}: { }: {
keyword: KeywordDefinition; keyword: KeywordDefinition;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onDragStart: () => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: () => void;
onDragEnd: () => void;
isDragOver: boolean;
isDragging: boolean;
}) { }) {
const t = useTranslations("settings.keywords"); const t = useTranslations("settings.keywords");
const palette = KEYWORD_PALETTE[keyword.color]; const palette = KEYWORD_PALETTE[keyword.color];
return ( return (
<div className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"> <div
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
className={cn(
"flex items-center gap-3 py-2.5 px-3 rounded-md border bg-background group transition-opacity",
isDragging ? "opacity-40" : "opacity-100",
isDragOver ? "border-primary" : "border-border"
)}
>
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" /> <GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} /> <div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span> <span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
@@ -166,9 +189,39 @@ export function KeywordSettings() {
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [isMigrating, setIsMigrating] = useState(false); const [isMigrating, setIsMigrating] = useState(false);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const existingIds = emailKeywords.map((k) => k.id); const existingIds = emailKeywords.map((k) => k.id);
const handleDragStart = (index: number) => {
setDragIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (index !== dragOverIndex) setDragOverIndex(index);
};
const handleDrop = (index: number) => {
if (dragIndex === null || dragIndex === index) {
setDragIndex(null);
setDragOverIndex(null);
return;
}
const reordered = [...emailKeywords];
const [moved] = reordered.splice(dragIndex, 1);
reordered.splice(index, 0, moved);
reorderKeywords(reordered);
setDragIndex(null);
setDragOverIndex(null);
};
const handleDragEnd = () => {
setDragIndex(null);
setDragOverIndex(null);
};
const handleAdd = (keyword: KeywordDefinition) => { const handleAdd = (keyword: KeywordDefinition) => {
addKeyword(keyword); addKeyword(keyword);
setIsAdding(false); setIsAdding(false);
@@ -220,7 +273,7 @@ export function KeywordSettings() {
{t("migrating")} {t("migrating")}
</div> </div>
)} )}
{emailKeywords.map((keyword) => {emailKeywords.map((keyword, index) =>
editingId === keyword.id ? ( editingId === keyword.id ? (
<KeywordEditForm <KeywordEditForm
key={keyword.id} key={keyword.id}
@@ -238,6 +291,12 @@ export function KeywordSettings() {
setIsAdding(false); setIsAdding(false);
}} }}
onDelete={() => handleDelete(keyword.id)} onDelete={() => handleDelete(keyword.id)}
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={() => handleDrop(index)}
onDragEnd={handleDragEnd}
isDragOver={dragOverIndex === index && dragIndex !== index}
isDragging={dragIndex === index}
/> />
) )
)} )}
+28 -5
View File
@@ -20,7 +20,7 @@ const STATUS_COLORS: Record<PluginStatus, string> = {
export function PluginsSettings() { export function PluginsSettings() {
const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore(); const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore();
const { isFeatureEnabled, isPluginForceEnabled, fetchPolicy, loaded } = usePolicyStore(); const { isFeatureEnabled, isPluginForceEnabled, isPluginApproved, fetchPolicy, loaded } = usePolicyStore();
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null); const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -75,6 +75,13 @@ export function PluginsSettings() {
return; return;
} }
const requireApproval = isFeatureEnabled('requirePluginApproval');
const isApproved = plugin.adminApproved || plugin.managed || isPluginApproved(plugin.id);
if (!plugin.enabled && requireApproval && !isApproved) {
toast.info(`Plugin "${plugin.name}" requires admin approval before it can be enabled`);
return;
}
if (plugin.enabled) { if (plugin.enabled) {
disablePlugin(plugin.id); disablePlugin(plugin.id);
toast.info(`Plugin "${plugin.name}" disabled`); toast.info(`Plugin "${plugin.name}" disabled`);
@@ -108,20 +115,26 @@ export function PluginsSettings() {
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{plugins.map(plugin => ( {plugins.map(plugin => {
const requireApproval = isFeatureEnabled('requirePluginApproval');
const isApproved = plugin.adminApproved || plugin.managed || isPluginApproved(plugin.id);
const needsApproval = requireApproval && !isApproved;
return (
<PluginCard <PluginCard
key={plugin.id} key={plugin.id}
plugin={plugin} plugin={plugin}
isExpanded={expandedPlugin === plugin.id} isExpanded={expandedPlugin === plugin.id}
isForceEnabled={plugin.forceEnabled || isPluginForceEnabled(plugin.id)} isForceEnabled={plugin.forceEnabled || isPluginForceEnabled(plugin.id)}
isManaged={Boolean(plugin.managed)} isManaged={Boolean(plugin.managed)}
needsApproval={needsApproval}
controlsDisabled={!initialized} controlsDisabled={!initialized}
onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)} onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)}
onToggle={() => handleToggle(plugin)} onToggle={() => handleToggle(plugin)}
onUninstall={() => handleUninstall(plugin)} onUninstall={() => handleUninstall(plugin)}
onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)} onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)}
/> />
))} );
})}
</div> </div>
)} )}
@@ -166,6 +179,7 @@ interface PluginCardProps {
isExpanded: boolean; isExpanded: boolean;
isForceEnabled: boolean; isForceEnabled: boolean;
isManaged: boolean; isManaged: boolean;
needsApproval: boolean;
controlsDisabled: boolean; controlsDisabled: boolean;
onToggleExpand: () => void; onToggleExpand: () => void;
onToggle: () => void; onToggle: () => void;
@@ -173,7 +187,7 @@ interface PluginCardProps {
onUpdateSettings: (settings: Record<string, unknown>) => void; onUpdateSettings: (settings: Record<string, unknown>) => void;
} }
function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDisabled, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) { function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, needsApproval, controlsDisabled, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) {
return ( return (
<div className={cn( <div className={cn(
'rounded-lg border transition-colors', 'rounded-lg border transition-colors',
@@ -197,6 +211,11 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
<Server className="w-2.5 h-2.5" /> Managed <Server className="w-2.5 h-2.5" /> Managed
</span> </span>
)} )}
{needsApproval && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400">
Awaiting approval
</span>
)}
</div> </div>
<div className="flex items-center gap-2 mt-0.5"> <div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">{plugin.author}</span> <span className="text-xs text-muted-foreground">{plugin.author}</span>
@@ -206,7 +225,7 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} disabled={controlsDisabled || isForceEnabled} /> <ToggleSwitch checked={plugin.enabled} onChange={onToggle} disabled={controlsDisabled || isForceEnabled || needsApproval} />
</div> </div>
</div> </div>
@@ -217,6 +236,10 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
<p className="text-xs text-amber-600 dark:text-amber-400">This plugin is forced by an administrator and cannot be disabled or uninstalled.</p> <p className="text-xs text-amber-600 dark:text-amber-400">This plugin is forced by an administrator and cannot be disabled or uninstalled.</p>
)} )}
{needsApproval && (
<p className="text-xs text-orange-600 dark:text-orange-400">This plugin is awaiting admin approval and cannot be enabled until an administrator approves it.</p>
)}
{/* Description */} {/* Description */}
{plugin.description && ( {plugin.description && (
<p className="text-xs text-muted-foreground">{plugin.description}</p> <p className="text-xs text-muted-foreground">{plugin.description}</p>
@@ -11,7 +11,7 @@ import { IconPicker } from "@/components/layout/icon-picker";
import { useSettingsStore, type SidebarApp } from "@/stores/settings-store"; import { useSettingsStore, type SidebarApp } from "@/stores/settings-store";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { cn } from "@/lib/utils"; import { cn, generateUUID } from "@/lib/utils";
interface SidebarAppFormData { interface SidebarAppFormData {
name: string; name: string;
@@ -188,7 +188,7 @@ export function SidebarAppsSettings() {
const draggedIndexRef = useRef<number | null>(null); const draggedIndexRef = useRef<number | null>(null);
const handleAdd = useCallback((data: SidebarAppFormData) => { const handleAdd = useCallback((data: SidebarAppFormData) => {
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const id = `app-${generateUUID()}`;
addSidebarApp({ id, ...data }); addSidebarApp({ id, ...data });
setShowAddForm(false); setShowAddForm(false);
}, [addSidebarApp]); }, [addSidebarApp]);
+387
View File
@@ -0,0 +1,387 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { Shield, Mail, X, AlertTriangle, Trophy, RotateCcw, Inbox, MailCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
const GAME_WIDTH = 400;
const GAME_HEIGHT = 520;
const FORTRESS_Y = GAME_HEIGHT - 48;
const SPAWN_INTERVAL_START = 850;
const SPAWN_INTERVAL_MIN = 320;
const GAME_DURATION = 30;
const ENEMY_SPEED_START = 1.2;
const ENEMY_SPEED_INCREASE = 0.04;
interface Enemy {
id: number;
x: number;
y: number;
speed: number;
type: "spam" | "phishing" | "legit";
}
type GameState = "idle" | "playing" | "won" | "lost";
export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
const [gameState, setGameState] = useState<GameState>("idle");
const [enemies, setEnemies] = useState<Enemy[]>([]);
const [score, setScore] = useState(0);
const [timeLeft, setTimeLeft] = useState(GAME_DURATION);
const [shieldHealth, setShieldHealth] = useState(3);
const [hitEffects, setHitEffects] = useState<{ id: number; x: number; y: number; color: string }[]>([]);
const [destroyEffects, setDestroyEffects] = useState<{ id: number; x: number; y: number }[]>([]);
const [deliverEffects, setDeliverEffects] = useState<{ id: number; x: number; y: number }[]>([]);
const nextId = useRef(0);
const animFrameRef = useRef<number>(0);
const lastTimeRef = useRef<number>(0);
const spawnTimerRef = useRef<number>(0);
const gameStateRef = useRef<GameState>("idle");
const elapsedRef = useRef(0);
const destroyedRef = useRef(new Set<number>());
useEffect(() => {
gameStateRef.current = gameState;
}, [gameState]);
const startGame = useCallback(() => {
setGameState("playing");
setEnemies([]);
setScore(0);
setTimeLeft(GAME_DURATION);
setShieldHealth(3);
setHitEffects([]);
setDestroyEffects([]);
setDeliverEffects([]);
nextId.current = 0;
spawnTimerRef.current = 0;
elapsedRef.current = 0;
destroyedRef.current = new Set();
lastTimeRef.current = performance.now();
}, []);
const spawnEnemy = useCallback(() => {
const id = nextId.current++;
const rand = Math.random();
const type = rand > 0.7 ? "legit" : rand > 0.45 ? "phishing" : "spam";
const x = 20 + Math.random() * (GAME_WIDTH - 60);
const elapsed = elapsedRef.current;
const speed = ENEMY_SPEED_START + (elapsed / 1000) * ENEMY_SPEED_INCREASE;
setEnemies((prev) => [...prev, { id, x, y: -30, speed, type }]);
}, []);
const handleHover = useCallback((enemy: Enemy) => {
if (destroyedRef.current.has(enemy.id)) return;
destroyedRef.current.add(enemy.id);
if (enemy.type === "legit") {
// Penalty for blocking legit mail
setShieldHealth((prev) => {
const nh = prev - 1;
if (nh <= 0) setGameState("lost");
return Math.max(0, nh);
});
setScore((prev) => Math.max(0, prev - 15));
const effectId = nextId.current++;
setHitEffects((p) => [...p, { id: effectId, x: enemy.x, y: enemy.y, color: "rgba(34, 197, 94, 0.5)" }]);
setTimeout(() => setHitEffects((p) => p.filter((h) => h.id !== effectId)), 500);
} else {
setScore((prev) => prev + 10);
const effectId = nextId.current++;
setDestroyEffects((prev) => [...prev, { id: effectId, x: enemy.x, y: enemy.y }]);
setTimeout(() => setDestroyEffects((prev) => prev.filter((e) => e.id !== effectId)), 400);
}
setEnemies((prev) => prev.filter((e) => e.id !== enemy.id));
}, []);
// Game loop
useEffect(() => {
if (gameState !== "playing") return;
const tick = (now: number) => {
if (gameStateRef.current !== "playing") return;
const dt = now - lastTimeRef.current;
lastTimeRef.current = now;
elapsedRef.current += dt;
// Timer
const newTimeLeft = GAME_DURATION - Math.floor(elapsedRef.current / 1000);
setTimeLeft(Math.max(0, newTimeLeft));
if (newTimeLeft <= 0) {
setGameState("won");
return;
}
// Spawn
spawnTimerRef.current += dt;
const spawnInterval = Math.max(
SPAWN_INTERVAL_MIN,
SPAWN_INTERVAL_START - (elapsedRef.current / 1000) * 35
);
if (spawnTimerRef.current >= spawnInterval) {
spawnTimerRef.current = 0;
spawnEnemy();
}
// Move enemies
setEnemies((prev) => {
const next: Enemy[] = [];
let spamBreached = false;
for (const e of prev) {
const ny = e.y + e.speed * (dt / 16);
if (ny >= FORTRESS_Y) {
if (e.type === "legit") {
// Legit mail delivered — bonus
setScore((s) => s + 5);
const effectId = nextId.current++;
setDeliverEffects((p) => [...p, { id: effectId, x: e.x, y: FORTRESS_Y }]);
setTimeout(() => setDeliverEffects((p) => p.filter((d) => d.id !== effectId)), 500);
} else {
spamBreached = true;
const effectId = nextId.current++;
setHitEffects((p) => [...p, { id: effectId, x: e.x, y: FORTRESS_Y, color: "rgba(219, 45, 84, 0.3)" }]);
setTimeout(() => setHitEffects((p) => p.filter((h) => h.id !== effectId)), 500);
}
} else {
next.push({ ...e, y: ny });
}
}
if (spamBreached) {
setShieldHealth((prev) => {
const nh = prev - 1;
if (nh <= 0) setGameState("lost");
return Math.max(0, nh);
});
}
return next;
});
animFrameRef.current = requestAnimationFrame(tick);
};
animFrameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(animFrameRef.current);
}, [gameState, spawnEnemy]);
const getEnemyStyle = (type: Enemy["type"]) => {
switch (type) {
case "phishing":
return { bg: "rgba(234, 179, 8, 0.15)", border: "rgba(234, 179, 8, 0.4)", color: "rgb(234, 179, 8)" };
case "legit":
return { bg: "rgba(34, 197, 94, 0.12)", border: "rgba(34, 197, 94, 0.4)", color: "rgb(34, 197, 94)" };
default:
return { bg: "rgba(219, 45, 84, 0.1)", border: "rgba(219, 45, 84, 0.3)", color: "rgb(219, 45, 84)" };
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="relative rounded-xl border border-border bg-card shadow-2xl overflow-hidden select-none"
style={{ width: GAME_WIDTH, maxWidth: "95vw" }}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-card">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4" style={{ color: "rgb(219, 45, 84)" }} />
<span className="text-sm font-semibold text-foreground">Spam Siege</span>
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-muted transition-colors">
<X className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{/* HUD */}
<div className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b border-border text-xs">
<div className="flex items-center gap-3">
<span className="text-muted-foreground">Score: <span className="font-semibold text-foreground">{score}</span></span>
<span className="text-muted-foreground">Time: <span className="font-semibold text-foreground">{timeLeft}s</span></span>
</div>
<div className="flex items-center gap-1">
{[...Array(3)].map((_, i) => (
<Shield
key={i}
className="w-3.5 h-3.5 transition-colors"
style={{ color: i < shieldHealth ? "rgb(219, 45, 84)" : "rgb(100, 100, 100)" }}
fill={i < shieldHealth ? "rgb(219, 45, 84)" : "none"}
strokeWidth={i < shieldHealth ? 0 : 1.5}
/>
))}
</div>
</div>
{/* Game area */}
<div
className="relative bg-background overflow-hidden"
style={{ height: GAME_HEIGHT }}
>
{/* Grid lines for depth */}
<div className="absolute inset-0 opacity-[0.03]" style={{
backgroundImage: "linear-gradient(to bottom, currentColor 1px, transparent 1px), linear-gradient(to right, currentColor 1px, transparent 1px)",
backgroundSize: "40px 40px",
}} />
{/* Fortress wall */}
<div className="absolute left-0 right-0 bottom-0 flex flex-col items-center" style={{ height: GAME_HEIGHT - FORTRESS_Y }}>
<div className="relative w-full">
{/* Shield centered above the line */}
<div className="absolute -top-5 left-1/2 -translate-x-1/2 z-10">
<Shield
className="w-7 h-7 drop-shadow-sm"
style={{ color: shieldHealth > 0 ? "rgb(219, 45, 84)" : "rgb(100, 100, 100)" }}
fill={shieldHealth > 0 ? "rgba(219, 45, 84, 0.2)" : "none"}
/>
</div>
{/* Solid line */}
<div
className="h-[2px] w-full"
style={{ backgroundColor: shieldHealth > 0 ? "rgba(219, 45, 84, 0.35)" : "rgba(100, 100, 100, 0.3)" }}
/>
</div>
{/* Subtle gradient fill below */}
<div
className="flex-1 w-full"
style={{
background: shieldHealth > 0
? "linear-gradient(to bottom, rgba(219, 45, 84, 0.06), transparent)"
: "linear-gradient(to bottom, rgba(100, 100, 100, 0.04), transparent)",
}}
/>
</div>
{/* Enemies */}
{enemies.map((e) => {
const style = getEnemyStyle(e.type);
return (
<div
key={e.id}
className="absolute flex items-center justify-center w-8 h-8 rounded-md transition-transform"
style={{
left: e.x,
top: e.y,
backgroundColor: style.bg,
border: `1px solid ${style.border}`,
}}
onMouseEnter={() => handleHover(e)}
>
{e.type === "phishing" ? (
<AlertTriangle className="w-4 h-4" style={{ color: style.color }} />
) : e.type === "legit" ? (
<MailCheck className="w-4 h-4" style={{ color: style.color }} />
) : (
<Mail className="w-4 h-4" style={{ color: style.color }} />
)}
</div>
);
})}
{/* Destroy effects */}
{destroyEffects.map((e) => (
<div
key={e.id}
className="absolute pointer-events-none animate-ping"
style={{ left: e.x + 4, top: e.y + 4 }}
>
<X className="w-5 h-5 text-muted-foreground/50" />
</div>
))}
{/* Deliver effects (legit mail arrived) */}
{deliverEffects.map((e) => (
<div
key={e.id}
className="absolute pointer-events-none animate-ping"
style={{ left: e.x + 4, top: e.y - 8 }}
>
<Inbox className="w-5 h-5" style={{ color: "rgb(34, 197, 94)" }} />
</div>
))}
{/* Hit effects on fortress */}
{hitEffects.map((e) => (
<div
key={e.id}
className="absolute pointer-events-none"
style={{ left: e.x, top: e.y - 10 }}
>
<div className="w-6 h-6 rounded-full animate-ping" style={{ backgroundColor: e.color }} />
</div>
))}
{/* Idle overlay */}
{gameState === "idle" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
<Shield className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} fill="rgba(219, 45, 84, 0.1)" />
<div className="text-center">
<p className="text-base font-semibold text-foreground">Spam Siege</p>
<p className="text-xs text-muted-foreground mt-1.5 max-w-[280px] leading-relaxed">
Hover over threats to block them. Let legitimate mail through. Survive {GAME_DURATION} seconds.
</p>
<div className="flex items-center justify-center gap-4 mt-3 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Mail className="w-3 h-3" style={{ color: "rgb(219, 45, 84)" }} /> Spam
</span>
<span className="inline-flex items-center gap-1">
<AlertTriangle className="w-3 h-3" style={{ color: "rgb(234, 179, 8)" }} /> Phishing
</span>
<span className="inline-flex items-center gap-1">
<MailCheck className="w-3 h-3" style={{ color: "rgb(34, 197, 94)" }} /> Legit
</span>
</div>
</div>
<Button size="sm" onClick={startGame} className="mt-1 text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
<Shield className="w-3.5 h-3.5 mr-1.5" />
Defend
</Button>
</div>
)}
{/* Won overlay */}
{gameState === "won" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
<Trophy className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} />
<div className="text-center">
<p className="text-base font-semibold text-foreground">Fortress Secured</p>
<p className="text-xs text-muted-foreground mt-1">
Score: <span className="font-semibold text-foreground">{score}</span>
</p>
</div>
<div className="flex gap-2 mt-1">
<Button size="sm" variant="outline" onClick={onClose}>
Close
</Button>
<Button size="sm" onClick={startGame} className="text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
Again
</Button>
</div>
</div>
)}
{/* Lost overlay */}
{gameState === "lost" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
<Shield className="w-14 h-14 text-muted-foreground/40" />
<div className="text-center">
<p className="text-base font-semibold text-foreground">Fortress Breached</p>
<p className="text-xs text-muted-foreground mt-1">
Score: <span className="font-semibold text-foreground">{score}</span>
</p>
</div>
<div className="flex gap-2 mt-1">
<Button size="sm" variant="outline" onClick={onClose}>
Close
</Button>
<Button size="sm" onClick={startGame} className="text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
Retry
</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
+64 -17
View File
@@ -2,9 +2,11 @@
import { useState, useEffect, useRef, useMemo } from "react"; import { useState, useEffect, useRef, useMemo } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { X, ShieldCheck, Search, Trash2, Plus } from "lucide-react"; import { X, ShieldCheck, Search, Trash2, Plus, Loader2 } from "lucide-react";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface TrustedSendersModalProps { interface TrustedSendersModalProps {
@@ -17,22 +19,43 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const { trustedSenders, addTrustedSender, removeTrustedSender } = useSettingsStore(); const { trustedSenders, addTrustedSender, removeTrustedSender, trustedSendersAddressBook } = useSettingsStore();
const {
trustedSenderEmails,
trustedSendersLoaded,
trustedSendersLoading,
loadTrustedSendersBook,
addToTrustedSendersBook,
removeFromTrustedSendersBook,
} = useContactStore();
const { client } = useAuthStore();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [newEmail, setNewEmail] = useState(""); const [newEmail, setNewEmail] = useState("");
const [emailError, setEmailError] = useState(""); const [emailError, setEmailError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
// When address book mode is on, load the book on first open
useEffect(() => {
if (isOpen && trustedSendersAddressBook && client && !trustedSendersLoaded) {
loadTrustedSendersBook(client);
}
}, [isOpen, trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]);
// The active list depends on mode
const activeSenders = trustedSendersAddressBook ? trustedSenderEmails : trustedSenders;
const isLoading = trustedSendersAddressBook && (!trustedSendersLoaded || trustedSendersLoading);
// Filter senders based on search query // Filter senders based on search query
const filteredSenders = useMemo(() => { const filteredSenders = useMemo(() => {
if (!searchQuery.trim()) return trustedSenders; if (!searchQuery.trim()) return activeSenders;
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
return trustedSenders.filter((email) => email.toLowerCase().includes(query)); return activeSenders.filter((email) => email.toLowerCase().includes(query));
}, [trustedSenders, searchQuery]); }, [activeSenders, searchQuery]);
// Show search only when 5+ senders // Show search only when 5+ senders
const showSearch = trustedSenders.length >= 5; const showSearch = activeSenders.length >= 5;
// Close on Escape key // Close on Escape key
useEffect(() => { useEffect(() => {
@@ -90,7 +113,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
return emailRegex.test(email); return emailRegex.test(email);
}; };
const handleAddSender = () => { const handleAddSender = async () => {
const trimmedEmail = newEmail.trim().toLowerCase(); const trimmedEmail = newEmail.trim().toLowerCase();
if (!trimmedEmail) { if (!trimmedEmail) {
@@ -103,15 +126,34 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
return; return;
} }
if (trustedSenders.includes(trimmedEmail)) { if (activeSenders.includes(trimmedEmail)) {
setEmailError(t("already_added")); setEmailError(t("already_added"));
return; return;
} }
addTrustedSender(trimmedEmail); setIsSubmitting(true);
setNewEmail(""); try {
setIsAdding(false); if (trustedSendersAddressBook && client) {
setEmailError(""); await addToTrustedSendersBook(client, trimmedEmail);
} else {
addTrustedSender(trimmedEmail);
}
setNewEmail("");
setIsAdding(false);
setEmailError("");
} catch {
setEmailError(t("save_error"));
} finally {
setIsSubmitting(false);
}
};
const handleRemoveSender = async (email: string) => {
if (trustedSendersAddressBook && client) {
await removeFromTrustedSendersBook(client, email);
} else {
removeTrustedSender(email);
}
}; };
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -170,7 +212,11 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{trustedSenders.length === 0 ? ( {isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : activeSenders.length === 0 ? (
/* Empty State */ /* Empty State */
<div className="flex flex-col items-center justify-center py-12 px-6 text-center"> <div className="flex flex-col items-center justify-center py-12 px-6 text-center">
<ShieldCheck className="w-12 h-12 text-muted-foreground/50 mb-4" /> <ShieldCheck className="w-12 h-12 text-muted-foreground/50 mb-4" />
@@ -209,7 +255,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
{email} {email}
</span> </span>
<button <button
onClick={() => removeTrustedSender(email)} onClick={() => handleRemoveSender(email)}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100" className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
aria-label={`${t("remove")} ${email}`} aria-label={`${t("remove")} ${email}`}
> >
@@ -222,7 +268,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
</div> </div>
{/* Footer - Add sender */} {/* Footer - Add sender */}
{trustedSenders.length > 0 && ( {!isLoading && activeSenders.length > 0 && (
<div className="px-6 py-4 border-t border-border flex-shrink-0"> <div className="px-6 py-4 border-t border-border flex-shrink-0">
{isAdding ? ( {isAdding ? (
<div className="space-y-2"> <div className="space-y-2">
@@ -244,9 +290,10 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
/> />
<button <button
onClick={handleAddSender} onClick={handleAddSender}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors text-sm font-medium" disabled={isSubmitting}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors text-sm font-medium disabled:opacity-50"
> >
{t("add_button")} {isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : t("add_button")}
</button> </button>
</div> </div>
{emailError && ( {emailError && (
+30 -7
View File
@@ -1,10 +1,11 @@
"use client"; "use client";
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback, useMemo, useEffect } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore, getContactPhotoUri } from "@/stores/contact-store"; import { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
import { avatarHooks } from "@/lib/plugin-hooks";
const IS_DEV = process.env.NODE_ENV !== "production"; const IS_DEV = process.env.NODE_ENV !== "production";
@@ -143,10 +144,26 @@ interface AvatarProps {
export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) { export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) {
const [imgError, setImgError] = useState(false); const [imgError, setImgError] = useState(false);
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
const senderFavicons = useSettingsStore((s) => s.senderFavicons); const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const contacts = useContactStore((s) => s.contacts); const contacts = useContactStore((s) => s.contacts);
const { devMode } = useConfig(); const { devMode } = useConfig();
// Ask plugins (e.g. Gravatar) to resolve an avatar URL for this email address.
// Runs whenever email or name changes; resets plugin avatar state on each change.
useEffect(() => {
setPluginAvatarUrl(null);
setPluginAvatarFailed(false);
if (!email || avatarHooks.onAvatarResolve.size === 0) return;
let cancelled = false;
avatarHooks.onAvatarResolve
.transform(null as string | null, { email, name })
.then((url) => { if (!cancelled) setPluginAvatarUrl(url); })
.catch(() => { if (!cancelled) setPluginAvatarFailed(true); });
return () => { cancelled = true; };
}, [email, name]);
// Look up contact photo by email from the contact store // Look up contact photo by email from the contact store
const resolvedContactPhoto = useMemo(() => { const resolvedContactPhoto = useMemo(() => {
if (contactPhotoUri) return contactPhotoUri; if (contactPhotoUri) return contactPhotoUri;
@@ -202,19 +219,25 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className }:
const showFavicon = const showFavicon =
senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed; senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
// Priority: contact photo > custom avatar > profile picture > company favicon > initials // Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null; const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
const imgSrc = !imgError && !domainFailed const imgSrc = !imgError && !domainFailed
? resolvedContactPhoto || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null) ? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
: (resolvedContactPhoto || customAvatar || profilePic || null); : (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
const handleImgError = useCallback(() => { const handleImgError = useCallback(() => {
// If the plugin avatar just failed, mark it and fall through to the next source
if (pluginAvatar && imgSrc === pluginAvatar) {
setPluginAvatarFailed(true);
return;
}
setImgError(true); setImgError(true);
// If this was a favicon URL (not a contact photo, custom avatar or profile pic), remember the domain // If this was a favicon URL (not a contact photo, plugin avatar, custom avatar or profile pic), remember the domain
if (faviconDomain && !resolvedContactPhoto && !customAvatar && !profilePic) { if (faviconDomain && !resolvedContactPhoto && !pluginAvatar && !customAvatar && !profilePic) {
failedFaviconDomains.add(faviconDomain); failedFaviconDomains.add(faviconDomain);
} }
}, [faviconDomain, resolvedContactPhoto, customAvatar, profilePic]); }, [imgSrc, pluginAvatar, faviconDomain, resolvedContactPhoto, customAvatar, profilePic]);
return ( return (
<div <div
+5 -1
View File
@@ -12,12 +12,16 @@ export function LanguageSwitcher({ className }: { className?: string }) {
{ value: 'en', label: 'English' }, { value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' }, { value: 'fr', label: 'Français' },
{ value: 'ja', label: '日本語' }, { value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'es', label: 'Español' }, { value: 'es', label: 'Español' },
{ value: 'it', label: 'Italiano' }, { value: 'it', label: 'Italiano' },
{ value: 'de', label: 'Deutsch' }, { value: 'de', label: 'Deutsch' },
{ value: 'lv', label: 'Latviešu' },
{ value: 'nl', label: 'Nederlands' }, { value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' }, { value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' } { value: 'ru', label: 'Русский' },
{ value: 'zh', label: '简体中文' }
]; ];
return ( return (
+186
View File
@@ -0,0 +1,186 @@
"use client";
import { useEffect, useRef } from "react";
export interface NavSnapshot {
mailboxId: string | null;
emailId: string | null;
threadId: string | null;
composerOpen: boolean;
sidebarOpen: boolean;
}
interface StoredNavState extends NavSnapshot {
navId: number;
}
interface UseBrowserNavigationOptions extends NavSnapshot {
onRestore: (state: NavSnapshot) => void | Promise<void>;
enabled?: boolean;
}
const STATE_KEY = "__mailNav";
let navIdCounter = 0;
function snapshotsEqual(
a: NavSnapshot | undefined | null,
b: NavSnapshot,
): boolean {
if (!a) return false;
return (
a.mailboxId === b.mailboxId &&
a.emailId === b.emailId &&
a.threadId === b.threadId &&
a.composerOpen === b.composerOpen &&
a.sidebarOpen === b.sidebarOpen
);
}
function readStoredState(): StoredNavState | undefined {
if (typeof window === "undefined") return undefined;
const raw = window.history.state as Record<string, unknown> | null;
if (!raw) return undefined;
return raw[STATE_KEY] as StoredNavState | undefined;
}
/**
* Syncs in-app navigation state to the browser history stack so the browser
* back / forward buttons (mouse buttons on desktop, gesture / hardware button
* on mobile) navigate within the mail UI.
*
* - Pushes a new history entry whenever the captured snapshot changes from
* user action.
* - Listens for popstate and calls onRestore so the page can apply the
* previous snapshot (mailbox, email, view, sidebar, conversation thread).
*
* The URL is left untouched so Next.js routes (e.g. /calendar, /settings)
* continue to behave normally.
*/
export function useBrowserNavigation({
mailboxId,
emailId,
threadId,
composerOpen,
sidebarOpen,
onRestore,
enabled = true,
}: UseBrowserNavigationOptions) {
// Counter so overlapping restores don't accidentally clear the flag
// belonging to a later restore.
const popDepthRef = useRef(0);
const isApplyingPopRef = useRef(false);
const restoreRef = useRef(onRestore);
const initializedRef = useRef(false);
// Always keep the latest restore callback in a ref so the popstate
// listener never sees a stale closure.
restoreRef.current = onRestore;
// Install the popstate listener once.
useEffect(() => {
if (typeof window === "undefined") return;
const handlePop = (event: PopStateEvent) => {
const raw = event.state as Record<string, unknown> | null;
const state = raw ? (raw[STATE_KEY] as StoredNavState | undefined) : undefined;
if (!state) return;
// Hold the "applying pop" flag for the entire restore — including any
// async work like fetching email content — so the resulting state
// updates don't trigger a fresh history push that would undo the
// user's back / forward navigation.
popDepthRef.current += 1;
isApplyingPopRef.current = true;
const settle = () => {
popDepthRef.current -= 1;
if (popDepthRef.current === 0) {
// One extra macrotask so React has flushed any state updates
// dispatched at the very end of the restore.
setTimeout(() => {
if (popDepthRef.current === 0) {
isApplyingPopRef.current = false;
}
}, 0);
}
};
let result: void | Promise<void>;
try {
result = restoreRef.current(state);
} catch (error) {
settle();
throw error;
}
if (result && typeof (result as Promise<void>).then === "function") {
(result as Promise<void>).finally(settle);
} else {
settle();
}
};
window.addEventListener("popstate", handlePop);
return () => window.removeEventListener("popstate", handlePop);
}, []);
// Push a new history entry whenever the navigation snapshot changes.
useEffect(() => {
if (!enabled) return;
if (typeof window === "undefined") return;
if (isApplyingPopRef.current) return;
const snapshot: NavSnapshot = {
mailboxId,
emailId,
threadId,
composerOpen,
sidebarOpen,
};
const existing = readStoredState();
if (snapshotsEqual(existing, snapshot)) return;
const stored: StoredNavState = { ...snapshot, navId: ++navIdCounter };
const baseState = (window.history.state ?? {}) as Record<string, unknown>;
const newState = { ...baseState, [STATE_KEY]: stored };
if (!initializedRef.current) {
initializedRef.current = true;
if (emailId || threadId) {
// The app is initializing directly on an email/thread view (e.g. the
// user navigated here from /settings or an external link). Seed a
// "list" history entry first so that the toolbar back button returns
// to the list instead of leaving the app entirely.
const listSnapshot: NavSnapshot = {
mailboxId,
emailId: null,
threadId: null,
composerOpen: false,
sidebarOpen,
};
const listStored: StoredNavState = {
...listSnapshot,
navId: ++navIdCounter,
};
const baseState = (window.history.state ?? {}) as Record<
string,
unknown
>;
window.history.replaceState(
{ ...baseState, [STATE_KEY]: listStored },
"",
);
// Now push the actual email state on top of the synthetic list entry.
window.history.pushState(newState, "");
} else {
// Replace the current entry on the very first run so we don't
// create an extra step the user has to back through to leave the app.
window.history.replaceState(newState, "");
}
} else {
window.history.pushState(newState, "");
}
}, [enabled, mailboxId, emailId, threadId, composerOpen, sidebarOpen]);
}
+31
View File
@@ -0,0 +1,31 @@
'use client';
import { useEffect } from 'react';
import { useAuthStore } from '@/stores/auth-store';
// Re-sync identities every 30 minutes while the app is open
const SYNC_INTERVAL_MS = 30 * 60 * 1000;
export function useIdentitySync() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const refreshIdentities = useAuthStore((s) => s.refreshIdentities);
useEffect(() => {
if (!isAuthenticated) return;
// Sync when the user returns to the tab (e.g. after adding an alias in Stalwart)
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refreshIdentities();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
const interval = setInterval(refreshIdentities, SYNC_INTERVAL_MS);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
clearInterval(interval);
};
}, [isAuthenticated, refreshIdentities]);
}
+1 -8
View File
@@ -78,14 +78,7 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us
const email = currentEmails.find(em => em.id === emailId); const email = currentEmails.find(em => em.id === emailId);
const keywords = { ...(email?.keywords || {}) }; const keywords = { ...(email?.keywords || {}) };
// Remove old label/color keywords // Add the tag without removing existing ones
Object.keys(keywords).forEach(key => {
if (key.startsWith("$label:") || key.startsWith("$color:")) {
keywords[key] = false;
}
});
// Add the new tag
keywords[`$label:${tagId}`] = true; keywords[`$label:${tagId}`] = true;
await client.updateEmailKeywords(emailId, keywords); await client.updateEmailKeywords(emailId, keywords);
+12
View File
@@ -26,15 +26,27 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'ja': case 'ja':
messages = (await import('../locales/ja/common.json')).default; messages = (await import('../locales/ja/common.json')).default;
break; break;
case 'ko':
messages = (await import('../locales/ko/common.json')).default;
break;
case 'lv':
messages = (await import('../locales/lv/common.json')).default;
break;
case 'nl': case 'nl':
messages = (await import('../locales/nl/common.json')).default; messages = (await import('../locales/nl/common.json')).default;
break; break;
case 'pl':
messages = (await import('../locales/pl/common.json')).default;
break;
case 'pt': case 'pt':
messages = (await import('../locales/pt/common.json')).default; messages = (await import('../locales/pt/common.json')).default;
break; break;
case 'ru': case 'ru':
messages = (await import('../locales/ru/common.json')).default; messages = (await import('../locales/ru/common.json')).default;
break; break;
case 'zh':
messages = (await import('../locales/zh/common.json')).default;
break;
default: default:
messages = (await import('../locales/en/common.json')).default; messages = (await import('../locales/en/common.json')).default;
} }
+1 -1
View File
@@ -1,7 +1,7 @@
import { defineRouting } from 'next-intl/routing'; import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({ export const routing = defineRouting({
locales: ['en', 'fr', 'de', 'es', 'it', 'ja', 'nl', 'pt', 'ru'], locales: ['en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'zh'],
defaultLocale: 'en', defaultLocale: 'en',
localePrefix: 'never' localePrefix: 'never'
}); });
+32
View File
@@ -1,3 +1,4 @@
import { unlink, writeFileSync } from "fs";
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock NextResponse before importing the route // Mock NextResponse before importing the route
@@ -24,6 +25,7 @@ describe('config API route', () => {
delete process.env.OAUTH_CLIENT_ID; delete process.env.OAUTH_CLIENT_ID;
delete process.env.OAUTH_ISSUER_URL; delete process.env.OAUTH_ISSUER_URL;
delete process.env.SESSION_SECRET; delete process.env.SESSION_SECRET;
delete process.env.SESSION_SECRET_FILE;
delete process.env.SETTINGS_SYNC_ENABLED; delete process.env.SETTINGS_SYNC_ENABLED;
delete process.env.STALWART_FEATURES; delete process.env.STALWART_FEATURES;
delete process.env.DEV_MOCK_JMAP; delete process.env.DEV_MOCK_JMAP;
@@ -128,6 +130,19 @@ describe('config API route', () => {
const config = await getConfig(); const config = await getConfig();
expect(config.rememberMeEnabled).toBe(true);
});
it('should enable rememberMe when SESSION_SECRET_FILE is set', async () => {
writeFileSync('./session-secret', 'test-secret');
process.env.SESSION_SECRET_FILE = './session-secret';
const config = await getConfig();
unlink('./session-secret', (err) => {
if (err) throw err;
});
expect(config.rememberMeEnabled).toBe(true); expect(config.rememberMeEnabled).toBe(true);
}); });
@@ -138,6 +153,23 @@ describe('config API route', () => {
process.env.SESSION_SECRET = 'test-secret'; process.env.SESSION_SECRET = 'test-secret';
const config2 = await getConfig(); const config2 = await getConfig();
expect(config2.settingsSyncEnabled).toBe(true);
});
it('should enable settingsSync only when both SESSION_SECRET_FILE and SETTINGS_SYNC_ENABLED are set', async () => {
process.env.SETTINGS_SYNC_ENABLED = 'true';
const config1 = await getConfig();
expect(config1.settingsSyncEnabled).toBe(false);
writeFileSync('./session-secret', 'test-secret');
process.env.SESSION_SECRET_FILE = './session-secret';
const config2 = await getConfig();
unlink('./session-secret', (err) => {
if (err) throw err;
});
expect(config2.settingsSyncEnabled).toBe(true); expect(config2.settingsSyncEnabled).toBe(true);
}); });
+43
View File
@@ -148,3 +148,46 @@ describe('toast bridge', () => {
expect(api.toast.warning).toBeInstanceOf(Function); expect(api.toast.warning).toBeInstanceOf(Function);
}); });
}); });
describe('http.post path validation', () => {
function makeApi(permissions: string[] = ['http:post']) {
return createPluginAPI(makePlugin({ permissions }));
}
it('rejects protocol-relative URLs like //evil.example', async () => {
const api = makeApi();
await expect(api.http.post('//evil.example/collect', {})).rejects.toThrow('must start with /api/');
});
it('rejects absolute URLs to other origins', async () => {
const api = makeApi();
await expect(api.http.post('https://evil.example/steal', {})).rejects.toThrow('must start with /api/');
});
it('rejects paths not under /api/', async () => {
const api = makeApi();
await expect(api.http.post('/other/path', {})).rejects.toThrow('must start with /api/');
});
it('rejects paths that use backslash to bypass the check', async () => {
const api = makeApi();
await expect(api.http.post('/api/\\@evil.example', {})).rejects.toThrow();
});
it('throws without http:post permission', async () => {
const api = makeApi([]);
await expect(api.http.post('/api/jitsi', {})).rejects.toThrow('lacks permission');
});
it('accepts a valid /api/ path', async () => {
const api = makeApi();
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ url: 'https://meet.example.com/room' }),
});
const result = await api.http.post('/api/jitsi', { eventTitle: 'test' });
expect(result.ok).toBe(true);
expect(result.data).toEqual({ url: 'https://meet.example.com/room' });
});
});
+106
View File
@@ -375,5 +375,111 @@ describe('expandRecurringEvents', () => {
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00'); const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
expect(result[0].originalId).toBe('evt1'); expect(result[0].originalId).toBe('evt1');
}); });
it('skips server-returned override events that belong to a recurring master', () => {
// Server returns the master event plus override instances with recurrenceId set
const master = makeEvent({
id: 'master1',
uid: 'shared-uid',
start: '2025-01-06T09:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
});
const override1 = makeEvent({
id: 'override1',
uid: 'shared-uid',
start: '2025-01-13T09:00:00',
recurrenceId: '2025-01-13T09:00:00',
recurrenceRules: null,
});
const override2 = makeEvent({
id: 'override2',
uid: 'shared-uid',
start: '2025-01-20T09:00:00',
recurrenceId: '2025-01-20T09:00:00',
recurrenceRules: null,
});
const result = expandRecurringEvents(
[master, override1, override2],
'2025-01-06T00:00:00',
'2025-01-27T00:00:00',
);
// Should have 3 weekly occurrences from master expansion only (Jan 6, 13, 20)
// Override events should be skipped since they share the master's UID
expect(result).toHaveLength(3);
expect(starts(result)).toEqual([
'2025-01-06T09:00:00',
'2025-01-13T09:00:00',
'2025-01-20T09:00:00',
]);
});
it('keeps override events whose UID has no matching master', () => {
// Standalone override event with no master in the batch
const orphanOverride = makeEvent({
id: 'orphan1',
uid: 'orphan-uid',
start: '2025-01-13T09:00:00',
recurrenceId: '2025-01-13T09:00:00',
recurrenceRules: null,
});
const result = expandRecurringEvents(
[orphanOverride],
'2025-01-06T00:00:00',
'2025-01-27T00:00:00',
);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('orphan1');
});
});
// -----------------------------------------------------------------------
// utcStart computation for occurrences
// -----------------------------------------------------------------------
describe('utcStart per occurrence', () => {
it('computes distinct utcStart for each weekly occurrence', () => {
const event = makeEvent({
start: '2026-09-01T12:00:00',
utcStart: '2026-09-01T10:00:00Z',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
});
const result = expand(event, '2026-09-01T00:00:00', '2026-10-01T00:00:00');
const utcStarts = result.map(e => (e as any).utcStart);
// Each occurrence should have a unique utcStart
expect(new Set(utcStarts).size).toBe(result.length);
// First occurrence keeps master's UTC offset relationship
expect(utcStarts[0]).toContain('2026-09-01');
expect(utcStarts[1]).toContain('2026-09-08');
expect(utcStarts[2]).toContain('2026-09-15');
});
it('computes distinct utcEnd for each weekly occurrence', () => {
const event = makeEvent({
start: '2026-09-01T12:00:00',
duration: 'PT1H',
utcStart: '2026-09-01T10:00:00Z',
utcEnd: '2026-09-01T11:00:00Z',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
});
const result = expand(event, '2026-09-01T00:00:00', '2026-10-01T00:00:00');
const utcEnds = result.map(e => (e as any).utcEnd);
// Each occurrence should have a unique utcEnd
expect(new Set(utcEnds).size).toBe(result.length);
expect(utcEnds[0]).toContain('2026-09-01');
expect(utcEnds[1]).toContain('2026-09-08');
expect(utcEnds[2]).toContain('2026-09-15');
});
it('does not set utcStart when master has none', () => {
const event = makeEvent({
start: '2025-01-06T09:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
expect(result[0].utcStart).toBeUndefined();
});
}); });
}); });
+2 -1
View File
@@ -1,6 +1,7 @@
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'; import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
import { readFileEnv } from '@/lib/read-file-env';
import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types'; import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types';
import type { AdminSessionPayload } from './types'; import type { AdminSessionPayload } from './types';
@@ -11,7 +12,7 @@ const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32; const MIN_SECRET_LENGTH = 32;
function getKey(): Buffer { function getKey(): Buffer {
const secret = process.env.SESSION_SECRET; const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
if (!secret) throw new Error('SESSION_SECRET not configured'); if (!secret) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) { if (secret.length < MIN_SECRET_LENGTH) {
throw new Error( throw new Error(
+6 -1
View File
@@ -25,6 +25,7 @@ export interface SettingRestriction {
export interface FeatureGates { export interface FeatureGates {
pluginsEnabled: boolean; pluginsEnabled: boolean;
pluginsUploadEnabled: boolean; pluginsUploadEnabled: boolean;
requirePluginApproval: boolean;
themesEnabled: boolean; themesEnabled: boolean;
sidebarAppsEnabled: boolean; sidebarAppsEnabled: boolean;
userThemesEnabled: boolean; userThemesEnabled: boolean;
@@ -41,8 +42,9 @@ export interface FeatureGates {
} }
export const DEFAULT_FEATURE_GATES: FeatureGates = { export const DEFAULT_FEATURE_GATES: FeatureGates = {
pluginsEnabled: true, pluginsEnabled: false,
pluginsUploadEnabled: true, pluginsUploadEnabled: true,
requirePluginApproval: true,
themesEnabled: true, themesEnabled: true,
sidebarAppsEnabled: true, sidebarAppsEnabled: true,
userThemesEnabled: true, userThemesEnabled: true,
@@ -80,6 +82,8 @@ export interface SettingsPolicy {
themePolicy: ThemePolicy; themePolicy: ThemePolicy;
/** Plugin IDs that are force-enabled (users cannot disable) */ /** Plugin IDs that are force-enabled (users cannot disable) */
forceEnabledPlugins: string[]; forceEnabledPlugins: string[];
/** Plugin IDs that have been approved by admin (users can enable) */
approvedPlugins: string[];
/** Theme IDs that are force-enabled (users cannot deactivate) */ /** Theme IDs that are force-enabled (users cannot deactivate) */
forceEnabledThemes: string[]; forceEnabledThemes: string[];
} }
@@ -90,6 +94,7 @@ export const DEFAULT_POLICY: SettingsPolicy = {
defaults: {}, defaults: {},
themePolicy: { ...DEFAULT_THEME_POLICY }, themePolicy: { ...DEFAULT_THEME_POLICY },
forceEnabledPlugins: [], forceEnabledPlugins: [],
approvedPlugins: [],
forceEnabledThemes: [], forceEnabledThemes: [],
}; };
+2 -1
View File
@@ -1,5 +1,6 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'; import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
const ALGORITHM = 'aes-256-gcm'; const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; const IV_LENGTH = 12;
@@ -8,7 +9,7 @@ const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32; const MIN_SECRET_LENGTH = 32;
function getKey(): Buffer { function getKey(): Buffer {
const secret = process.env.SESSION_SECRET; const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
if (!secret) throw new Error('SESSION_SECRET not configured'); if (!secret) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) { if (secret.length < MIN_SECRET_LENGTH) {
throw new Error( throw new Error(
+207
View File
@@ -0,0 +1,207 @@
import type { ContactCard, CalendarEvent, Calendar, PartialDate, Timestamp } from '@/lib/jmap/types';
import { getContactDisplayName } from '@/stores/contact-store';
import { format, eachYearOfInterval, parseISO } from 'date-fns';
export const BIRTHDAY_CALENDAR_ID = '__birthday-calendar__';
export const BIRTHDAY_CALENDAR_COLOR = '#eab308'; // Yellow
/**
* Virtual calendar object for the contact birthday calendar.
*/
export function createBirthdayCalendar(name?: string, color?: string): Calendar {
return {
id: BIRTHDAY_CALENDAR_ID,
name: name || 'Birthdays',
description: null,
color: color || BIRTHDAY_CALENDAR_COLOR,
sortOrder: 999,
isSubscribed: true,
isVisible: true,
isDefault: false,
includeInAvailability: 'none',
defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
myRights: {
mayReadFreeBusy: true,
mayReadItems: true,
mayWriteAll: false,
mayWriteOwn: false,
mayUpdatePrivate: false,
mayRSVP: false,
mayAdmin: false,
mayDelete: false,
},
};
}
/**
* Extract month and day from an AnniversaryDate.
* Returns null if the date cannot be parsed into month/day.
*/
function parseBirthdayDate(date: string | PartialDate | Timestamp): { month: number; day: number; year?: number } | null {
if (typeof date === 'string') {
// Could be ISO date string like "1990-05-15" or partial "--05-15"
if (date.startsWith('--')) {
// Partial date: --MM-DD
const match = date.match(/^--(\d{2})-(\d{2})$/);
if (match) {
return { month: parseInt(match[1], 10), day: parseInt(match[2], 10) };
}
return null;
}
try {
const parsed = parseISO(date);
if (!isNaN(parsed.getTime())) {
return {
month: parsed.getMonth() + 1,
day: parsed.getDate(),
year: parsed.getFullYear(),
};
}
} catch {
return null;
}
return null;
}
if ('utc' in date && date['@type'] === 'Timestamp') {
// Timestamp type
try {
const parsed = parseISO(date.utc);
if (!isNaN(parsed.getTime())) {
return {
month: parsed.getMonth() + 1,
day: parsed.getDate(),
year: parsed.getFullYear(),
};
}
} catch {
return null;
}
return null;
}
// PartialDate type
const partial = date as PartialDate;
if (partial.month && partial.day) {
return {
month: partial.month,
day: partial.day,
year: partial.year || undefined,
};
}
return null;
}
/**
* Generate virtual CalendarEvent objects from contacts that have birthday anniversaries.
* Events are generated for each year in the given date range.
*/
export function generateBirthdayEvents(
contacts: ContactCard[],
rangeStart: string,
rangeEnd: string,
): CalendarEvent[] {
const events: CalendarEvent[] = [];
const start = parseISO(rangeStart);
const end = parseISO(rangeEnd);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
return events;
}
const years = eachYearOfInterval({ start, end });
// Also include the end date's year if not already covered
const endYear = end.getFullYear();
if (!years.some(y => y.getFullYear() === endYear)) {
years.push(new Date(endYear, 0, 1));
}
for (const contact of contacts) {
if (!contact.anniversaries) continue;
for (const [key, anniversary] of Object.entries(contact.anniversaries)) {
if (anniversary.kind !== 'birth') continue;
const parsed = parseBirthdayDate(anniversary.date);
if (!parsed) continue;
const displayName = getContactDisplayName(contact);
if (!displayName) continue;
for (const yearDate of years) {
const year = yearDate.getFullYear();
// Handle Feb 29 in non-leap years: JS silently rolls over to Mar 1,
// but the ISO eventStart string would be invalid. Clamp to Feb 28.
let occMonth = parsed.month;
let occDay = parsed.day;
const occurrenceDate = new Date(year, occMonth - 1, occDay);
if (occurrenceDate.getMonth() !== occMonth - 1) {
occurrenceDate.setDate(0); // last day of previous month
occMonth = occurrenceDate.getMonth() + 1;
occDay = occurrenceDate.getDate();
}
if (occurrenceDate < start || occurrenceDate > end) continue;
const monthStr = String(occMonth).padStart(2, '0');
const dayStr = String(occDay).padStart(2, '0');
const eventStart = `${year}-${monthStr}-${dayStr}T00:00:00`;
const age = parsed.year ? year - parsed.year : undefined;
const ageText = age && age > 0 ? ` (${age})` : '';
const event: CalendarEvent = {
id: `birthday-${contact.id}-${key}-${year}`,
calendarIds: { [BIRTHDAY_CALENDAR_ID]: true },
isDraft: false,
isOrigin: false,
utcStart: null,
utcEnd: null,
'@type': 'Event',
uid: `birthday-${contact.id}-${key}`,
title: `🎂 ${displayName}${ageText}`,
description: '',
descriptionContentType: 'text/plain',
created: null,
updated: '',
sequence: 0,
start: eventStart,
duration: 'P1D',
timeZone: null,
showWithoutTime: true,
status: 'confirmed',
freeBusyStatus: 'free',
privacy: 'public',
color: null,
keywords: null,
categories: null,
locale: null,
replyTo: null,
organizerCalendarAddress: null,
participants: null,
mayInviteSelf: false,
mayInviteOthers: false,
hideAttendees: false,
recurrenceId: null,
recurrenceIdTimeZone: null,
recurrenceRules: null,
recurrenceOverrides: null,
excludedRecurrenceRules: null,
useDefaultAlerts: false,
alerts: null,
locations: null,
virtualLocations: null,
links: null,
relatedTo: null,
};
events.push(event);
}
}
}
return events;
}
+2 -3
View File
@@ -1,4 +1,5 @@
import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types'; import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
import { generateUUID } from '@/lib/utils';
export interface ParticipantInfo { export interface ParticipantInfo {
id: string; id: string;
@@ -104,9 +105,7 @@ export function buildParticipantMap(
): Record<string, Partial<CalendarParticipant>> { ): Record<string, Partial<CalendarParticipant>> {
const participants: Record<string, Partial<CalendarParticipant>> = {}; const participants: Record<string, Partial<CalendarParticipant>> = {};
const generateId = () => typeof crypto !== 'undefined' && crypto.randomUUID const generateId = () => generateUUID();
? crypto.randomUUID()
: `p-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
participants[generateId()] = { participants[generateId()] = {
'@type': 'Participant', '@type': 'Participant',
+1 -1
View File
@@ -104,7 +104,7 @@ export const debug = {
} }
}; };
const CATEGORY_KEYS = new Set<string>(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push']); const CATEGORY_KEYS = new Set<string>(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push', 'contacts']);
function isCategoryKey(value: string): value is DebugCategory { function isCategoryKey(value: string): value is DebugCategory {
return CATEGORY_KEYS.has(value); return CATEGORY_KEYS.has(value);
} }
+11
View File
@@ -481,6 +481,17 @@ export class DemoJMAPClient implements IJMAPClient {
async getAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; } async getAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
async getAllAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; } async getAllAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
async createAddressBook(name: string): Promise<AddressBook> {
const book: AddressBook = { id: `demo-book-${Date.now()}`, name };
this.data.addressBooks.push(book);
return book;
}
async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>): Promise<void> {
const book = this.data.addressBooks.find(b => b.id === addressBookId);
if (book) Object.assign(book, updates);
}
async getContacts(addressBookId?: string): Promise<ContactCard[]> { async getContacts(addressBookId?: string): Promise<ContactCard[]> {
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]); if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
return [...this.data.contacts]; return [...this.data.contacts];
+2
View File
@@ -178,6 +178,8 @@ export interface IJMAPClient {
getContactsAccountId(): string; getContactsAccountId(): string;
getAddressBooks(): Promise<AddressBook[]>; getAddressBooks(): Promise<AddressBook[]>;
getAllAddressBooks(): Promise<AddressBook[]>; getAllAddressBooks(): Promise<AddressBook[]>;
createAddressBook(name: string): Promise<AddressBook>;
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
getContacts(addressBookId?: string): Promise<ContactCard[]>; getContacts(addressBookId?: string): Promise<ContactCard[]>;
getAllContacts(): Promise<ContactCard[]>; getAllContacts(): Promise<ContactCard[]>;
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>; getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
+128 -27
View File
@@ -1320,7 +1320,10 @@ export class JMAPClient implements IJMAPClient {
const result = response.methodResponses?.[0]?.[1]; const result = response.methodResponses?.[0]?.[1];
if (result?.notDestroyed?.[mailboxId]) { if (result?.notDestroyed?.[mailboxId]) {
throw new Error(`Failed to delete mailbox: ${result.notDestroyed[mailboxId].type || 'unknown error'}`); const err = result.notDestroyed[mailboxId];
const error = new Error(err.description || `Failed to delete mailbox: ${err.type || 'unknown error'}`);
(error as Error & { jmapType?: string }).jmapType = err.type;
throw error;
} }
} }
@@ -2815,6 +2818,56 @@ export class JMAPClient implements IJMAPClient {
} }
} }
async createAddressBook(name: string): Promise<AddressBook> {
const accountId = this.getContactsAccountId();
const response = await this.request([
["AddressBook/set", {
accountId,
create: { "new-book": { name } },
}, "0"]
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "AddressBook/set") {
const result = response.methodResponses[0][1];
const created = result.created?.["new-book"];
if (created) {
return { id: created.id, name, ...created } as AddressBook;
}
const err = result.notCreated?.["new-book"];
throw new Error(err?.description || "Failed to create address book");
}
throw new Error("Failed to create address book");
}
async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void> {
const accountId = targetAccountId || this.getContactsAccountId();
// Only forward server-settable properties
const { name, description, sortOrder, isDefault, color } = updates as Record<string, unknown>;
const patch: Record<string, unknown> = {};
if (name !== undefined) patch.name = name;
if (description !== undefined) patch.description = description;
if (sortOrder !== undefined) patch.sortOrder = sortOrder;
if (isDefault !== undefined) patch.isDefault = isDefault;
if (color !== undefined) patch.color = color;
const response = await this.request([
["AddressBook/set", {
accountId,
update: { [addressBookId]: patch },
}, "0"]
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "AddressBook/set") {
const result = response.methodResponses[0][1];
if (result.notUpdated?.[addressBookId]) {
const error = result.notUpdated[addressBookId];
throw new Error(error.description || "Failed to update address book");
}
return;
}
throw new Error("Failed to update address book");
}
private async fetchPaginatedContacts( private async fetchPaginatedContacts(
accountId: string, accountId: string,
filter?: Record<string, unknown>, filter?: Record<string, unknown>,
@@ -3220,33 +3273,48 @@ export class JMAPClient implements IJMAPClient {
async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]> { async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]> {
const accountId = targetAccountId || this.getCalendarsAccountId(); const accountId = targetAccountId || this.getCalendarsAccountId();
const GET_BATCH_SIZE = this.getMaxObjectsInGet();
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 }; const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
if (calendarIds && calendarIds.length > 0) { if (calendarIds && calendarIds.length > 0) {
queryArgs.filter = { inCalendars: calendarIds }; queryArgs.filter = { inCalendars: calendarIds };
} }
const response = await this.request([ // First, query to get all IDs
const queryResponse = await this.request([
["CalendarEvent/query", queryArgs, "0"], ["CalendarEvent/query", queryArgs, "0"],
["CalendarEvent/get", {
accountId,
properties: [...CALENDAR_EVENT_PROPERTIES],
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
}, "1"]
], this.calendarUsing()); ], this.calendarUsing());
// Check for JMAP method-level errors // Check for JMAP method-level errors
if (response.methodResponses?.[0]?.[0] === "error") { if (queryResponse.methodResponses?.[0]?.[0] === "error") {
const error = response.methodResponses[0][1]; const error = queryResponse.methodResponses[0][1];
throw new Error(error?.description || error?.type || "CalendarEvent/query failed"); throw new Error(error?.description || error?.type || "CalendarEvent/query failed");
} }
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") { const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || [];
return ((response.methodResponses[1][1].list || []) as CalendarEvent[]) if (ids.length === 0) return [];
.filter((event) => !isTaskObject(event))
.map((event) => normalizeCalendarEventLike(event)); // Batch the /get calls to stay within server max-objects limit
const allEvents: CalendarEvent[] = [];
for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) {
const batchIds = ids.slice(i, i + GET_BATCH_SIZE);
const getResponse = await this.request([
["CalendarEvent/get", {
accountId,
properties: [...CALENDAR_EVENT_PROPERTIES],
ids: batchIds,
}, "0"]
], this.calendarUsing());
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
const events = (getResponse.methodResponses[0][1].list || []) as CalendarEvent[];
allEvents.push(...events);
}
} }
return [];
return allEvents
.filter((event) => !isTaskObject(event))
.map((event) => normalizeCalendarEventLike(event));
} }
async queryAllCalendarEvents( async queryAllCalendarEvents(
@@ -3311,21 +3379,42 @@ export class JMAPClient implements IJMAPClient {
queryArgs.sort = sort; queryArgs.sort = sort;
} }
const response = await this.request([ const GET_BATCH_SIZE = this.getMaxObjectsInGet();
// First, query to get IDs
const queryResponse = await this.request([
["CalendarEvent/query", queryArgs, "0"], ["CalendarEvent/query", queryArgs, "0"],
["CalendarEvent/get", {
accountId,
properties: [...CALENDAR_EVENT_PROPERTIES],
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
}, "1"]
], this.calendarUsing()); ], this.calendarUsing());
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") { if (queryResponse.methodResponses?.[0]?.[0] === "error") {
return ((response.methodResponses[1][1].list || []) as CalendarEvent[]) const error = queryResponse.methodResponses[0][1];
.filter((event) => !isTaskObject(event)) throw new Error(error?.description || error?.type || "CalendarEvent/query failed");
.map((event) => normalizeCalendarEventLike(event));
} }
return [];
const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || [];
if (ids.length === 0) return [];
// Batch the /get calls to stay within server max-objects limit
const allEvents: CalendarEvent[] = [];
for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) {
const batchIds = ids.slice(i, i + GET_BATCH_SIZE);
const getResponse = await this.request([
["CalendarEvent/get", {
accountId,
properties: [...CALENDAR_EVENT_PROPERTIES],
ids: batchIds,
}, "0"]
], this.calendarUsing());
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
const events = (getResponse.methodResponses[0][1].list || []) as CalendarEvent[];
allEvents.push(...events);
}
}
return allEvents
.filter((event) => !isTaskObject(event))
.map((event) => normalizeCalendarEventLike(event));
} catch (error) { } catch (error) {
console.error('Failed to query calendar events:', error); console.error('Failed to query calendar events:', error);
return []; return [];
@@ -4058,7 +4147,9 @@ export class JMAPClient implements IJMAPClient {
async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode> { async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode> {
const accountId = this.getFilesAccountId(); const accountId = this.getFilesAccountId();
const fileProps: Record<string, unknown> = { name, type, blobId, size }; //fall back for long MIME types
const safeType = type.length > 30 ? 'application/octet-stream' : type;
const fileProps: Record<string, unknown> = { name, type: safeType, blobId, size };
if (parentId !== null) { if (parentId !== null) {
fileProps.parentId = parentId; fileProps.parentId = parentId;
} }
@@ -4112,6 +4203,7 @@ export class JMAPClient implements IJMAPClient {
[["FileNode/set", { [["FileNode/set", {
accountId, accountId,
destroy: ids, destroy: ids,
onDestroyRemoveChildren: true,
}, "fns0"]], }, "fns0"]],
this.fileUsing(), this.fileUsing(),
); );
@@ -4120,9 +4212,18 @@ export class JMAPClient implements IJMAPClient {
if (!result || result[0] === "error") { if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/set destroy failed"); throw new Error(result?.[1]?.description || "FileNode/set destroy failed");
} }
const notDestroyedMap: Record<string, { type?: string; description?: string }> = result[1].notDestroyed || {};
const notDestroyedIds = Object.keys(notDestroyedMap);
if (notDestroyedIds.length > 0) {
const firstError = notDestroyedMap[notDestroyedIds[0]];
throw new Error(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`);
}
return { return {
destroyed: result[1].destroyed || [], destroyed: result[1].destroyed || [],
notDestroyed: result[1].notDestroyed ? Object.keys(result[1].notDestroyed) : [], notDestroyed: [],
}; };
} }
+4 -2
View File
@@ -203,12 +203,14 @@ export interface ContactCard {
} }
export interface ContactName { export interface ContactName {
components: NameComponent[]; components?: NameComponent[];
isOrdered?: boolean; isOrdered?: boolean;
full?: string;
defaultSeparator?: string;
} }
export interface NameComponent { export interface NameComponent {
kind: 'given' | 'surname' | 'prefix' | 'suffix' | 'additional' | 'separator' | 'credential'; kind: 'given' | 'surname' | 'prefix' | 'suffix' | 'additional' | 'separator' | 'credential' | 'title' | 'middle' | 'given2' | 'surname2' | 'generation';
value: string; value: string;
} }
+4 -1
View File
@@ -1,9 +1,12 @@
const COOKIE_SAME_SITE = (process.env.COOKIE_SAME_SITE || 'lax') as 'lax' | 'none' | 'strict'; const COOKIE_SAME_SITE = (process.env.COOKIE_SAME_SITE || 'lax') as 'lax' | 'none' | 'strict';
const COOKIE_SECURE = process.env.COOKIE_SECURE !== undefined
? process.env.COOKIE_SECURE === 'true'
: (COOKIE_SAME_SITE === 'none' || process.env.NODE_ENV === 'production');
export function getCookieOptions() { export function getCookieOptions() {
return { return {
httpOnly: true, httpOnly: true,
secure: COOKIE_SAME_SITE === 'none' || process.env.NODE_ENV === 'production', secure: COOKIE_SECURE,
sameSite: COOKIE_SAME_SITE, sameSite: COOKIE_SAME_SITE,
path: '/', path: '/',
maxAge: 30 * 24 * 60 * 60, maxAge: 30 * 24 * 60 * 60,
+2 -1
View File
@@ -1,8 +1,9 @@
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery'; import { discoverOAuth } from '@/lib/oauth/discovery';
import type { OAuthMetadata } from '@/lib/oauth/discovery'; import type { OAuthMetadata } from '@/lib/oauth/discovery';
import { readFileEnv } from '@/lib/read-file-env';
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || ''; const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
export function getRequiredConfig() { export function getRequiredConfig() {
const clientId = process.env.OAUTH_CLIENT_ID; const clientId = process.env.OAUTH_CLIENT_ID;
+3 -1
View File
@@ -1,4 +1,6 @@
export const OAUTH_SCOPES = 'openid email profile'; const DEFAULT_SCOPES = 'openid email profile';
const EXTRA_SCOPES = process.env.OAUTH_EXTRA_SCOPES || '';
export const OAUTH_SCOPES = process.env.OAUTH_SCOPES || (EXTRA_SCOPES ? `${DEFAULT_SCOPES} ${EXTRA_SCOPES}`.trim() : DEFAULT_SCOPES);
export const REFRESH_TOKEN_COOKIE = 'jmap_rt'; export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */ /** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
+44 -8
View File
@@ -22,11 +22,12 @@ import {
taskHooks, templateHooks, smimeHooks, vacationHooks, taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks, uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks,
sidebarAppHooks, sidebarAppHooks, avatarHooks,
} from './plugin-hooks'; } from './plugin-hooks';
import { toast as appToast } from '@/stores/toast-store'; import { toast as appToast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
// ─── Permission helpers ────────────────────────────────────── // --- Permission helpers --------------------------------------
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
function getPluginExternals(): any { function getPluginExternals(): any {
@@ -60,7 +61,7 @@ function guardedHook<T extends (...args: never[]) => unknown>(
return bus.register(plugin.id, handler, order); return bus.register(plugin.id, handler, order);
} }
// ─── Plugin-scoped storage ─────────────────────────────────── // --- Plugin-scoped storage -----------------------------------
function createPluginStorage(pluginId: string) { function createPluginStorage(pluginId: string) {
const prefix = `plugin:${pluginId}:`; const prefix = `plugin:${pluginId}:`;
@@ -92,7 +93,7 @@ function createPluginStorage(pluginId: string) {
}; };
} }
// ─── Plugin-scoped logger ──────────────────────────────────── // --- Plugin-scoped logger ------------------------------------
function createPluginLogger(pluginId: string) { function createPluginLogger(pluginId: string) {
const tag = `[plugin:${pluginId}]`; const tag = `[plugin:${pluginId}]`;
@@ -104,7 +105,7 @@ function createPluginLogger(pluginId: string) {
}; };
} }
// ─── PluginAPI interface ───────────────────────────────────── // --- PluginAPI interface -------------------------------------
export interface PluginAPI { export interface PluginAPI {
plugin: { id: string; version: string; settings: Record<string, unknown> }; plugin: { id: string; version: string; settings: Record<string, unknown> };
@@ -128,6 +129,9 @@ export interface PluginAPI {
info: (message: string) => void; info: (message: string) => void;
warning: (message: string) => void; warning: (message: string) => void;
}; };
http: {
post: (path: string, body: Record<string, unknown>) => Promise<{ ok: boolean; status: number; data: unknown }>;
};
storage: ReturnType<typeof createPluginStorage>; storage: ReturnType<typeof createPluginStorage>;
log: ReturnType<typeof createPluginLogger>; log: ReturnType<typeof createPluginLogger>;
admin: { admin: {
@@ -310,9 +314,11 @@ export interface PluginHooksAPI {
onSidebarAppOpen: (handler: (...args: unknown[]) => unknown) => Disposable; onSidebarAppOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onSidebarAppClose: (handler: (...args: unknown[]) => unknown) => Disposable; onSidebarAppClose: (handler: (...args: unknown[]) => unknown) => Disposable;
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable; onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Avatar
onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable;
} }
// ─── Permission mapping for hooks ──────────────────────────── // --- Permission mapping for hooks ----------------------------
const HOOK_PERMISSIONS: Record<string, Permission> = { const HOOK_PERMISSIONS: Record<string, Permission> = {
// Email // Email
@@ -413,6 +419,8 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
// Sidebar Apps // Sidebar Apps
onSidebarAppOpen: 'ui:observe', onSidebarAppClose: 'ui:observe', onSidebarAppOpen: 'ui:observe', onSidebarAppClose: 'ui:observe',
onSidebarAppChange: 'ui:observe', onSidebarAppChange: 'ui:observe',
// Avatar
onAvatarResolve: 'email:read',
}; };
// Map hook names → actual HookBus instances // Map hook names → actual HookBus instances
@@ -459,9 +467,11 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
...Object.fromEntries(Object.entries(accountSecurityHooks)), ...Object.fromEntries(Object.entries(accountSecurityHooks)),
// Sidebar Apps // Sidebar Apps
...Object.fromEntries(Object.entries(sidebarAppHooks)), ...Object.fromEntries(Object.entries(sidebarAppHooks)),
// Avatar
...Object.fromEntries(Object.entries(avatarHooks)),
}; };
// ─── Slot registration bridge ──────────────────────────────── // --- Slot registration bridge --------------------------------
// Lazy import to avoid circular dependency — plugin-store imports plugin-api indirectly // Lazy import to avoid circular dependency — plugin-store imports plugin-api indirectly
let registerSlotFn: ((name: SlotName, reg: { pluginId: string; component: React.ComponentType<Record<string, unknown>>; order: number }) => Disposable) | null = null; let registerSlotFn: ((name: SlotName, reg: { pluginId: string; component: React.ComponentType<Record<string, unknown>>; order: number }) => Disposable) | null = null;
@@ -483,7 +493,7 @@ function registerSlot(
return registerSlotFn(slotName, { pluginId, component, order }); return registerSlotFn(slotName, { pluginId, component, order });
} }
// ─── Factory ───────────────────────────────────────────────── // --- Factory -------------------------------------------------
export function createPluginAPI(plugin: InstalledPlugin): PluginAPI { export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
// Build hooks proxy — each hook method checks permission and registers on the right bus // Build hooks proxy — each hook method checks permission and registers on the right bus
@@ -640,6 +650,32 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
warning: (message: string) => appToast.warning(message), warning: (message: string) => appToast.warning(message),
}, },
http: {
post: async (path: string, body: Record<string, unknown>) => {
requirePermission(plugin, 'http:post');
if (typeof path !== 'string' || !path.startsWith('/api/')) {
throw new Error('path must start with /api/');
}
const url = new URL(path, globalThis.location.origin);
if (url.origin !== globalThis.location.origin) {
throw new Error('path must resolve to the same origin');
}
const { client } = useAuthStore.getState();
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (client) {
headers['Authorization'] = client.getAuthHeader();
headers['X-JMAP-Username'] = client.getUsername();
}
const res = await fetch(url.pathname + url.search, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
const data = await res.json().catch(() => null);
return { ok: res.ok, status: res.status, data };
},
},
storage: createPluginStorage(plugin.id), storage: createPluginStorage(plugin.id),
log: createPluginLogger(plugin.id), log: createPluginLogger(plugin.id),
+8
View File
@@ -399,6 +399,13 @@ export const sidebarAppHooks = {
onSidebarAppChange: new HookBus(), onSidebarAppChange: new HookBus(),
}; };
// §7.21 Avatar Hooks
// Transform hook: handlers receive (currentUrl: string | null, context: { email: string; name?: string })
// and return a URL string to use as the avatar, or undefined/null to pass through to the next handler.
export const avatarHooks = {
onAvatarResolve: new HookBus(),
};
// ─── Aggregate: remove all handlers for a plugin across all buses ─── // ─── Aggregate: remove all handlers for a plugin across all buses ───
const allHookGroups = [ const allHookGroups = [
@@ -407,6 +414,7 @@ const allHookGroups = [
taskHooks, templateHooks, smimeHooks, vacationHooks, taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks, uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
avatarHooks,
]; ];
export function removeAllPluginHooks(pluginId: string): void { export function removeAllPluginHooks(pluginId: string): void {
+8 -8
View File
@@ -8,7 +8,7 @@ import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import * as ReactJSX from 'react/jsx-runtime'; import * as ReactJSX from 'react/jsx-runtime';
// ─── Shared React (window.__PLUGIN_EXTERNALS__) ───────────── // --- Shared React (window.__PLUGIN_EXTERNALS__) -------------
export function exposePluginExternals(): void { export function exposePluginExternals(): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@@ -20,7 +20,7 @@ export function exposePluginExternals(): void {
}; };
} }
// ─── Active plugin tracking ────────────────────────────────── // --- Active plugin tracking ----------------------------------
interface ActivePlugin { interface ActivePlugin {
id: string; id: string;
@@ -31,7 +31,7 @@ interface ActivePlugin {
const activePlugins = new Map<string, ActivePlugin>(); const activePlugins = new Map<string, ActivePlugin>();
// ─── Load a single plugin ──────────────────────────────────── // --- Load a single plugin ------------------------------------
type PluginStoreAccessor = { type PluginStoreAccessor = {
setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void; setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void;
@@ -101,7 +101,7 @@ export async function loadPlugin(plugin: InstalledPlugin): Promise<void> {
} }
} }
// ─── Deactivate a single plugin ────────────────────────────── // --- Deactivate a single plugin ------------------------------
export function deactivatePlugin(pluginId: string): void { export function deactivatePlugin(pluginId: string): void {
const active = activePlugins.get(pluginId); const active = activePlugins.get(pluginId);
@@ -127,7 +127,7 @@ export function deactivatePlugin(pluginId: string): void {
console.info(`[plugin-loader] Plugin "${pluginId}" deactivated`); console.info(`[plugin-loader] Plugin "${pluginId}" deactivated`);
} }
// ─── Activate all enabled plugins ──────────────────────────── // --- Activate all enabled plugins ----------------------------
export async function activateAllPlugins(plugins: InstalledPlugin[]): Promise<void> { export async function activateAllPlugins(plugins: InstalledPlugin[]): Promise<void> {
// Ensure externals are exposed // Ensure externals are exposed
@@ -139,7 +139,7 @@ export async function activateAllPlugins(plugins: InstalledPlugin[]): Promise<vo
} }
} }
// ─── Deactivate all plugins ───────────────────────────────── // --- Deactivate all plugins ---------------------------------
export function deactivateAllPlugins(): void { export function deactivateAllPlugins(): void {
for (const pluginId of [...activePlugins.keys()]) { for (const pluginId of [...activePlugins.keys()]) {
@@ -147,13 +147,13 @@ export function deactivateAllPlugins(): void {
} }
} }
// ─── Check if a plugin is active ───────────────────────────── // --- Check if a plugin is active -----------------------------
export function isPluginActive(pluginId: string): boolean { export function isPluginActive(pluginId: string): boolean {
return activePlugins.has(pluginId); return activePlugins.has(pluginId);
} }
// ─── Setup auto-disable callback ───────────────────────────── // --- Setup auto-disable callback -----------------------------
export function setupAutoDisable(): void { export function setupAutoDisable(): void {
pluginErrorTracker.setAutoDisableCallback((pluginId) => { pluginErrorTracker.setAutoDisableCallback((pluginId) => {
+3
View File
@@ -79,6 +79,8 @@ export interface InstalledPlugin {
managed?: boolean; managed?: boolean;
// True when plugin is admin-enforced and cannot be disabled locally. // True when plugin is admin-enforced and cannot be disabled locally.
forceEnabled?: boolean; forceEnabled?: boolean;
// True when plugin has been approved by an admin. Unapproved plugins cannot be enabled.
adminApproved?: boolean;
settingsSchema?: Record<string, SettingFieldSchema>; settingsSchema?: Record<string, SettingFieldSchema>;
settings: Record<string, unknown>; settings: Record<string, unknown>;
} }
@@ -402,6 +404,7 @@ export const ALL_PERMISSIONS = [
'settings:read', 'settings:write', 'settings:read', 'settings:write',
'security:read', 'security:read',
'auth:observe', 'auth:observe',
'http:post',
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer', 'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section', 'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard', 'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
+13
View File
@@ -0,0 +1,13 @@
import { readFileSync } from "fs";
export function readFileEnv(path: string | undefined): string | null {
if (!path) {
return null;
}
try {
return readFileSync(path, "utf-8").trim();
} catch {
return null;
}
}
+37
View File
@@ -35,7 +35,23 @@ export function expandRecurringEvents(
const end = parseISO(rangeEnd); const end = parseISO(rangeEnd);
const result: CalendarEvent[] = []; const result: CalendarEvent[] = [];
// Collect UIDs of master recurring events so we can skip their
// server-returned override instances (which have recurrenceId set).
// The master's recurrenceOverrides already accounts for them.
const recurringUids = new Set<string>();
for (const event of events) { for (const event of events) {
if (event.recurrenceRules?.length && event.uid && !event.recurrenceId) {
recurringUids.add(event.uid);
}
}
for (const event of events) {
// Skip override instances returned by the server — they belong to a
// master recurring event and are already handled via recurrenceOverrides.
if (event.recurrenceId && event.uid && recurringUids.has(event.uid)) {
continue;
}
if (!event.recurrenceRules?.length) { if (!event.recurrenceRules?.length) {
result.push(event); result.push(event);
continue; continue;
@@ -112,6 +128,25 @@ function createOccurrence(
? format(date, "yyyy-MM-dd'T'00:00:00") ? format(date, "yyyy-MM-dd'T'00:00:00")
: format(date, "yyyy-MM-dd'T'HH:mm:ss"); : format(date, "yyyy-MM-dd'T'HH:mm:ss");
// Compute utcStart/utcEnd for this occurrence so that getEventStartDate()
// and getEventEndDate() (which prefer utcStart/utcEnd for timed events)
// return the correct dates instead of the master's original UTC times.
let utcStart: string | undefined;
let utcEnd: string | undefined;
if (!master.showWithoutTime && master.utcStart && master.start) {
const masterLocal = parseISO(master.start);
const masterUtc = parseISO(master.utcStart);
const offsetMs = masterUtc.getTime() - masterLocal.getTime();
utcStart = new Date(date.getTime() + offsetMs).toISOString();
// Shift utcEnd by the same amount as utcStart
if (master.utcEnd) {
const masterUtcEnd = parseISO(master.utcEnd);
const durationMs = masterUtcEnd.getTime() - masterUtc.getTime();
utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString();
}
}
return { return {
...master, ...master,
...(override || {}), ...(override || {}),
@@ -120,6 +155,8 @@ function createOccurrence(
uid: master.uid, uid: master.uid,
calendarIds: master.calendarIds, calendarIds: master.calendarIds,
start: (override?.start) || startStr, start: (override?.start) || startStr,
...(utcStart && !override?.utcStart ? { utcStart } : {}),
...(utcEnd && !override?.utcEnd ? { utcEnd } : {}),
recurrenceId, recurrenceId,
recurrenceRules: master.recurrenceRules, recurrenceRules: master.recurrenceRules,
recurrenceOverrides: master.recurrenceOverrides, recurrenceOverrides: master.recurrenceOverrides,
+2 -1
View File
@@ -3,13 +3,14 @@ import { readFile, writeFile, unlink, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
const ALGORITHM = 'aes-256-gcm'; const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; const IV_LENGTH = 12;
const TAG_LENGTH = 16; const TAG_LENGTH = 16;
function getKey(): Buffer { function getKey(): Buffer {
const secret = process.env.SESSION_SECRET; const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
if (!secret) throw new Error('SESSION_SECRET not configured'); if (!secret) throw new Error('SESSION_SECRET not configured');
return createHash('sha256').update(secret).digest(); return createHash('sha256').update(secret).digest();
} }
+4 -2
View File
@@ -5,6 +5,8 @@
* All line endings are CRLF per RFC 5322. * All line endings are CRLF per RFC 5322.
*/ */
import { generateUUID } from '@/lib/utils';
const CRLF = '\r\n'; const CRLF = '\r\n';
export interface MimeAttachment { export interface MimeAttachment {
@@ -43,7 +45,7 @@ export function buildMimeMessage(input: MimeMessageInput): Uint8Array {
// BCC is intentionally omitted from the MIME headers per RFC 5322 // BCC is intentionally omitted from the MIME headers per RFC 5322
lines.push(formatHeader('Subject', encodeHeaderValue(input.subject))); lines.push(formatHeader('Subject', encodeHeaderValue(input.subject)));
lines.push(formatHeader('Date', formatDate(input.date ?? new Date()))); lines.push(formatHeader('Date', formatDate(input.date ?? new Date())));
lines.push(formatHeader('Message-ID', input.messageId ?? `<${crypto.randomUUID()}@smime.local>`)); lines.push(formatHeader('Message-ID', input.messageId ?? `<${generateUUID()}@smime.local>`));
if (input.inReplyTo) { if (input.inReplyTo) {
lines.push(formatHeader('In-Reply-To', input.inReplyTo)); lines.push(formatHeader('In-Reply-To', input.inReplyTo));
} }
@@ -245,7 +247,7 @@ export function wrapCmsAsSmimeMessage(cmsBlob: Blob | ArrayBuffer | Uint8Array,
} }
lines.push(formatHeader('Subject', encodeHeaderValue(input.subject))); lines.push(formatHeader('Subject', encodeHeaderValue(input.subject)));
lines.push(formatHeader('Date', formatDate(input.date ?? new Date()))); lines.push(formatHeader('Date', formatDate(input.date ?? new Date())));
lines.push(formatHeader('Message-ID', input.messageId ?? `<${crypto.randomUUID()}@smime.local>`)); lines.push(formatHeader('Message-ID', input.messageId ?? `<${generateUUID()}@smime.local>`));
if (input.inReplyTo) { if (input.inReplyTo) {
lines.push(formatHeader('In-Reply-To', input.inReplyTo)); lines.push(formatHeader('In-Reply-To', input.inReplyTo));
} }
+2 -1
View File
@@ -1,5 +1,6 @@
import * as asn1js from 'asn1js'; import * as asn1js from 'asn1js';
import * as pkijs from 'pkijs'; import * as pkijs from 'pkijs';
import { generateUUID } from '@/lib/utils';
import { import {
extractCertificateInfo, extractCertificateInfo,
classifyCapabilities, classifyCapabilities,
@@ -154,7 +155,7 @@ export async function importPkcs12(
const email = certInfo.emailAddresses[0] ?? ''; const email = certInfo.emailAddresses[0] ?? '';
const keyRecord: SmimeKeyRecord = { const keyRecord: SmimeKeyRecord = {
id: crypto.randomUUID(), id: generateUUID(),
email: email.toLowerCase(), email: email.toLowerCase(),
certificate: leafCertDer, certificate: leafCertDer,
certificateChain: chainCertsDer, certificateChain: chainCertsDer,
+2 -1
View File
@@ -1,6 +1,7 @@
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import type { EmailTemplate } from './template-types'; import type { EmailTemplate } from './template-types';
import { BUILT_IN_PLACEHOLDERS } from './template-types'; import { BUILT_IN_PLACEHOLDERS } from './template-types';
import { generateUUID } from './utils';
const PLACEHOLDER_REGEX = /\{\{(\w+)\}\}/g; const PLACEHOLDER_REGEX = /\{\{(\w+)\}\}/g;
const MAX_TEMPLATE_NAME_LENGTH = 200; const MAX_TEMPLATE_NAME_LENGTH = 200;
@@ -149,7 +150,7 @@ export function importTemplates(json: string): ImportResult {
const recipients = t.defaultRecipients as Record<string, unknown> | undefined; const recipients = t.defaultRecipients as Record<string, unknown> | undefined;
templates.push({ templates.push({
id: crypto.randomUUID(), id: generateUUID(),
name: sanitizeText(t.name), name: sanitizeText(t.name),
subject: sanitizeText(t.subject), subject: sanitizeText(t.subject),
body: sanitizeText(t.body), body: sanitizeText(t.body),
+19 -8
View File
@@ -152,21 +152,32 @@ export const KEYWORD_PREFIX = "$label:";
export const KEYWORD_PREFIX_LEGACY = "$color:"; export const KEYWORD_PREFIX_LEGACY = "$color:";
/** /**
* Gets label/color tag from email keywords (if any). * Gets all active label/color tag IDs from email keywords.
* Reads both the current $label: prefix and the legacy $color: prefix. * Reads both the current $label: prefix and the legacy $color: prefix.
*/ */
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null { export function getEmailColorTags(keywords: Record<string, boolean> | undefined): string[] {
if (!keywords) return null; if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) { for (const key of Object.keys(keywords)) {
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) { if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
return key.startsWith(KEYWORD_PREFIX) tags.push(
? key.slice(KEYWORD_PREFIX.length) key.startsWith(KEYWORD_PREFIX)
: key.slice(KEYWORD_PREFIX_LEGACY.length); ? key.slice(KEYWORD_PREFIX.length)
: key.slice(KEYWORD_PREFIX_LEGACY.length)
);
} }
} }
return tags;
}
return null; /**
* Gets label/color tag from email keywords (if any).
* Reads both the current $label: prefix and the legacy $color: prefix.
* @deprecated Use getEmailColorTags for multi-tag support.
*/
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
const tags = getEmailColorTags(keywords);
return tags.length > 0 ? tags[0] : null;
} }
/** /**
+27 -40
View File
@@ -7,6 +7,19 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
export function generateUUID(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// Fallback: construct a v4 UUID from crypto.getRandomValues
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
export function formatDate(date: Date | string): string { export function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date; const d = typeof date === "string" ? new Date(date) : date;
const now = new Date(); const now = new Date();
@@ -244,7 +257,10 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
`total own: ${ownMailboxes.length}, shared: ${sharedMailboxes.length}` `total own: ${ownMailboxes.length}, shared: ${sharedMailboxes.length}`
); );
// If we have shared mailboxes, create a virtual "Shared Folders" parent // For each shared account, create a virtual top-level account node
// containing that account's mailboxes. This places shared accounts as
// peers of the primary account's folders rather than nesting them under
// a "Shared Folders" wrapper. (GitHub #151)
if (sharedMailboxes.length > 0) { if (sharedMailboxes.length > 0) {
// Group shared mailboxes by account // Group shared mailboxes by account
const accountGroups = new Map<string, Mailbox[]>(); const accountGroups = new Map<string, Mailbox[]>();
@@ -256,20 +272,16 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
accountGroups.get(accountId)!.push(mb); accountGroups.get(accountId)!.push(mb);
}); });
// Create virtual nodes for each shared account
const sharedAccountNodes: MailboxNode[] = [];
accountGroups.forEach((accountMailboxes, accountId) => { accountGroups.forEach((accountMailboxes, accountId) => {
// Create account nodes // Create nodes for this account's mailboxes
const accountMailboxMap = new Map<string, MailboxNode>(); const accountMailboxMap = new Map<string, MailboxNode>();
const accountRootNodes: MailboxNode[] = []; const accountRootNodes: MailboxNode[] = [];
// Create nodes for this account's mailboxes
accountMailboxes.forEach(mailbox => { accountMailboxes.forEach(mailbox => {
accountMailboxMap.set(mailbox.id, { accountMailboxMap.set(mailbox.id, {
...mailbox, ...mailbox,
children: [], children: [],
depth: 2 // Account level is depth 1, these are depth 2 depth: 0,
}); });
}); });
@@ -285,10 +297,13 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
} }
}); });
// Correctly calculate depths from account root level down // Render the shared account's mailboxes flush with primary-account
recalculateDepths(accountRootNodes, 2); // mailboxes (depth 0) so the indents line up. The virtual account
// node visually wraps them via its chevron/header rather than via
// an extra indent level. (GitHub #151)
recalculateDepths(accountRootNodes, 0);
// Create virtual account folder node // Create virtual account folder node at top level (depth 0)
const accountName = accountMailboxes[0]?.accountName || accountId; const accountName = accountMailboxes[0]?.accountName || accountId;
const accountNode: MailboxNode = { const accountNode: MailboxNode = {
id: `shared-account-${accountId}`, id: `shared-account-${accountId}`,
@@ -314,39 +329,11 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
accountName: accountName, accountName: accountName,
isShared: true, isShared: true,
children: accountRootNodes, children: accountRootNodes,
depth: 1, depth: 0,
}; };
sharedAccountNodes.push(accountNode); rootMailboxes.push(accountNode);
}); });
// Create virtual "Shared Folders" root node
const sharedFoldersNode: MailboxNode = {
id: 'shared-folders-root',
name: 'Shared Folders',
sortOrder: 999, // After all own folders
totalEmails: sharedMailboxes.reduce((sum, mb) => sum + mb.totalEmails, 0),
unreadEmails: sharedMailboxes.reduce((sum, mb) => sum + mb.unreadEmails, 0),
totalThreads: 0,
unreadThreads: 0,
myRights: {
mayReadItems: true,
mayAddItems: false,
mayRemoveItems: false,
maySetSeen: false,
maySetKeywords: false,
mayCreateChild: false,
mayRename: false,
mayDelete: false,
maySubmit: false,
},
isSubscribed: true,
isShared: true,
children: sharedAccountNodes,
depth: 0,
};
rootMailboxes.push(sharedFoldersNode);
} }
// Smart multi-level sorting // Smart multi-level sorting
+4 -3
View File
@@ -1,4 +1,5 @@
import type { ContactCard, NameComponent, ContactOnlineService, AnniversaryDate, PartialDate } from "@/lib/jmap/types"; import type { ContactCard, NameComponent, ContactOnlineService, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
import { generateUUID } from "@/lib/utils";
// Convert RFC 9553 AnniversaryDate (PartialDate|Timestamp|string) to vCard date string // Convert RFC 9553 AnniversaryDate (PartialDate|Timestamp|string) to vCard date string
function anniversaryDateToVcardString(date: AnniversaryDate): string { function anniversaryDateToVcardString(date: AnniversaryDate): string {
@@ -153,7 +154,7 @@ export function parseVCard(vcfString: string): ContactCard[] {
} }
function buildContact(raw: Record<string, string[]>): ContactCard | null { function buildContact(raw: Record<string, string[]>): ContactCard | null {
const id = `import-${crypto.randomUUID()}`; const id = `import-${generateUUID()}`;
const card: ContactCard = { id, addressBookIds: {} }; const card: ContactCard = { id, addressBookIds: {} };
for (const [fullKey, values] of Object.entries(raw)) { for (const [fullKey, values] of Object.entries(raw)) {
@@ -526,7 +527,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
} }
} }
const hasName = card.name && card.name.components.length > 0; const hasName = card.name && (card.name.components?.length ?? 0) > 0 || !!card.name?.full;
const hasEmail = card.emails && Object.keys(card.emails).length > 0; const hasEmail = card.emails && Object.keys(card.emails).length > 0;
if (!hasName && !hasEmail && card.kind !== "group") return null; if (!hasName && !hasEmail && card.kind !== "group") return null;
@@ -563,7 +564,7 @@ function generateSingleVCard(contact: ContactCard): string {
const suffix = components.find(c => c.kind === "suffix")?.value || ""; const suffix = components.find(c => c.kind === "suffix")?.value || "";
const additional = components.find(c => c.kind === "additional")?.value || ""; const additional = components.find(c => c.kind === "additional")?.value || "";
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" "); const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ") || contact.name?.full || "";
if (fn) { if (fn) {
lines.push(`FN:${encodeValue(fn)}`); lines.push(`FN:${encodeValue(fn)}`);
lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`); lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`);
+45 -5
View File
@@ -494,7 +494,13 @@
"close_draft_message": "Sie haben ungespeicherte Änderungen. Möchten Sie diese als Entwurf speichern oder verwerfen?", "close_draft_message": "Sie haben ungespeicherte Änderungen. Möchten Sie diese als Entwurf speichern oder verwerfen?",
"save_draft": "Entwurf speichern", "save_draft": "Entwurf speichern",
"drop_files": "Dateien zum Anhängen ablegen", "drop_files": "Dateien zum Anhängen ablegen",
"show_less": "Weniger anzeigen" "show_less": "Weniger anzeigen",
"forgot_attachment": {
"title": "Haben Sie den Anhang vergessen?",
"message": "Ihre Nachricht enthält \"{keyword}\", aber es ist keine Datei angehängt. Trotzdem senden?",
"send_anyway": "Trotzdem senden",
"back": "Zurück zur Bearbeitung"
}
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Bestätigen", "confirm": "Bestätigen",
@@ -914,6 +920,15 @@
"button": "Als Standard festlegen", "button": "Als Standard festlegen",
"success": "Browser wurde aufgefordert, als Standard festzulegen", "success": "Browser wurde aufgefordert, als Standard festzulegen",
"error": "Ihr Browser unterstützt diese Funktion nicht" "error": "Ihr Browser unterstützt diese Funktion nicht"
},
"attachment_reminder": {
"label": "Erinnerung an Anhang",
"description": "Warnung anzeigen, wenn die Nachricht Anhänge erwähnt, aber keine angehängt sind",
"keywords_label": "Schlüsselwörter",
"keywords_description": "Wörter oder Phrasen, die die Erinnerung auslösen",
"add_placeholder": "Schlüsselwort hinzufügen...",
"add": "Hinzufügen",
"remove": "Entfernen"
} }
}, },
"composer": { "composer": {
@@ -993,7 +1008,7 @@
"title": "Kontosicherheit", "title": "Kontosicherheit",
"description": "Verwalten Sie Ihr Passwort, Zwei-Faktor-Authentifizierung und Sicherheitseinstellungen", "description": "Verwalten Sie Ihr Passwort, Zwei-Faktor-Authentifizierung und Sicherheitseinstellungen",
"detecting": "Serverfunktionen werden erkannt...", "detecting": "Serverfunktionen werden erkannt...",
"not_available": "Kontosicherheitsverwaltung ist für diesen Mailserver nicht verfügbar.", "not_available": "Kontosicherheitsverwaltung ist für diesen Mailserver nicht verfügbar. Erforderliche Berechtigungen könnten deaktiviert sein. Siehe die <a href=\"/docs/guides/account-security\" class=\"underline hover:opacity-80\" target=\"_blank\">Dokumentation</a> für Details.",
"password": { "password": {
"title": "Passwort ändern", "title": "Passwort ändern",
"current": "Aktuelles Passwort", "current": "Aktuelles Passwort",
@@ -1009,6 +1024,7 @@
"display_name": { "display_name": {
"label": "Anzeigename", "label": "Anzeigename",
"description": "Ihr Name, wie er auf dem Server angezeigt wird", "description": "Ihr Name, wie er auf dem Server angezeigt wird",
"placeholder": "Geben Sie Ihren Anzeigenamen ein",
"save": "Speichern", "save": "Speichern",
"success": "Anzeigename aktualisiert", "success": "Anzeigename aktualisiert",
"error": "Anzeigename konnte nicht aktualisiert werden" "error": "Anzeigename konnte nicht aktualisiert werden"
@@ -1155,6 +1171,8 @@
"error_create": "Ordner konnte nicht erstellt werden", "error_create": "Ordner konnte nicht erstellt werden",
"error_rename": "Ordner konnte nicht umbenannt werden", "error_rename": "Ordner konnte nicht umbenannt werden",
"error_delete": "Ordner konnte nicht gelöscht werden", "error_delete": "Ordner konnte nicht gelöscht werden",
"error_delete_has_children": "Ordner kann nicht gelöscht werden: Er enthält noch Unterordner. Löschen oder verschieben Sie diese zuerst.",
"error_delete_has_email": "Ordner kann nicht gelöscht werden: Er enthält noch E-Mails. Verschieben oder löschen Sie diese zuerst.",
"error_role": "Ordnerrolle konnte nicht aktualisiert werden" "error_role": "Ordnerrolle konnte nicht aktualisiert werden"
}, },
"advanced": { "advanced": {
@@ -1208,6 +1226,9 @@
"label": "Einstellungen importieren", "label": "Einstellungen importieren",
"description": "Einstellungen aus JSON-Datei hochladen", "description": "Einstellungen aus JSON-Datei hochladen",
"button": "Importieren" "button": "Importieren"
},
"about": {
"title": "Bulwark Webmail"
} }
}, },
"sidebar_apps": { "sidebar_apps": {
@@ -1224,7 +1245,13 @@
"import_label": "Import Contacts", "import_label": "Import Contacts",
"import_description": "Import contacts from a vCard (.vcf) file", "import_description": "Import contacts from a vCard (.vcf) file",
"export_label": "Export Contacts", "export_label": "Export Contacts",
"export_description": "Export all contacts as a vCard (.vcf) file" "export_description": "Export all contacts as a vCard (.vcf) file",
"manage_title": "Adressbücher",
"manage_description": "Adressbücher umbenennen",
"no_address_books": "Keine Adressbücher gefunden",
"categories_title": "Kategorien",
"categories_description": "Kontaktkategorien umbenennen",
"no_categories": "Keine Kategorien gefunden"
}, },
"filters": { "filters": {
"title": "E-Mail-Filter", "title": "E-Mail-Filter",
@@ -1629,6 +1656,10 @@
"search_placeholder": "Kontakte suchen...", "search_placeholder": "Kontakte suchen...",
"create_new": "Neuer Kontakt", "create_new": "Neuer Kontakt",
"no_category": "Ohne Kategorie", "no_category": "Ohne Kategorie",
"rename_category": "Kategorie umbenennen",
"category_name_label": "Kategoriename",
"category_renamed": "Kategorie umbenannt",
"category_rename_failed": "Kategorie konnte nicht umbenannt werden",
"category_added": "Kontakt zu {name} hinzugefügt", "category_added": "Kontakt zu {name} hinzugefügt",
"category_added_plural": "{count} Kontakte zu {name} hinzugefügt", "category_added_plural": "{count} Kontakte zu {name} hinzugefügt",
"empty_state": "Keine Kontakte", "empty_state": "Keine Kontakte",
@@ -1655,7 +1686,13 @@
"moved": "Kontakt verschoben nach {name}", "moved": "Kontakt verschoben nach {name}",
"moved_plural": "{count} Kontakte verschoben nach {name}", "moved_plural": "{count} Kontakte verschoben nach {name}",
"move_failed": "Kontakt konnte nicht verschoben werden", "move_failed": "Kontakt konnte nicht verschoben werden",
"address_book": "Adressbuch" "address_book": "Adressbuch",
"rename": "Adressbuch umbenennen",
"name_label": "Name des Adressbuchs",
"renamed": "Adressbuch umbenannt",
"rename_failed": "Adressbuch konnte nicht umbenannt werden",
"default": "Standard",
"manage": "Adressbücher verwalten"
}, },
"detail": { "detail": {
"emails": "E-Mail-Adressen", "emails": "E-Mail-Adressen",
@@ -1870,6 +1907,7 @@
"back_to_email": "Zurück zu E-Mails", "back_to_email": "Zurück zu E-Mails",
"back_to_month": "Zurück zur Monatsansicht", "back_to_month": "Zurück zur Monatsansicht",
"my_calendars": "Meine Kalender", "my_calendars": "Meine Kalender",
"birthday_calendar": "Geburtstage",
"mini_calendar_change": "Klicken, um den Monat zu wechseln", "mini_calendar_change": "Klicken, um den Monat zu wechseln",
"views": { "views": {
"month": "Monat", "month": "Monat",
@@ -2007,7 +2045,9 @@
"hover_preview_delay_500ms": "0,5 Sekunden Verzögerung", "hover_preview_delay_500ms": "0,5 Sekunden Verzögerung",
"hover_preview_delay_1s": "1 Sekunde Verzögerung", "hover_preview_delay_1s": "1 Sekunde Verzögerung",
"hover_preview_delay_2s": "2 Sekunden Verzögerung", "hover_preview_delay_2s": "2 Sekunden Verzögerung",
"hover_preview_off": "Deaktiviert" "hover_preview_off": "Deaktiviert",
"show_birthday_calendar": "Geburtstags-Kalender",
"show_birthday_calendar_desc": "Zeigt einen virtuellen Kalender mit Geburtstagen aus deinen Kontakten"
}, },
"days": { "days": {
"monday": "Montag", "monday": "Montag",
+52 -7
View File
@@ -494,7 +494,13 @@
"smime_unlock_title": "Unlock S/MIME Key", "smime_unlock_title": "Unlock S/MIME Key",
"smime_unlock_message": "Enter the passphrase to unlock your S/MIME signing key.", "smime_unlock_message": "Enter the passphrase to unlock your S/MIME signing key.",
"smime_unlock_button": "Unlock", "smime_unlock_button": "Unlock",
"smime_passphrase_placeholder": "Passphrase" "smime_passphrase_placeholder": "Passphrase",
"forgot_attachment": {
"title": "Did you forget an attachment?",
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
"send_anyway": "Send anyway",
"back": "Back to editing"
}
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Confirm", "confirm": "Confirm",
@@ -887,7 +893,10 @@
"remove": "Remove", "remove": "Remove",
"close": "Close", "close": "Close",
"invalid_email": "Please enter a valid email address", "invalid_email": "Please enter a valid email address",
"already_added": "This sender is already trusted" "already_added": "This sender is already trusted",
"save_error": "Failed to save — check the Contacts debug log for details",
"use_address_book_label": "Sync with address book",
"use_address_book_description": "Store trusted senders in a dedicated \"Trusted Senders\" address book so they sync across all your devices"
}, },
"hover_actions": { "hover_actions": {
"label": "Quick Hover Actions", "label": "Quick Hover Actions",
@@ -914,6 +923,15 @@
"button": "Set as Default", "button": "Set as Default",
"success": "Browser prompted to set as default", "success": "Browser prompted to set as default",
"error": "Your browser does not support this feature" "error": "Your browser does not support this feature"
},
"attachment_reminder": {
"label": "Attachment Reminder",
"description": "Warn before sending when your message mentions attachments but none are attached",
"keywords_label": "Trigger keywords",
"keywords_description": "Words or phrases that trigger the reminder when found in your message",
"add_placeholder": "Add keyword...",
"add": "Add",
"remove": "Remove"
} }
}, },
"composer": { "composer": {
@@ -993,7 +1011,7 @@
"title": "Account Security", "title": "Account Security",
"description": "Manage your password, two-factor authentication, and security settings", "description": "Manage your password, two-factor authentication, and security settings",
"detecting": "Detecting server capabilities...", "detecting": "Detecting server capabilities...",
"not_available": "Account security management is not available for this mail server.", "not_available": "Account security management is not available for this mail server. Required permissions may be disabled. See the <a href=\"/docs/guides/account-security\" class=\"underline hover:opacity-80\" target=\"_blank\">documentation</a> for details.",
"password": { "password": {
"title": "Change Password", "title": "Change Password",
"current": "Current Password", "current": "Current Password",
@@ -1009,6 +1027,7 @@
"display_name": { "display_name": {
"label": "Display Name", "label": "Display Name",
"description": "Your name as it appears on the server", "description": "Your name as it appears on the server",
"placeholder": "Enter your display name",
"save": "Save", "save": "Save",
"success": "Display name updated", "success": "Display name updated",
"error": "Failed to update display name" "error": "Failed to update display name"
@@ -1155,6 +1174,8 @@
"error_create": "Failed to create folder", "error_create": "Failed to create folder",
"error_rename": "Failed to rename folder", "error_rename": "Failed to rename folder",
"error_delete": "Failed to delete folder", "error_delete": "Failed to delete folder",
"error_delete_has_children": "Cannot delete folder: it still contains subfolders. Delete or move them first.",
"error_delete_has_email": "Cannot delete folder: it still contains emails. Move or delete them first.",
"error_role": "Failed to update folder role" "error_role": "Failed to update folder role"
}, },
"advanced": { "advanced": {
@@ -1179,7 +1200,9 @@
"email": "Email Viewing", "email": "Email Viewing",
"email_description": "Email rendering, TNEF processing, and mark-as-read", "email_description": "Email rendering, TNEF processing, and mark-as-read",
"push": "Push Notifications", "push": "Push Notifications",
"push_description": "Push notification setup and delivery" "push_description": "Push notification setup and delivery",
"contacts": "Contacts & Address Books",
"contacts_description": "Contact sync, address book operations, and trusted senders"
}, },
"settings_sync": { "settings_sync": {
"label": "Settings Sync", "label": "Settings Sync",
@@ -1208,6 +1231,9 @@
"label": "Import Settings", "label": "Import Settings",
"description": "Upload settings from JSON file", "description": "Upload settings from JSON file",
"button": "Import" "button": "Import"
},
"about": {
"title": "Bulwark Webmail"
} }
}, },
"sidebar_apps": { "sidebar_apps": {
@@ -1224,7 +1250,13 @@
"import_label": "Import Contacts", "import_label": "Import Contacts",
"import_description": "Import contacts from a vCard (.vcf) file", "import_description": "Import contacts from a vCard (.vcf) file",
"export_label": "Export Contacts", "export_label": "Export Contacts",
"export_description": "Export all contacts as a vCard (.vcf) file" "export_description": "Export all contacts as a vCard (.vcf) file",
"manage_title": "Address Books",
"manage_description": "Rename your address books",
"no_address_books": "No address books found",
"categories_title": "Categories",
"categories_description": "Rename contact categories",
"no_categories": "No categories found"
}, },
"filters": { "filters": {
"title": "Email Filters", "title": "Email Filters",
@@ -1629,6 +1661,10 @@
"search_placeholder": "Search contacts...", "search_placeholder": "Search contacts...",
"create_new": "New Contact", "create_new": "New Contact",
"no_category": "No Category", "no_category": "No Category",
"rename_category": "Rename category",
"category_name_label": "Category name",
"category_renamed": "Category renamed",
"category_rename_failed": "Failed to rename category",
"category_added": "Contact added to {name}", "category_added": "Contact added to {name}",
"category_added_plural": "{count} contacts added to {name}", "category_added_plural": "{count} contacts added to {name}",
"empty_state": "No contacts yet", "empty_state": "No contacts yet",
@@ -1655,7 +1691,13 @@
"moved": "Contact moved to {name}", "moved": "Contact moved to {name}",
"moved_plural": "{count} contacts moved to {name}", "moved_plural": "{count} contacts moved to {name}",
"move_failed": "Failed to move contact", "move_failed": "Failed to move contact",
"address_book": "Address Book" "address_book": "Address Book",
"rename": "Rename address book",
"name_label": "Address book name",
"renamed": "Address book renamed",
"rename_failed": "Failed to rename address book",
"default": "Default",
"manage": "Manage address books"
}, },
"detail": { "detail": {
"emails": "Email Addresses", "emails": "Email Addresses",
@@ -1870,6 +1912,7 @@
"back_to_email": "Back to email", "back_to_email": "Back to email",
"back_to_month": "Back to month", "back_to_month": "Back to month",
"my_calendars": "Calendars", "my_calendars": "Calendars",
"birthday_calendar": "Birthdays",
"mini_calendar_change": "Click to change month", "mini_calendar_change": "Click to change month",
"views": { "views": {
"month": "Month", "month": "Month",
@@ -2007,7 +2050,9 @@
"hover_preview_delay_500ms": "0.5-second delay", "hover_preview_delay_500ms": "0.5-second delay",
"hover_preview_delay_1s": "1-second delay", "hover_preview_delay_1s": "1-second delay",
"hover_preview_delay_2s": "2-second delay", "hover_preview_delay_2s": "2-second delay",
"hover_preview_off": "Disabled" "hover_preview_off": "Disabled",
"show_birthday_calendar": "Contact birthday calendar",
"show_birthday_calendar_desc": "Show a virtual calendar with birthdays from your contacts"
}, },
"days": { "days": {
"monday": "Monday", "monday": "Monday",
+45 -5
View File
@@ -494,7 +494,13 @@
"close_draft_message": "Tiene cambios sin guardar. ¿Desea guardar esto como borrador o descartarlo?", "close_draft_message": "Tiene cambios sin guardar. ¿Desea guardar esto como borrador o descartarlo?",
"save_draft": "Guardar borrador", "save_draft": "Guardar borrador",
"drop_files": "Suelta archivos para adjuntar", "drop_files": "Suelta archivos para adjuntar",
"show_less": "Mostrar menos" "show_less": "Mostrar menos",
"forgot_attachment": {
"title": "Did you forget an attachment?",
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
"send_anyway": "Send anyway",
"back": "Back to editing"
}
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Confirmar", "confirm": "Confirmar",
@@ -914,6 +920,15 @@
"button": "Establecer como predeterminado", "button": "Establecer como predeterminado",
"success": "El navegador solicitó establecer como predeterminado", "success": "El navegador solicitó establecer como predeterminado",
"error": "Su navegador no admite esta función" "error": "Su navegador no admite esta función"
},
"attachment_reminder": {
"label": "Attachment Reminder",
"description": "Warn before sending when your message mentions attachments but none are attached",
"keywords_label": "Trigger keywords",
"keywords_description": "Words or phrases that trigger the reminder when found in your message",
"add_placeholder": "Add keyword...",
"add": "Add",
"remove": "Remove"
} }
}, },
"composer": { "composer": {
@@ -993,7 +1008,7 @@
"title": "Seguridad de la cuenta", "title": "Seguridad de la cuenta",
"description": "Administre su contraseña, autenticación de dos factores y configuración de seguridad", "description": "Administre su contraseña, autenticación de dos factores y configuración de seguridad",
"detecting": "Detectando capacidades del servidor...", "detecting": "Detectando capacidades del servidor...",
"not_available": "La gestión de seguridad de la cuenta no está disponible para este servidor de correo.", "not_available": "La gestión de seguridad de la cuenta no está disponible para este servidor de correo. Es posible que los permisos requeridos estén deshabilitados. Consulte la <a href=\"/docs/guides/account-security\" class=\"underline hover:opacity-80\" target=\"_blank\">documentación</a> para obtener más detalles.",
"password": { "password": {
"title": "Cambiar contraseña", "title": "Cambiar contraseña",
"current": "Contraseña actual", "current": "Contraseña actual",
@@ -1009,6 +1024,7 @@
"display_name": { "display_name": {
"label": "Nombre para mostrar", "label": "Nombre para mostrar",
"description": "Su nombre tal como aparece en el servidor", "description": "Su nombre tal como aparece en el servidor",
"placeholder": "Ingrese su nombre para mostrar",
"save": "Guardar", "save": "Guardar",
"success": "Nombre para mostrar actualizado", "success": "Nombre para mostrar actualizado",
"error": "No se pudo actualizar el nombre para mostrar" "error": "No se pudo actualizar el nombre para mostrar"
@@ -1155,6 +1171,8 @@
"error_create": "Error al crear la carpeta", "error_create": "Error al crear la carpeta",
"error_rename": "Error al renombrar la carpeta", "error_rename": "Error al renombrar la carpeta",
"error_delete": "Error al eliminar la carpeta", "error_delete": "Error al eliminar la carpeta",
"error_delete_has_children": "No se puede eliminar la carpeta: aún contiene subcarpetas. Elimínelas o muévalas primero.",
"error_delete_has_email": "No se puede eliminar la carpeta: aún contiene correos. Muévalos o elimínelos primero.",
"error_role": "Error al actualizar el rol de la carpeta" "error_role": "Error al actualizar el rol de la carpeta"
}, },
"advanced": { "advanced": {
@@ -1208,6 +1226,9 @@
"label": "Importar Configuración", "label": "Importar Configuración",
"description": "Cargar configuración desde archivo JSON", "description": "Cargar configuración desde archivo JSON",
"button": "Importar" "button": "Importar"
},
"about": {
"title": "Bulwark Webmail"
} }
}, },
"sidebar_apps": { "sidebar_apps": {
@@ -1224,7 +1245,13 @@
"import_label": "Import Contacts", "import_label": "Import Contacts",
"import_description": "Import contacts from a vCard (.vcf) file", "import_description": "Import contacts from a vCard (.vcf) file",
"export_label": "Export Contacts", "export_label": "Export Contacts",
"export_description": "Export all contacts as a vCard (.vcf) file" "export_description": "Export all contacts as a vCard (.vcf) file",
"manage_title": "Libretas de direcciones",
"manage_description": "Renombrar tus libretas de direcciones",
"no_address_books": "No se encontraron libretas de direcciones",
"categories_title": "Categorías",
"categories_description": "Renombrar categorías de contactos",
"no_categories": "No se encontraron categorías"
}, },
"filters": { "filters": {
"title": "Filtros de correo", "title": "Filtros de correo",
@@ -1629,6 +1656,10 @@
"search_placeholder": "Buscar contactos...", "search_placeholder": "Buscar contactos...",
"create_new": "Nuevo contacto", "create_new": "Nuevo contacto",
"no_category": "Sin categoría", "no_category": "Sin categoría",
"rename_category": "Renombrar categoría",
"category_name_label": "Nombre de la categoría",
"category_renamed": "Categoría renombrada",
"category_rename_failed": "Error al renombrar la categoría",
"category_added": "Contacto añadido a {name}", "category_added": "Contacto añadido a {name}",
"category_added_plural": "{count} contactos añadidos a {name}", "category_added_plural": "{count} contactos añadidos a {name}",
"empty_state": "No hay contactos", "empty_state": "No hay contactos",
@@ -1655,7 +1686,13 @@
"moved": "Contacto movido a {name}", "moved": "Contacto movido a {name}",
"moved_plural": "{count} contactos movidos a {name}", "moved_plural": "{count} contactos movidos a {name}",
"move_failed": "Error al mover el contacto", "move_failed": "Error al mover el contacto",
"address_book": "Libreta de direcciones" "address_book": "Libreta de direcciones",
"rename": "Renombrar libreta de direcciones",
"name_label": "Nombre de la libreta de direcciones",
"renamed": "Libreta de direcciones renombrada",
"rename_failed": "Error al renombrar la libreta de direcciones",
"default": "Predeterminada",
"manage": "Administrar libretas de direcciones"
}, },
"detail": { "detail": {
"emails": "Direcciones de correo", "emails": "Direcciones de correo",
@@ -1870,6 +1907,7 @@
"back_to_email": "Volver al correo", "back_to_email": "Volver al correo",
"back_to_month": "Volver al mes", "back_to_month": "Volver al mes",
"my_calendars": "Mis calendarios", "my_calendars": "Mis calendarios",
"birthday_calendar": "Cumpleaños",
"mini_calendar_change": "Clic para cambiar de mes", "mini_calendar_change": "Clic para cambiar de mes",
"views": { "views": {
"month": "Mes", "month": "Mes",
@@ -2007,7 +2045,9 @@
"hover_preview_delay_500ms": "Retraso de 0,5 segundos", "hover_preview_delay_500ms": "Retraso de 0,5 segundos",
"hover_preview_delay_1s": "Retraso de 1 segundo", "hover_preview_delay_1s": "Retraso de 1 segundo",
"hover_preview_delay_2s": "Retraso de 2 segundos", "hover_preview_delay_2s": "Retraso de 2 segundos",
"hover_preview_off": "Desactivado" "hover_preview_off": "Desactivado",
"show_birthday_calendar": "Calendario de cumpleaños",
"show_birthday_calendar_desc": "Mostrar un calendario virtual con los cumpleaños de tus contactos"
}, },
"days": { "days": {
"monday": "Lunes", "monday": "Lunes",
+45 -5
View File
@@ -494,7 +494,13 @@
"close_draft_message": "Vous avez des modifications non enregistrées. Voulez-vous enregistrer comme brouillon ou supprimer ?", "close_draft_message": "Vous avez des modifications non enregistrées. Voulez-vous enregistrer comme brouillon ou supprimer ?",
"save_draft": "Enregistrer le brouillon", "save_draft": "Enregistrer le brouillon",
"drop_files": "Déposez les fichiers à joindre", "drop_files": "Déposez les fichiers à joindre",
"show_less": "Afficher moins" "show_less": "Afficher moins",
"forgot_attachment": {
"title": "Did you forget an attachment?",
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
"send_anyway": "Send anyway",
"back": "Back to editing"
}
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Confirmer", "confirm": "Confirmer",
@@ -914,6 +920,15 @@
"button": "Définir par défaut", "button": "Définir par défaut",
"success": "Le navigateur a été invité à définir par défaut", "success": "Le navigateur a été invité à définir par défaut",
"error": "Votre navigateur ne prend pas en charge cette fonctionnalité" "error": "Votre navigateur ne prend pas en charge cette fonctionnalité"
},
"attachment_reminder": {
"label": "Attachment Reminder",
"description": "Warn before sending when your message mentions attachments but none are attached",
"keywords_label": "Trigger keywords",
"keywords_description": "Words or phrases that trigger the reminder when found in your message",
"add_placeholder": "Add keyword...",
"add": "Add",
"remove": "Remove"
} }
}, },
"composer": { "composer": {
@@ -993,7 +1008,7 @@
"title": "Sécurité du compte", "title": "Sécurité du compte",
"description": "Gérez votre mot de passe, l'authentification à deux facteurs et les paramètres de sécurité", "description": "Gérez votre mot de passe, l'authentification à deux facteurs et les paramètres de sécurité",
"detecting": "Détection des capacités du serveur...", "detecting": "Détection des capacités du serveur...",
"not_available": "La gestion de la sécurité du compte n'est pas disponible pour ce serveur de messagerie.", "not_available": "La gestion de la sécurité du compte n'est pas disponible pour ce serveur de messagerie. Les permissions requises peuvent être désactivées. Consultez la <a href=\"/docs/guides/account-security\" class=\"underline hover:opacity-80\" target=\"_blank\">documentation</a> pour plus de détails.",
"password": { "password": {
"title": "Changer le mot de passe", "title": "Changer le mot de passe",
"current": "Mot de passe actuel", "current": "Mot de passe actuel",
@@ -1009,6 +1024,7 @@
"display_name": { "display_name": {
"label": "Nom d'affichage", "label": "Nom d'affichage",
"description": "Votre nom tel qu'il apparaît sur le serveur", "description": "Votre nom tel qu'il apparaît sur le serveur",
"placeholder": "Entrez votre nom d'affichage",
"save": "Enregistrer", "save": "Enregistrer",
"success": "Nom d'affichage mis à jour", "success": "Nom d'affichage mis à jour",
"error": "Impossible de mettre à jour le nom d'affichage" "error": "Impossible de mettre à jour le nom d'affichage"
@@ -1155,6 +1171,8 @@
"error_create": "Échec de la création du dossier", "error_create": "Échec de la création du dossier",
"error_rename": "Échec du renommage du dossier", "error_rename": "Échec du renommage du dossier",
"error_delete": "Échec de la suppression du dossier", "error_delete": "Échec de la suppression du dossier",
"error_delete_has_children": "Impossible de supprimer le dossier : il contient encore des sous-dossiers. Supprimez-les ou déplacez-les d'abord.",
"error_delete_has_email": "Impossible de supprimer le dossier : il contient encore des e-mails. Déplacez-les ou supprimez-les d'abord.",
"error_role": "Échec de la mise à jour du rôle du dossier" "error_role": "Échec de la mise à jour du rôle du dossier"
}, },
"advanced": { "advanced": {
@@ -1208,6 +1226,9 @@
"label": "Importer les paramètres", "label": "Importer les paramètres",
"description": "Charger les paramètres depuis un fichier JSON", "description": "Charger les paramètres depuis un fichier JSON",
"button": "Importer" "button": "Importer"
},
"about": {
"title": "Bulwark Webmail"
} }
}, },
"sidebar_apps": { "sidebar_apps": {
@@ -1224,7 +1245,13 @@
"import_label": "Import Contacts", "import_label": "Import Contacts",
"import_description": "Import contacts from a vCard (.vcf) file", "import_description": "Import contacts from a vCard (.vcf) file",
"export_label": "Export Contacts", "export_label": "Export Contacts",
"export_description": "Export all contacts as a vCard (.vcf) file" "export_description": "Export all contacts as a vCard (.vcf) file",
"manage_title": "Carnets d'adresses",
"manage_description": "Renommer vos carnets d'adresses",
"no_address_books": "Aucun carnet d'adresses trouvé",
"categories_title": "Catégories",
"categories_description": "Renommer les catégories de contacts",
"no_categories": "Aucune catégorie trouvée"
}, },
"filters": { "filters": {
"title": "Filtres de courrier", "title": "Filtres de courrier",
@@ -1629,6 +1656,10 @@
"search_placeholder": "Rechercher des contacts...", "search_placeholder": "Rechercher des contacts...",
"create_new": "Nouveau contact", "create_new": "Nouveau contact",
"no_category": "Sans catégorie", "no_category": "Sans catégorie",
"rename_category": "Renommer la catégorie",
"category_name_label": "Nom de la catégorie",
"category_renamed": "Catégorie renommée",
"category_rename_failed": "Échec du renommage de la catégorie",
"category_added": "Contact ajouté à {name}", "category_added": "Contact ajouté à {name}",
"category_added_plural": "{count} contacts ajoutés à {name}", "category_added_plural": "{count} contacts ajoutés à {name}",
"empty_state": "Aucun contact", "empty_state": "Aucun contact",
@@ -1655,7 +1686,13 @@
"moved": "Contact déplacé vers {name}", "moved": "Contact déplacé vers {name}",
"moved_plural": "{count} contacts déplacés vers {name}", "moved_plural": "{count} contacts déplacés vers {name}",
"move_failed": "Échec du déplacement du contact", "move_failed": "Échec du déplacement du contact",
"address_book": "Carnet d'adresses" "address_book": "Carnet d'adresses",
"rename": "Renommer le carnet d'adresses",
"name_label": "Nom du carnet d'adresses",
"renamed": "Carnet d'adresses renommé",
"rename_failed": "Échec du renommage du carnet d'adresses",
"default": "Par défaut",
"manage": "Gérer les carnets d'adresses"
}, },
"detail": { "detail": {
"emails": "Adresses e-mail", "emails": "Adresses e-mail",
@@ -1870,6 +1907,7 @@
"back_to_email": "Retour aux e-mails", "back_to_email": "Retour aux e-mails",
"back_to_month": "Retour au mois", "back_to_month": "Retour au mois",
"my_calendars": "Mes calendriers", "my_calendars": "Mes calendriers",
"birthday_calendar": "Anniversaires",
"mini_calendar_change": "Cliquer pour changer de mois", "mini_calendar_change": "Cliquer pour changer de mois",
"views": { "views": {
"month": "Mois", "month": "Mois",
@@ -2007,7 +2045,9 @@
"hover_preview_delay_500ms": "Délai de 0,5 seconde", "hover_preview_delay_500ms": "Délai de 0,5 seconde",
"hover_preview_delay_1s": "Délai de 1 seconde", "hover_preview_delay_1s": "Délai de 1 seconde",
"hover_preview_delay_2s": "Délai de 2 secondes", "hover_preview_delay_2s": "Délai de 2 secondes",
"hover_preview_off": "Désactivé" "hover_preview_off": "Désactivé",
"show_birthday_calendar": "Calendrier des anniversaires",
"show_birthday_calendar_desc": "Afficher un calendrier virtuel avec les anniversaires de vos contacts"
}, },
"days": { "days": {
"monday": "Lundi", "monday": "Lundi",

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