Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9207ec563c | ||
|
|
44e0e17203 | ||
|
|
b9c9901643 | ||
|
|
a201c1617b | ||
|
|
d24f402b0c | ||
|
|
5cfc905f10 | ||
|
|
4531cfe47c | ||
|
|
927a3b8b11 | ||
|
|
4ad8396adc | ||
|
|
6aaeb34272 | ||
|
|
ab57966a94 | ||
|
|
18d9b9adf6 | ||
|
|
ed311d79e3 | ||
|
|
457400ceee | ||
|
|
88d87be685 | ||
|
|
5ddb2acfc7 | ||
|
|
5f150f039d | ||
|
|
4f54f768e8 | ||
|
|
c24762c7a3 | ||
|
|
3e85e07363 | ||
|
|
facef97fcc | ||
|
|
89d580b90d | ||
|
|
734155c939 | ||
|
|
790d7084af | ||
|
|
60efb047d8 | ||
|
|
f9052eb23f | ||
|
|
9c5daa3918 | ||
|
|
23870801fc | ||
|
|
ae793551ba | ||
|
|
f19aaf6207 | ||
|
|
d0cc439fd1 | ||
|
|
f42f57b8d0 | ||
|
|
0d4b588fd0 | ||
|
|
6a725dde58 | ||
|
|
6499da6281 | ||
|
|
b2379fb03f | ||
|
|
b29e71124d | ||
|
|
63593e2146 | ||
|
|
10cbe7a637 | ||
|
|
79418f013a | ||
|
|
4024732696 | ||
|
|
ffad3ea78b | ||
|
|
a9b9aeb44d | ||
|
|
3d76fe6d75 | ||
|
|
58cafe28ad | ||
|
|
60f9a3dc4d | ||
|
|
11d9db5580 | ||
|
|
05d3f0b469 | ||
|
|
c3f60448ad | ||
|
|
523711cca3 | ||
|
|
bcb487810a | ||
|
|
7e1d644f7d | ||
|
|
4aac65d7d2 | ||
|
|
14ecae61dc | ||
|
|
c53ff5a30a | ||
|
|
5aad97d64e | ||
|
|
081c865018 | ||
|
|
050f38b1fa | ||
|
|
d657bdfa75 | ||
|
|
bbf724d9e1 | ||
|
|
9d8c6044e3 | ||
|
|
2d17ca71e3 | ||
|
|
d77dd1e3e1 | ||
|
|
9d97b74684 | ||
|
|
c4673acb65 | ||
|
|
1bdc51dcc7 | ||
|
|
6ee0f6a40a | ||
|
|
9ee25c930e | ||
|
|
1b2c70a90b | ||
|
|
cdea876992 | ||
|
|
16557830ae | ||
|
|
8836f9cdca | ||
|
|
26bfe9cb6c | ||
|
|
05eaaad61f | ||
|
|
2e0852b272 | ||
|
|
6173a9ad13 | ||
|
|
e3560d9cb4 | ||
|
|
52326326e2 | ||
|
|
2734fa08b7 | ||
|
|
67a0d622bc | ||
|
|
58968a1cbf |
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](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).
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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 />}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }> = [];
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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
@@ -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') {
|
||||||
|
|||||||
@@ -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
@@ -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 |
@@ -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 */}
|
||||||
|
|||||||
@@ -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$/);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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?.();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -78,14 +78,7 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us
|
|||||||
const email = currentEmails.find(em => em.id === emailId);
|
const email = currentEmails.find(em => em.id === emailId);
|
||||||
const keywords = { ...(email?.keywords || {}) };
|
const keywords = { ...(email?.keywords || {}) };
|
||||||
|
|
||||||
// Remove old label/color keywords
|
// Add the tag without removing existing ones
|
||||||
Object.keys(keywords).forEach(key => {
|
|
||||||
if (key.startsWith("$label:") || key.startsWith("$color:")) {
|
|
||||||
keywords[key] = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add the new tag
|
|
||||||
keywords[`$label:${tagId}`] = true;
|
keywords[`$label:${tagId}`] = true;
|
||||||
|
|
||||||
await client.updateEmailKeywords(emailId, keywords);
|
await client.updateEmailKeywords(emailId, keywords);
|
||||||
|
|||||||
+13
-1
@@ -26,15 +26,27 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
|||||||
case 'ja':
|
case 'ja':
|
||||||
messages = (await import('../locales/ja/common.json')).default;
|
messages = (await import('../locales/ja/common.json')).default;
|
||||||
break;
|
break;
|
||||||
|
case 'ko':
|
||||||
|
messages = (await import('../locales/ko/common.json')).default;
|
||||||
|
break;
|
||||||
|
case 'lv':
|
||||||
|
messages = (await import('../locales/lv/common.json')).default;
|
||||||
|
break;
|
||||||
case 'nl':
|
case 'nl':
|
||||||
messages = (await import('../locales/nl/common.json')).default;
|
messages = (await import('../locales/nl/common.json')).default;
|
||||||
break;
|
break;
|
||||||
|
case 'pl':
|
||||||
|
messages = (await import('../locales/pl/common.json')).default;
|
||||||
|
break;
|
||||||
case 'pt':
|
case 'pt':
|
||||||
messages = (await import('../locales/pt/common.json')).default;
|
messages = (await import('../locales/pt/common.json')).default;
|
||||||
break;
|
break;
|
||||||
case 'ru':
|
case 'ru':
|
||||||
messages = (await import('../locales/ru/common.json')).default;
|
messages = (await import('../locales/ru/common.json')).default;
|
||||||
break;
|
break;
|
||||||
|
case 'zh':
|
||||||
|
messages = (await import('../locales/zh/common.json')).default;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
messages = (await import('../locales/en/common.json')).default;
|
messages = (await import('../locales/en/common.json')).default;
|
||||||
}
|
}
|
||||||
@@ -45,4 +57,4 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
|||||||
timeZone: 'Europe/Paris',
|
timeZone: 'Europe/Paris',
|
||||||
now: new Date()
|
now: new Date()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -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'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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),
|
||||||
|
|
||||||
|
|||||||
@@ -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,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) => {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user