Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b88026de82 | ||
|
|
6ed8ae5812 | ||
|
|
089583a9ef | ||
|
|
2d834213ee | ||
|
|
439a4dbe8a | ||
|
|
8350bad2a6 | ||
|
|
2d56cc9be9 | ||
|
|
2547c10060 | ||
|
|
01779fa59e | ||
|
|
fc38427ed0 | ||
|
|
3cbfb70860 | ||
|
|
c32b740dac | ||
|
|
a02091a7ad | ||
|
|
bc311adf6a | ||
|
|
8aca1623f4 | ||
|
|
a8be40579e | ||
|
|
705b942800 | ||
|
|
d68b81e6b8 | ||
|
|
616e4d018d | ||
|
|
68e141b787 | ||
|
|
1e6f5e2c8c | ||
|
|
9495b34430 | ||
|
|
65fc489b9c | ||
|
|
bd686c092c | ||
|
|
6cff98ddb8 | ||
|
|
dcc35335f5 | ||
|
|
8a54ae2456 | ||
|
|
e26654a005 | ||
|
|
c1c06c68bb | ||
|
|
74cf642182 | ||
|
|
c5b1731a63 | ||
|
|
40cf164df3 | ||
|
|
ff56245db8 | ||
|
|
9b4de4d152 | ||
|
|
0c9e60db8b | ||
|
|
def8ee89fa | ||
|
|
e7e07a38d7 | ||
|
|
a009e5ae32 | ||
|
|
b141240fa3 | ||
|
|
44896dee3e | ||
|
|
a5c5fa6669 | ||
|
|
95af61c4be | ||
|
|
34e495dde3 | ||
|
|
0b721661e9 | ||
|
|
9fa851a674 | ||
|
|
41f91244d9 | ||
|
|
77514bd054 | ||
|
|
4501b3894b | ||
|
|
2edf2fab89 | ||
|
|
d493bb17dc | ||
|
|
234129397d | ||
|
|
9b3a47f9be | ||
|
|
0fcc932e66 |
@@ -5,9 +5,7 @@ node_modules
|
|||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.dev.example
|
!.env.dev.example
|
||||||
.claude/
|
|
||||||
scripts/
|
scripts/
|
||||||
TODO.md
|
TODO.md
|
||||||
CLAUDE.md
|
|
||||||
*.md
|
*.md
|
||||||
!README.md
|
!README.md
|
||||||
|
|||||||
@@ -20,9 +20,20 @@ on:
|
|||||||
tags: ["v*.*.*"]
|
tags: ["v*.*.*"]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: ghcr.io/${{ github.repository }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build:
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -31,9 +42,6 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -48,21 +56,75 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: ${{ env.IMAGE_NAME }}
|
||||||
ghcr.io/${{ github.repository }}
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: digests-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=sha,prefix=
|
type=sha,prefix=
|
||||||
|
|
||||||
- name: Build and push
|
- name: Create manifest list and push
|
||||||
uses: docker/build-push-action@v6
|
working-directory: /tmp/digests
|
||||||
with:
|
run: |
|
||||||
context: .
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
platforms: linux/amd64,linux/arm64
|
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
- name: Inspect image
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
run: |
|
||||||
cache-from: type=gha
|
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|||||||
@@ -42,9 +42,6 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# claude code
|
|
||||||
.claude/
|
|
||||||
|
|
||||||
# settings sync data
|
# settings sync data
|
||||||
/data/
|
/data/
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,81 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.4.6 (2026-03-21)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Demo**: Add full demo mode with fixture data for emails, calendars, contacts, files, filters, identities, mailboxes, and vacation responses
|
||||||
|
- **Demo**: Implement JMAP client interface abstraction to support demo and live backends
|
||||||
|
- **Contacts**: Add no-category filter, drag-and-drop to category, and category combo box in contact form
|
||||||
|
- **Email**: Add hover actions for emails with configurable quick-action buttons
|
||||||
|
- **Settings**: Implement keyword migration functionality for upgrading legacy email tags
|
||||||
|
- **Security**: Enhance S/MIME certificate extraction and add legacy PBE (password-based encryption) support
|
||||||
|
- **Tour**: Add interactive guided tour overlay for new user onboarding
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Settings**: Add missing `showTimeInMonthView` and `showOnMobile` type definitions to settings store
|
||||||
|
- **UI**: Adjust padding and size of sidebar buttons for improved layout
|
||||||
|
|
||||||
|
## 1.4.5 (2026-03-20)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Calendar**: Add prev/next navigation buttons and date label to desktop calendar toolbar
|
||||||
|
- **Calendar**: Add pending event preview functionality to calendar views and event modal
|
||||||
|
- **Calendar**: Add setting to show event start time in month view
|
||||||
|
- **Contacts**: Implement pagination for fetching contacts with maxObjectsInGet capability
|
||||||
|
- **Email**: Add attachment position setting in email settings
|
||||||
|
- **Layout**: Add mobile visibility toggle for sidebar apps
|
||||||
|
- **Error**: Add NotFound component to handle 404 errors and redirect unauthenticated users
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Auth**: Enhance account switching logic and clear stores on account change
|
||||||
|
- **Auth**: Improve account restoration logic and handle stale accounts
|
||||||
|
- **Auth**: Improve draft handling in email composer and enhance session cookie verification
|
||||||
|
- **Calendar**: Expand recurring events in CalendarEvent/query so individual occurrences are returned (#65)
|
||||||
|
- **Calendar**: Validate event start field when fetching calendar events
|
||||||
|
- **Calendar**: Auto-scroll agenda view to today's events and include today's date in groups
|
||||||
|
- **Calendar**: Correct JSX syntax in CalendarToolbar component
|
||||||
|
- **Dependencies**: Update flatted to 3.4.2
|
||||||
|
- **DevOps**: Use native ARM runners instead of QEMU for Docker builds
|
||||||
|
- **DevOps**: Enhance health check with detailed memory diagnostics and stable liveness probe
|
||||||
|
|
||||||
|
## 1.4.4 (2026-03-19)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Calendar**: Implement CalDAV discovery API with automatic calendar home resolution for multi-account setups
|
||||||
|
- **Calendar**: Enhance calendar management settings with mailbox role reassignment controls
|
||||||
|
- **Email**: Add signature rendering utilities with HTML-to-text conversion and sanitization
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Auth**: Fix account session handling to update existing accounts instead of duplicating entries
|
||||||
|
- **Auth**: Fix logout redirects and unauthenticated home page rendering
|
||||||
|
- **Calendar**: Fix duplicate calendar edits and prevent double-save submissions in event modal
|
||||||
|
- **Calendar**: Remove stale calendar ID references in favor of CalDAV-discovered IDs
|
||||||
|
- **Contacts**: Improve RFC 9553 compliance for contact birthdays and address formatting
|
||||||
|
- **Email**: Fix email signature rendering for identity signatures
|
||||||
|
- **Folders**: Improve mailbox role management by clearing roles from all mailboxes before reassigning
|
||||||
|
|
||||||
|
## 1.4.3 (2026-03-19)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Auth**: Implement multi-account support with up to 5 simultaneous accounts and instant switching
|
||||||
|
- **Auth**: Add account switcher component with connection status, default account selection, and per-account logout
|
||||||
|
- **Auth**: Support multi-account OAuth and basic auth with per-account session persistence
|
||||||
|
- **Contacts**: Enhance contacts sidebar with collapsible sections, bulk operations, and address book grouping
|
||||||
|
- **Contacts**: Add contact import functionality and keyword filtering
|
||||||
|
- **Settings**: Add per-account encrypted settings storage with server-side sync support
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **UI**: Adjust popover alignment in sub-address helper component
|
||||||
|
- **Settings**: Improve error logging in settings sync functionality
|
||||||
|
|
||||||
## 1.4.2 (2026-03-19)
|
## 1.4.2 (2026-03-19)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark_Logo_with_Lettering_White_and_Color.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_Logo_with_Lettering_Dark_Color.svg" />
|
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
|
||||||
<img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="480" />
|
<img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
# Bulwark Webmail
|
# Bulwark Webmail
|
||||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
Built with Next.js and the JMAP protocol.
|
Built with Next.js and the JMAP protocol.
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -25,16 +25,16 @@ Built with Next.js and the JMAP protocol.
|
|||||||
<tr>
|
<tr>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/inbox.png" width="100%" alt="Inbox - three-pane layout with sidebar, email list, and viewer (dark mode)">
|
<img src="screenshots/inbox.png" width="100%" alt="Inbox — three-pane layout with sidebar, email list, and viewer (dark mode)">
|
||||||
|
|
||||||
**Mail** - Three-pane layout with sidebar, email list, and viewer
|
**Mail** — Three-pane layout with sidebar, email list, and viewer
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
|
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
|
||||||
|
|
||||||
**Calendar** - Month, week, day, and agenda views with event management
|
**Calendar** — Month, week, day, and agenda views with event management
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -43,14 +43,14 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
|
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
|
||||||
|
|
||||||
**Contacts** - Contact management with groups and vCard support
|
**Contacts** — Contact management with groups and vCard support
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/files.png" width="100%" alt="File browser">
|
<img src="screenshots/files.png" width="100%" alt="File browser">
|
||||||
|
|
||||||
**Files** - Cloud file browser with upload, preview, and folder navigation
|
**Files** — Cloud file browser with upload, preview, and folder navigation
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -62,16 +62,16 @@ Built with Next.js and the JMAP protocol.
|
|||||||
<tr>
|
<tr>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox - light mode">
|
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox — light mode">
|
||||||
|
|
||||||
**Light mode** - Full theme support with intelligent color transformation
|
**Light mode** — Full theme support with intelligent color transformation
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/settings.png" width="100%" alt="Settings">
|
<img src="screenshots/settings.png" width="100%" alt="Settings">
|
||||||
|
|
||||||
**Settings** - Appearance, identities, filters, templates, and more
|
**Settings** — Appearance, identities, filters, templates, and more
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -80,7 +80,7 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
<img src="screenshots/login.png" width="100%" alt="Login page">
|
<img src="screenshots/login.png" width="100%" alt="Login page">
|
||||||
|
|
||||||
**Login** - Configurable branding with OAuth2/OIDC and 2FA support
|
**Login** — Configurable branding with OAuth2/OIDC and 2FA support
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
@@ -94,21 +94,21 @@ Built with Next.js and the JMAP protocol.
|
|||||||
### Mail
|
### Mail
|
||||||
|
|
||||||
- **Read, compose, reply, reply-all, forward** with rich HTML rendering
|
- **Read, compose, reply, reply-all, forward** with rich HTML rendering
|
||||||
- **Threading** - Gmail-style inline expansion with thread navigation
|
- **Threading** — Gmail-style inline expansion with thread navigation
|
||||||
- **Draft auto-save** with discard confirmation
|
- **Draft auto-save** with discard confirmation
|
||||||
- **Attachments** - upload, download, and inline preview
|
- **Attachments** — upload, download, and inline preview
|
||||||
- **Search** - full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
|
- **Search** — full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
|
||||||
- **Batch operations** - multi-select with checkboxes, archive, delete, move, tag
|
- **Batch operations** — multi-select with checkboxes, archive, delete, move, tag
|
||||||
- **Archive modes** - archive directly or organize archived mail by year or month
|
- **Archive modes** — archive directly or organize archived mail by year or month
|
||||||
- **Print** emails directly from the viewer
|
- **Print** emails directly from the viewer
|
||||||
- **Color tags/labels** and star/unstar
|
- **Color tags/labels** and star/unstar
|
||||||
- **Virtual scrolling** for large mailboxes
|
- **Virtual scrolling** for large mailboxes
|
||||||
- **Quick reply** from the viewer
|
- **Quick reply** from the viewer
|
||||||
- **Sender avatars** - favicon-based with negative caching for performance
|
- **Sender avatars** — favicon-based with negative caching for performance
|
||||||
- **Recipient popover** for quick contact interaction
|
- **Recipient popover** for quick contact interaction
|
||||||
- **Folder management** - create, rename, delete folders with icon picker and subfolder support
|
- **TNEF support** — extract Outlook `winmail.dat` message bodies and attachments automatically
|
||||||
- **Tag counts** - unread and total counts displayed in sidebar
|
- **Folder management** — create, rename, delete folders with icon picker and subfolder support
|
||||||
- **TNEF support** - extract Outlook `winmail.dat` message bodies and attachments automatically
|
- **Tag counts** — unread and total counts displayed in sidebar
|
||||||
|
|
||||||
### Calendar
|
### Calendar
|
||||||
|
|
||||||
@@ -116,8 +116,8 @@ Built with Next.js and the JMAP protocol.
|
|||||||
- **Event hover preview** popover with details
|
- **Event hover preview** popover with details
|
||||||
- **Drag-and-drop rescheduling**, click-drag creation, edge-resize (15-min snap)
|
- **Drag-and-drop rescheduling**, click-drag creation, edge-resize (15-min snap)
|
||||||
- **Recurring events** with edit/delete scope (this / this and following / all)
|
- **Recurring events** with edit/delete scope (this / this and following / all)
|
||||||
- **Participant scheduling** - iTIP invitations, organizer/attendee UI, RSVP
|
- **Participant scheduling** — iTIP invitations, organizer/attendee UI, RSVP
|
||||||
- **Inline calendar invitations** in email viewer - auto-detect `.ics`, RSVP, import
|
- **Inline calendar invitations** in email viewer — auto-detect `.ics`, RSVP, import
|
||||||
- **iCalendar import** with preview and bulk create
|
- **iCalendar import** with preview and bulk create
|
||||||
- **Notifications** with configurable sound and alert persistence
|
- **Notifications** with configurable sound and alert persistence
|
||||||
- **Real-time sync** via JMAP push
|
- **Real-time sync** via JMAP push
|
||||||
@@ -128,15 +128,15 @@ Built with Next.js and the JMAP protocol.
|
|||||||
- **Contact groups** with group expansion and member management
|
- **Contact groups** with group expansion and member management
|
||||||
- **vCard import/export** (RFC 6350) with duplicate detection
|
- **vCard import/export** (RFC 6350) with duplicate detection
|
||||||
- **Autocomplete** in composer (To/Cc/Bcc)
|
- **Autocomplete** in composer (To/Cc/Bcc)
|
||||||
- **Bulk operations** - multi-select, delete, group add, export
|
- **Bulk operations** — multi-select, delete, group add, export
|
||||||
|
|
||||||
### Filters & Automation
|
### Filters & Automation
|
||||||
|
|
||||||
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
|
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
|
||||||
- **Visual rule builder** - conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
|
- **Visual rule builder** — conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
|
||||||
- **Raw Sieve editor** with syntax validation
|
- **Raw Sieve editor** with syntax validation
|
||||||
- **Vacation responder** with date range scheduling and sidebar indicator
|
- **Vacation responder** with date range scheduling and sidebar indicator
|
||||||
- **Email templates** - reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
|
- **Email templates** — reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
|
||||||
|
|
||||||
### Files
|
### Files
|
||||||
|
|
||||||
@@ -144,39 +144,39 @@ Built with Next.js and the JMAP protocol.
|
|||||||
- **Upload and download** files with progress tracking and folder upload support
|
- **Upload and download** files with progress tracking and folder upload support
|
||||||
- **Folder navigation** with breadcrumb path and tree sidebar
|
- **Folder navigation** with breadcrumb path and tree sidebar
|
||||||
- **Grid and list views** with sorting by name, size, or date
|
- **Grid and list views** with sorting by name, size, or date
|
||||||
- **Clipboard operations** - cut, copy, paste, duplicate files
|
- **Clipboard operations** — cut, copy, paste, duplicate files
|
||||||
- **File preview** for images, text, audio, video, and more
|
- **File preview** for images, text, audio, video, and more
|
||||||
- **Favorites and recent files** for quick access
|
- **Favorites and recent files** for quick access
|
||||||
- **Bulk operations** - multi-select, delete, move, download
|
- **Bulk operations** — multi-select, delete, move, download
|
||||||
|
|
||||||
### Security & Privacy
|
### Security & Privacy
|
||||||
|
|
||||||
- **External content blocked** by default - trusted senders list for auto-load
|
- **External content blocked** by default — trusted senders list for auto-load
|
||||||
- **HTML sanitization** via DOMPurify with XSS prevention
|
- **HTML sanitization** via DOMPurify with XSS prevention
|
||||||
- **S/MIME** - manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures
|
- **S/MIME** — manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures
|
||||||
- **SPF/DKIM/DMARC** status indicators
|
- **SPF/DKIM/DMARC** status indicators
|
||||||
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode
|
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode
|
||||||
- **TOTP two-factor authentication**
|
- **TOTP two-factor authentication**
|
||||||
- **Account security panel** - manage passwords and 2FA via Stalwart admin API
|
- **Account security panel** — manage passwords and 2FA via Stalwart admin API
|
||||||
- **"Remember me"** - AES-256-GCM encrypted httpOnly cookie (opt-in)
|
- **"Remember me"** — AES-256-GCM encrypted httpOnly cookie (opt-in)
|
||||||
- **Security headers** - CSP with per-request nonce, X-Frame-Options, Referrer-Policy
|
- **Security headers** — CSP with per-request nonce, X-Frame-Options, Referrer-Policy
|
||||||
- **Newsletter unsubscribe** (RFC 2369)
|
- **Newsletter unsubscribe** (RFC 2369)
|
||||||
|
|
||||||
### Interface
|
### Interface
|
||||||
|
|
||||||
- **Three-pane layout** - sidebar, email list, viewer with resizable columns
|
- **Three-pane layout** — sidebar, email list, viewer with resizable columns
|
||||||
- **Dark and light themes** with intelligent email color transformation
|
- **Dark and light themes** with intelligent email color transformation
|
||||||
- **Responsive** - desktop sidebar + mobile bottom tab bar with tablet support
|
- **Always-light email rendering** option for problematic HTML messages in dark theme
|
||||||
- **Keyboard shortcuts** - full navigation without a mouse
|
- **Responsive** — desktop sidebar + mobile bottom tab bar with tablet support
|
||||||
|
- **Keyboard shortcuts** — full navigation without a mouse
|
||||||
- **Drag-and-drop** email organization between mailboxes and tag assignment
|
- **Drag-and-drop** email organization between mailboxes and tag assignment
|
||||||
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
|
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
|
||||||
- **Always-light email rendering** option for problematic HTML messages in dark theme
|
|
||||||
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
|
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
|
||||||
- **Sidebar apps** - pin custom tools to the navigation rail and open them inline or in a new tab
|
- **Sidebar apps** — pin custom tools to the navigation rail and open them inline or in a new tab
|
||||||
- **Settings sync** - preferences synchronized with the server (encrypted)
|
- **Settings sync** — preferences synchronized with the server (encrypted)
|
||||||
- **Storage quota** display
|
- **Storage quota** display
|
||||||
- **Shared folders** - multi-account access
|
- **Shared folders** — multi-account access
|
||||||
- **Accessibility** - WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
- **Accessibility** — WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
||||||
|
|
||||||
### Internationalization
|
### Internationalization
|
||||||
|
|
||||||
@@ -187,13 +187,13 @@ Automatic browser detection with persistent preference.
|
|||||||
### Identity Management
|
### Identity Management
|
||||||
|
|
||||||
- **Multiple sender identities** with per-identity signatures
|
- **Multiple sender identities** with per-identity signatures
|
||||||
- **Sub-addressing** - `user+tag@domain.com` with contextual tag suggestions
|
- **Identity refresh** — keep the identity manager aligned with server-side changes after edits
|
||||||
- **Identity refresh** - keep the identity manager aligned with server-side changes after edits
|
- **Sub-addressing** — `user+tag@domain.com` with contextual tag suggestions
|
||||||
- **Identity badges** in viewer and email list
|
- **Identity badges** in viewer and email list
|
||||||
|
|
||||||
### Operations
|
### Operations
|
||||||
|
|
||||||
- **Automatic update check** - server logs when a newer release is available
|
- **Automatic update check** — server logs when a newer release is available
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -211,7 +211,7 @@ Or with Docker Compose:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
# Edit .env.local - set JMAP_SERVER_URL
|
# Edit .env.local — set JMAP_SERVER_URL
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ git clone https://github.com/bulwarkmail/webmail.git
|
|||||||
cd webmail
|
cd webmail
|
||||||
npm install
|
npm install
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
# Edit .env.local - set JMAP_SERVER_URL
|
# Edit .env.local — set JMAP_SERVER_URL
|
||||||
npm run build && npm start
|
npm run build && npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -246,7 +246,7 @@ JMAP_SERVER_URL=https://mail.example.com
|
|||||||
APP_NAME=My Webmail
|
APP_NAME=My Webmail
|
||||||
```
|
```
|
||||||
|
|
||||||
All variables are **runtime** - Docker deployments can be configured without rebuilding.
|
All variables are **runtime** — Docker deployments can be configured without rebuilding.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Server Listen Address</summary>
|
<summary>Server Listen Address</summary>
|
||||||
@@ -313,7 +313,7 @@ Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day exp
|
|||||||
|
|
||||||
## Why Stalwart?
|
## Why Stalwart?
|
||||||
|
|
||||||
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** - not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies.
|
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** — not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ function OAuthCallbackInner() {
|
|||||||
sessionStorage.removeItem("oauth_state");
|
sessionStorage.removeItem("oauth_state");
|
||||||
sessionStorage.removeItem("oauth_code_verifier");
|
sessionStorage.removeItem("oauth_code_verifier");
|
||||||
sessionStorage.removeItem("oauth_server_url");
|
sessionStorage.removeItem("oauth_server_url");
|
||||||
|
sessionStorage.removeItem("oauth_add_account_mode");
|
||||||
let redirectTo = `/${params.locale}`;
|
let redirectTo = `/${params.locale}`;
|
||||||
try {
|
try {
|
||||||
const saved = sessionStorage.getItem('redirect_after_login');
|
const saved = sessionStorage.getItem('redirect_after_login');
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Plus } from "lucide-react";
|
|||||||
import {
|
import {
|
||||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||||
addMonths, subMonths, addWeeks, subWeeks, addDays, subDays,
|
addMonths, subMonths, addWeeks, subWeeks, addDays, subDays,
|
||||||
format, parseISO,
|
startOfDay, format, parseISO,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { isCalendarViewMode } from "@/stores/calendar-store";
|
import { isCalendarViewMode } from "@/stores/calendar-store";
|
||||||
@@ -25,7 +25,7 @@ import { CalendarDayView } from "@/components/calendar/calendar-day-view";
|
|||||||
import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
|
import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
|
||||||
import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
||||||
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
||||||
import { EventModal } from "@/components/calendar/event-modal";
|
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
||||||
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
||||||
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
||||||
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
|
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
|
||||||
@@ -82,6 +82,7 @@ export default function CalendarPage() {
|
|||||||
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
|
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
|
||||||
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
|
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
|
||||||
|
const [pendingPreview, setPendingPreview] = useState<PendingEventPreview | null>(null);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
|
|
||||||
// Sidebar resize state
|
// Sidebar resize state
|
||||||
@@ -156,11 +157,15 @@ export default function CalendarPage() {
|
|||||||
start: format(d, "yyyy-MM-dd'T'00:00:00"),
|
start: format(d, "yyyy-MM-dd'T'00:00:00"),
|
||||||
end: format(d, "yyyy-MM-dd'T'23:59:59"),
|
end: format(d, "yyyy-MM-dd'T'23:59:59"),
|
||||||
};
|
};
|
||||||
case "agenda":
|
case "agenda": {
|
||||||
|
// Agenda always starts from today at the earliest
|
||||||
|
const today = startOfDay(new Date());
|
||||||
|
const agendaStart = d >= today ? d : today;
|
||||||
return {
|
return {
|
||||||
start: format(d, "yyyy-MM-dd'T'00:00:00"),
|
start: format(agendaStart, "yyyy-MM-dd'T'00:00:00"),
|
||||||
end: format(addDays(d, 30), "yyyy-MM-dd'T'23:59:59"),
|
end: format(addDays(agendaStart, 30), "yyyy-MM-dd'T'23:59:59"),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [selectedDate, normalizedViewMode, firstDayOfWeek]);
|
}, [selectedDate, normalizedViewMode, firstDayOfWeek]);
|
||||||
|
|
||||||
@@ -264,12 +269,13 @@ export default function CalendarPage() {
|
|||||||
}, [closeDetail, openEditModal]);
|
}, [closeDetail, openEditModal]);
|
||||||
|
|
||||||
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
||||||
|
if (isMobile) return;
|
||||||
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
||||||
// Don't show hover popover if the sidebar is already open for this event
|
// Don't show hover popover if the sidebar is already open for this event
|
||||||
if (showEventModal && editEvent?.id === event.id) return;
|
if (showEventModal && editEvent?.id === event.id) return;
|
||||||
setDetailEvent(event);
|
setDetailEvent(event);
|
||||||
setDetailAnchorRect(anchorRect);
|
setDetailAnchorRect(anchorRect);
|
||||||
}, [showEventModal, editEvent]);
|
}, [isMobile, showEventModal, editEvent]);
|
||||||
|
|
||||||
const handleHoverLeave = useCallback(() => {
|
const handleHoverLeave = useCallback(() => {
|
||||||
hoverTimerRef.current = setTimeout(() => {
|
hoverTimerRef.current = setTimeout(() => {
|
||||||
@@ -611,6 +617,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
const visibleEvents = useMemo(() =>
|
const visibleEvents = useMemo(() =>
|
||||||
events.filter((e) => {
|
events.filter((e) => {
|
||||||
|
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));
|
||||||
}),
|
}),
|
||||||
@@ -643,6 +650,7 @@ export default function CalendarPage() {
|
|||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
pendingPreview={pendingPreview}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "week":
|
case "week":
|
||||||
@@ -659,6 +667,7 @@ export default function CalendarPage() {
|
|||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
pendingPreview={pendingPreview}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "day":
|
case "day":
|
||||||
@@ -673,6 +682,7 @@ export default function CalendarPage() {
|
|||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
pendingPreview={pendingPreview}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "agenda":
|
case "agenda":
|
||||||
@@ -703,7 +713,7 @@ export default function CalendarPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh bg-background overflow-hidden">
|
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
|
||||||
{/* Left Navigation Rail */}
|
{/* Left Navigation Rail */}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||||
@@ -711,7 +721,7 @@ export default function CalendarPage() {
|
|||||||
collapsed
|
collapsed
|
||||||
quota={quota}
|
quota={quota}
|
||||||
isPushConnected={isPushConnected}
|
isPushConnected={isPushConnected}
|
||||||
onLogout={() => { logout(); router.push('/login'); }}
|
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
onInlineApp={handleInlineApp}
|
onInlineApp={handleInlineApp}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onCloseInlineApp={closeInlineApp}
|
||||||
@@ -766,7 +776,7 @@ export default function CalendarPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!inlineApp && (
|
{!inlineApp && (
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
||||||
<CalendarToolbar
|
<CalendarToolbar
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
viewMode={normalizedViewMode}
|
viewMode={normalizedViewMode}
|
||||||
@@ -785,6 +795,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex flex-1 overflow-hidden relative"
|
className="flex flex-1 overflow-hidden relative"
|
||||||
|
data-tour="calendar-view"
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
>
|
>
|
||||||
@@ -803,7 +814,8 @@ export default function CalendarPage() {
|
|||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
onDuplicate={handleDuplicateEvent}
|
onDuplicate={handleDuplicateEvent}
|
||||||
onRsvp={handleRsvp}
|
onRsvp={handleRsvp}
|
||||||
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }}
|
||||||
|
onPreviewChange={setPendingPreview}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
/>
|
/>
|
||||||
@@ -826,13 +838,15 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{/* Mobile Bottom Navigation */}
|
{/* Mobile Bottom Navigation */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<NavigationRail
|
<div className="shrink-0">
|
||||||
orientation="horizontal"
|
<NavigationRail
|
||||||
onManageApps={handleManageApps}
|
orientation="horizontal"
|
||||||
onInlineApp={handleInlineApp}
|
onManageApps={handleManageApps}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onInlineApp={handleInlineApp}
|
||||||
activeAppId={inlineApp?.id ?? null}
|
onCloseInlineApp={closeInlineApp}
|
||||||
/>
|
activeAppId={inlineApp?.id ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{detailEvent && detailAnchorRect && (
|
{detailEvent && detailAnchorRect && (
|
||||||
|
|||||||
+113
-17
@@ -13,6 +13,7 @@ import { ContactForm } from "@/components/contacts/contact-form";
|
|||||||
import { ContactGroupForm } from "@/components/contacts/contact-group-form";
|
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 { 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 } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
@@ -73,10 +74,12 @@ export default function ContactsPage() {
|
|||||||
bulkDeleteContacts,
|
bulkDeleteContacts,
|
||||||
bulkAddToGroup,
|
bulkAddToGroup,
|
||||||
moveContactToAddressBook,
|
moveContactToAddressBook,
|
||||||
|
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 [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();
|
||||||
@@ -84,10 +87,10 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
// Panel resize state - sidebar (categories)
|
// Panel resize state - sidebar (categories)
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||||
try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 180; } catch { return 180; }
|
try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 256; } catch { return 256; }
|
||||||
});
|
});
|
||||||
const [isSidebarResizing, setIsSidebarResizing] = useState(false);
|
const [isSidebarResizing, setIsSidebarResizing] = useState(false);
|
||||||
const sidebarDragStartWidth = useRef(180);
|
const sidebarDragStartWidth = useRef(256);
|
||||||
|
|
||||||
// Panel resize state - contact list
|
// Panel resize state - contact list
|
||||||
const [listWidth, setListWidth] = useState(() => {
|
const [listWidth, setListWidth] = useState(() => {
|
||||||
@@ -123,23 +126,34 @@ export default function ContactsPage() {
|
|||||||
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
|
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
|
||||||
const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : [];
|
const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : [];
|
||||||
|
|
||||||
|
// Collect all unique keywords across contacts
|
||||||
|
const allKeywords = useMemo(() => {
|
||||||
|
const kws = new Set<string>();
|
||||||
|
for (const contact of individuals) {
|
||||||
|
if (!contact.keywords) continue;
|
||||||
|
for (const [kw, active] of Object.entries(contact.keywords)) {
|
||||||
|
if (active) kws.add(kw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(kws).sort((a, b) => a.localeCompare(b));
|
||||||
|
}, [individuals]);
|
||||||
|
|
||||||
// Contacts to display based on active category
|
// Contacts to display based on active category
|
||||||
const displayedContacts = useMemo(() => {
|
const displayedContacts = useMemo(() => {
|
||||||
if (activeCategory === "all") return individuals.filter(c => !c.isShared);
|
if (activeCategory === "all") return individuals;
|
||||||
|
if (activeCategory === "uncategorized") {
|
||||||
|
return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0);
|
||||||
|
}
|
||||||
if ("addressBookId" in activeCategory) {
|
if ("addressBookId" in activeCategory) {
|
||||||
const bookId = activeCategory.addressBookId;
|
const bookId = activeCategory.addressBookId;
|
||||||
return individuals.filter(c => {
|
return individuals.filter(c => {
|
||||||
if (!c.addressBookIds) return false;
|
if (!c.addressBookIds) return false;
|
||||||
// Check both namespaced (accountId:bookId) and raw bookId
|
return c.addressBookIds[bookId] === true;
|
||||||
if (c.addressBookIds[bookId]) return true;
|
|
||||||
// For shared contacts, match namespaced id
|
|
||||||
if (c.isShared && c.accountId) {
|
|
||||||
const namespacedId = `${c.accountId}:${Object.keys(c.addressBookIds).find(k => c.addressBookIds[k])}`;
|
|
||||||
return namespacedId === bookId;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if ("keyword" in activeCategory) {
|
||||||
|
return individuals.filter(c => c.keywords?.[activeCategory.keyword]);
|
||||||
|
}
|
||||||
// Show members of the selected group
|
// Show members of the selected group
|
||||||
return getGroupMembers(activeCategory.groupId);
|
return getGroupMembers(activeCategory.groupId);
|
||||||
}, [activeCategory, individuals, getGroupMembers]);
|
}, [activeCategory, individuals, getGroupMembers]);
|
||||||
@@ -147,10 +161,14 @@ export default function ContactsPage() {
|
|||||||
// Label for the current category
|
// Label for the current category
|
||||||
const categoryLabel = useMemo(() => {
|
const categoryLabel = useMemo(() => {
|
||||||
if (activeCategory === "all") return t("tabs.all");
|
if (activeCategory === "all") return t("tabs.all");
|
||||||
|
if (activeCategory === "uncategorized") return t("no_category");
|
||||||
if ("addressBookId" in activeCategory) {
|
if ("addressBookId" in activeCategory) {
|
||||||
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
|
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
|
||||||
return book?.name || t("tabs.all");
|
return book?.name || t("tabs.all");
|
||||||
}
|
}
|
||||||
|
if ("keyword" in activeCategory) {
|
||||||
|
return activeCategory.keyword;
|
||||||
|
}
|
||||||
const group = contacts.find(c => c.id === activeCategory.groupId);
|
const group = contacts.find(c => c.id === activeCategory.groupId);
|
||||||
return group ? getContactDisplayName(group) : t("tabs.all");
|
return group ? getContactDisplayName(group) : t("tabs.all");
|
||||||
}, [activeCategory, contacts, addressBooks, t]);
|
}, [activeCategory, contacts, addressBooks, t]);
|
||||||
@@ -160,6 +178,7 @@ export default function ContactsPage() {
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
if (typeof category === "object" && "groupId" in category) {
|
if (typeof category === "object" && "groupId" in category) {
|
||||||
setSelectedGroupId(category.groupId);
|
setSelectedGroupId(category.groupId);
|
||||||
|
setView("group-detail");
|
||||||
} else {
|
} else {
|
||||||
setSelectedGroupId(null);
|
setSelectedGroupId(null);
|
||||||
}
|
}
|
||||||
@@ -179,6 +198,38 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
}, [client, moveContactToAddressBook, t]);
|
}, [client, moveContactToAddressBook, t]);
|
||||||
|
|
||||||
|
const handleDropContactsToCategory = useCallback(async (contactIds: string[], keyword: string) => {
|
||||||
|
if (!client && supportsSync) return;
|
||||||
|
try {
|
||||||
|
for (const contactId of contactIds) {
|
||||||
|
const contact = contacts.find(c => c.id === contactId);
|
||||||
|
if (!contact) continue;
|
||||||
|
const existingKeywords = contact.keywords || {};
|
||||||
|
if (existingKeywords[keyword]) continue; // already has this keyword
|
||||||
|
const updatedKeywords = { ...existingKeywords, [keyword]: true };
|
||||||
|
if (supportsSync && client) {
|
||||||
|
await updateContact(client, contactId, { keywords: updatedKeywords });
|
||||||
|
} else {
|
||||||
|
updateLocalContact(contactId, { keywords: updatedKeywords });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const msg = contactIds.length === 1
|
||||||
|
? t("category_added", { name: keyword })
|
||||||
|
: t("category_added_plural", { count: contactIds.length, name: keyword });
|
||||||
|
toast.success(msg);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add contacts to category:', error);
|
||||||
|
toast.error(t("toast.error_update"));
|
||||||
|
}
|
||||||
|
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
|
||||||
|
|
||||||
|
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
|
||||||
|
return importContacts(
|
||||||
|
supportsSync && client ? client : null,
|
||||||
|
importedContacts
|
||||||
|
);
|
||||||
|
}, [supportsSync, client, importContacts]);
|
||||||
|
|
||||||
const handleSelectContact = (id: string) => {
|
const handleSelectContact = (id: string) => {
|
||||||
setSelectedContact(id);
|
setSelectedContact(id);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
@@ -273,6 +324,35 @@ export default function ContactsPage() {
|
|||||||
setView("group-edit");
|
setView("group-edit");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditGroupFromSidebar = useCallback((groupId: string) => {
|
||||||
|
setSelectedGroupId(groupId);
|
||||||
|
setActiveCategory({ groupId });
|
||||||
|
setView("group-edit");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: t("groups.delete_confirm_title"),
|
||||||
|
message: t("groups.delete_confirm"),
|
||||||
|
confirmText: t("form.delete"),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteGroup(supportsSync && client ? client : null, groupId);
|
||||||
|
toast.success(t("toast.deleted"));
|
||||||
|
if (selectedGroupId === groupId) {
|
||||||
|
setSelectedGroupId(null);
|
||||||
|
setActiveCategory("all");
|
||||||
|
setView("list");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete group:', error);
|
||||||
|
toast.error(t("toast.error_delete"));
|
||||||
|
}
|
||||||
|
}, [confirmDialog, deleteGroup, supportsSync, client, selectedGroupId, t]);
|
||||||
|
|
||||||
const handleDeleteGroup = async () => {
|
const handleDeleteGroup = async () => {
|
||||||
if (!selectedGroup) return;
|
if (!selectedGroup) return;
|
||||||
|
|
||||||
@@ -391,7 +471,7 @@ export default function ContactsPage() {
|
|||||||
const renderRightPanel = () => {
|
const renderRightPanel = () => {
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case "create":
|
case "create":
|
||||||
return <ContactForm addressBooks={addressBooks} onSave={handleSaveNew} onCancel={handleCancel} />;
|
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||||
|
|
||||||
case "edit":
|
case "edit":
|
||||||
if (!selectedContact) return null;
|
if (!selectedContact) return null;
|
||||||
@@ -399,6 +479,7 @@ export default function ContactsPage() {
|
|||||||
<ContactForm
|
<ContactForm
|
||||||
contact={selectedContact}
|
contact={selectedContact}
|
||||||
addressBooks={addressBooks}
|
addressBooks={addressBooks}
|
||||||
|
allKeywords={allKeywords}
|
||||||
onSave={handleSaveEdit}
|
onSave={handleSaveEdit}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
/>
|
/>
|
||||||
@@ -416,7 +497,6 @@ export default function ContactsPage() {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onSelectMember={(id) => {
|
onSelectMember={(id) => {
|
||||||
setSelectedContact(id);
|
setSelectedContact(id);
|
||||||
setActiveCategory("all");
|
|
||||||
setView("detail");
|
setView("detail");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -506,7 +586,7 @@ export default function ContactsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh bg-background overflow-hidden">
|
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
|
||||||
{/* Navigation Rail - desktop only */}
|
{/* Navigation Rail - desktop only */}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||||
@@ -514,7 +594,7 @@ export default function ContactsPage() {
|
|||||||
collapsed
|
collapsed
|
||||||
quota={quota}
|
quota={quota}
|
||||||
isPushConnected={isPushConnected}
|
isPushConnected={isPushConnected}
|
||||||
onLogout={() => { logout(); router.push('/login'); }}
|
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
onInlineApp={handleInlineApp}
|
onInlineApp={handleInlineApp}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onCloseInlineApp={closeInlineApp}
|
||||||
@@ -548,23 +628,28 @@ export default function ContactsPage() {
|
|||||||
onSelectCategory={handleSelectCategory}
|
onSelectCategory={handleSelectCategory}
|
||||||
onCreateGroup={handleCreateGroup}
|
onCreateGroup={handleCreateGroup}
|
||||||
onCreateContact={handleCreateNew}
|
onCreateContact={handleCreateNew}
|
||||||
|
onImport={() => setShowImportDialog(true)}
|
||||||
|
onEditGroup={handleEditGroupFromSidebar}
|
||||||
|
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||||
onDropContacts={handleDropContacts}
|
onDropContacts={handleDropContacts}
|
||||||
|
onDropContactsToCategory={handleDropContactsToCategory}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
||||||
onResize={(delta) => setSidebarWidth(Math.max(140, Math.min(300, sidebarDragStartWidth.current + delta)))}
|
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
|
||||||
onResizeEnd={() => {
|
onResizeEnd={() => {
|
||||||
setIsSidebarResizing(false);
|
setIsSidebarResizing(false);
|
||||||
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
|
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => { setSidebarWidth(180); localStorage.setItem("contacts-sidebar-width", "180"); }}
|
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Panel 2: Contact list */}
|
{/* Panel 2: Contact list */}
|
||||||
<div
|
<div
|
||||||
|
data-tour="contacts-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border bg-background flex flex-col flex-shrink-0",
|
"border-r border-border bg-background flex flex-col flex-shrink-0",
|
||||||
isMobile ? "w-full" : "",
|
isMobile ? "w-full" : "",
|
||||||
@@ -642,6 +727,17 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
{showImportDialog && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
|
||||||
|
<ContactImportDialog
|
||||||
|
existingContacts={contacts}
|
||||||
|
onImport={handleImportContacts}
|
||||||
|
onClose={() => setShowImportDialog(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ export default function FilesPage() {
|
|||||||
collapsed
|
collapsed
|
||||||
quota={quota}
|
quota={quota}
|
||||||
isPushConnected={isPushConnected}
|
isPushConnected={isPushConnected}
|
||||||
onLogout={() => { logout(); router.push('/login'); }}
|
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
onInlineApp={handleInlineApp}
|
onInlineApp={handleInlineApp}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onCloseInlineApp={closeInlineApp}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
|
|||||||
import { IntlProvider } from "@/components/providers/intl-provider";
|
import { IntlProvider } from "@/components/providers/intl-provider";
|
||||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||||
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
|
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
|
||||||
|
import { TourProvider } from "@/components/tour/tour-provider";
|
||||||
import { locales } from "@/i18n/routing";
|
import { locales } from "@/i18n/routing";
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
@@ -26,7 +27,9 @@ export default async function LocaleLayout({
|
|||||||
<IntlProvider locale={locale} messages={messages}>
|
<IntlProvider locale={locale} messages={messages}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<CalendarAlertProvider>
|
<CalendarAlertProvider>
|
||||||
{children}
|
<TourProvider>
|
||||||
|
{children}
|
||||||
|
</TourProvider>
|
||||||
</CalendarAlertProvider>
|
</CalendarAlertProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
|
|||||||
+216
-19
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -11,12 +11,12 @@ 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 { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield } from "lucide-react";
|
import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play } 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.2";
|
const APP_VERSION = "1.4.3";
|
||||||
|
|
||||||
const THEME_OPTIONS = [
|
const THEME_OPTIONS = [
|
||||||
{ value: "light" as const, icon: Sun, label: "Light" },
|
{ value: "light" as const, icon: Sun, label: "Light" },
|
||||||
@@ -28,9 +28,11 @@ export default function LoginPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations("login");
|
const t = useTranslations("login");
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
const searchParams = useSearchParams();
|
||||||
|
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||||
|
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||||
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig();
|
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -52,6 +54,7 @@ export default function LoginPage() {
|
|||||||
const [oauthMetadata, setOauthMetadata] = useState<OAuthMetadata | null>(null);
|
const [oauthMetadata, setOauthMetadata] = useState<OAuthMetadata | null>(null);
|
||||||
const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false);
|
const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false);
|
||||||
const [oauthLoading, setOauthLoading] = useState(false);
|
const [oauthLoading, setOauthLoading] = useState(false);
|
||||||
|
const [demoLoading, setDemoLoading] = useState(false);
|
||||||
|
|
||||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -102,7 +105,7 @@ export default function LoginPage() {
|
|||||||
}, [serverUrl]);
|
}, [serverUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated && !isAddAccountMode) {
|
||||||
let redirectTo = '/';
|
let redirectTo = '/';
|
||||||
try {
|
try {
|
||||||
const saved = sessionStorage.getItem('redirect_after_login');
|
const saved = sessionStorage.getItem('redirect_after_login');
|
||||||
@@ -113,7 +116,7 @@ export default function LoginPage() {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
router.push(redirectTo);
|
router.push(redirectTo);
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, router]);
|
}, [isAuthenticated, router, isAddAccountMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearError();
|
clearError();
|
||||||
@@ -204,7 +207,7 @@ export default function LoginPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!serverUrl) {
|
if (!serverUrl && !demoMode) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
||||||
<div className="w-full max-w-md mx-auto px-4 text-center">
|
<div className="w-full max-w-md mx-auto px-4 text-center">
|
||||||
@@ -303,6 +306,9 @@ export default function LoginPage() {
|
|||||||
sessionStorage.setItem("oauth_code_verifier", verifier);
|
sessionStorage.setItem("oauth_code_verifier", verifier);
|
||||||
sessionStorage.setItem("oauth_state", state);
|
sessionStorage.setItem("oauth_state", state);
|
||||||
sessionStorage.setItem("oauth_server_url", serverUrl!);
|
sessionStorage.setItem("oauth_server_url", serverUrl!);
|
||||||
|
if (isAddAccountMode) {
|
||||||
|
sessionStorage.setItem("oauth_add_account_mode", "true");
|
||||||
|
}
|
||||||
|
|
||||||
const authUrl = new URL(oauthMetadata.authorization_endpoint);
|
const authUrl = new URL(oauthMetadata.authorization_endpoint);
|
||||||
authUrl.searchParams.set("response_type", "code");
|
authUrl.searchParams.set("response_type", "code");
|
||||||
@@ -329,15 +335,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
saveUsername(formData.username);
|
saveUsername(formData.username);
|
||||||
let redirectTo = '/';
|
router.push('/');
|
||||||
try {
|
|
||||||
const saved = sessionStorage.getItem('redirect_after_login');
|
|
||||||
if (saved) {
|
|
||||||
sessionStorage.removeItem('redirect_after_login');
|
|
||||||
redirectTo = saved;
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
router.push(redirectTo);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -356,9 +354,167 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDemoLogin = async () => {
|
||||||
|
setDemoLoading(true);
|
||||||
|
const success = await loginDemo();
|
||||||
|
if (success) {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
setDemoLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2];
|
const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2];
|
||||||
const CurrentThemeIcon = currentThemeOption.icon;
|
const CurrentThemeIcon = currentThemeOption.icon;
|
||||||
|
|
||||||
|
// Demo-only mode: show only a large demo login button
|
||||||
|
if (demoMode && !isAddAccountMode) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
|
||||||
|
{/* Theme toggle */}
|
||||||
|
<div className="absolute top-5 right-5" ref={themeMenuRef} suppressHydrationWarning>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowThemeMenu(!showThemeMenu)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-2 rounded-xl border text-sm transition-all duration-200",
|
||||||
|
showThemeMenu
|
||||||
|
? "bg-secondary border-border text-foreground shadow-md"
|
||||||
|
: "bg-background/60 backdrop-blur-sm border-border/50 text-muted-foreground hover:text-foreground hover:bg-secondary/80 hover:border-border"
|
||||||
|
)}
|
||||||
|
aria-label={`Theme: ${currentThemeOption.label}`}
|
||||||
|
aria-expanded={showThemeMenu}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
>
|
||||||
|
<CurrentThemeIcon className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline" suppressHydrationWarning>{currentThemeOption.label}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showThemeMenu && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-2 w-40 rounded-xl border border-border bg-background shadow-lg overflow-hidden animate-fade-in z-50"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Theme selection"
|
||||||
|
>
|
||||||
|
{THEME_OPTIONS.map((option) => {
|
||||||
|
const Icon = option.icon;
|
||||||
|
const isActive = theme === option.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isActive}
|
||||||
|
onClick={() => handleThemeSelect(option.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-3 px-3.5 py-2.5 text-sm transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-foreground font-medium"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
<span className="flex-1 text-left">{option.label}</span>
|
||||||
|
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-[440px] mx-auto">
|
||||||
|
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
|
||||||
|
{/* Header with logo */}
|
||||||
|
<div className="px-8 pt-12 pb-4 text-center">
|
||||||
|
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
|
||||||
|
<img
|
||||||
|
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
|
||||||
|
alt={appName}
|
||||||
|
className="max-w-20 max-h-20 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-foreground tracking-tight">
|
||||||
|
{appName}
|
||||||
|
</h1>
|
||||||
|
<p className="text-base text-muted-foreground mt-2 max-w-xs mx-auto leading-relaxed">
|
||||||
|
{t("demo_tagline")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Large demo button */}
|
||||||
|
<div className="px-8 pb-10 pt-4">
|
||||||
|
{error && (
|
||||||
|
<div className={cn(
|
||||||
|
"mb-5 p-3.5 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3",
|
||||||
|
shakeError && "animate-shake"
|
||||||
|
)}>
|
||||||
|
<AlertCircle className="w-4.5 h-4.5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400 leading-relaxed">
|
||||||
|
{t(`error.${error}`) || t("error.generic")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-14 font-semibold text-lg bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-lg shadow-primary/25 hover:shadow-xl hover:shadow-primary/30 hover:scale-[1.02] active:scale-[0.98]"
|
||||||
|
onClick={handleDemoLogin}
|
||||||
|
disabled={demoLoading || isLoading}
|
||||||
|
>
|
||||||
|
{demoLoading ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
{t("demo_launching")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Play className="w-5 h-5" />
|
||||||
|
{t("demo_login_button")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-muted-foreground mt-4 leading-relaxed">
|
||||||
|
{t("demo_no_signup")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-6 flex flex-col items-center gap-2">
|
||||||
|
{loginCompanyName && (
|
||||||
|
<p className="text-center text-xs text-muted-foreground/60 font-medium">
|
||||||
|
{loginCompanyName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(loginImprintUrl || loginPrivacyPolicyUrl || loginWebsiteUrl) && (
|
||||||
|
<div className="flex items-center gap-3 flex-wrap justify-center">
|
||||||
|
{loginWebsiteUrl && (
|
||||||
|
<a href={loginWebsiteUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
|
||||||
|
{t("website")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{loginImprintUrl && (
|
||||||
|
<a href={loginImprintUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
|
||||||
|
{t("imprint")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{loginPrivacyPolicyUrl && (
|
||||||
|
<a href={loginPrivacyPolicyUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
|
||||||
|
{t("privacy_policy")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-center text-xs text-muted-foreground/40">
|
||||||
|
v{APP_VERSION}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
|
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
|
||||||
{/* Theme toggle - top right, dropdown style */}
|
{/* Theme toggle - top right, dropdown style */}
|
||||||
@@ -426,10 +582,10 @@ export default function LoginPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
||||||
{appName}
|
{isAddAccountMode ? t("add_account_title") : appName}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1.5">
|
<p className="text-sm text-muted-foreground mt-1.5">
|
||||||
{t("title") !== appName ? t("title") : "Sign in to your account"}
|
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -737,6 +893,47 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAddAccountMode && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full h-10 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => router.push('/')}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Demo Mode Button */}
|
||||||
|
{demoMode && !isAddAccountMode && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-border/40">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full h-11 font-medium text-[15px] rounded-xl border-border/60 hover:bg-muted/50"
|
||||||
|
onClick={handleDemoLogin}
|
||||||
|
disabled={demoLoading || isLoading}
|
||||||
|
>
|
||||||
|
{demoLoading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
{t("demo_launching")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
{t("try_demo")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<p className="text-center text-xs text-muted-foreground mt-2">
|
||||||
|
{t("demo_description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+14
-9
@@ -41,6 +41,7 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
|||||||
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";
|
||||||
|
import { appendPlainTextSignature } from "@/lib/signature-utils";
|
||||||
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square } from "lucide-react";
|
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square } from "lucide-react";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -769,7 +770,9 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
router.push('/login');
|
if (!useAuthStore.getState().isAuthenticated) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearch = async (query: string) => {
|
const handleSearch = async (query: string) => {
|
||||||
@@ -813,13 +816,13 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => {
|
const handleDownloadAttachment = async (blobId: string, name: string, type?: string, forceDownload?: boolean) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { mailAttachmentAction } = useSettingsStore.getState();
|
const { mailAttachmentAction } = useSettingsStore.getState();
|
||||||
|
|
||||||
if (mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) {
|
if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) {
|
||||||
setPreviewAttachment({ blobId, name, type });
|
setPreviewAttachment({ blobId, name, type });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -860,10 +863,7 @@ export default function Home() {
|
|||||||
const primaryIdentity = identities[0];
|
const primaryIdentity = identities[0];
|
||||||
|
|
||||||
// Append signature from the primary identity
|
// Append signature from the primary identity
|
||||||
let finalBody = body;
|
const finalBody = appendPlainTextSignature(body, primaryIdentity);
|
||||||
if (primaryIdentity?.textSignature) {
|
|
||||||
finalBody = body + '\n\n-- \n' + primaryIdentity.textSignature;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send reply with just the body text
|
// Send reply with just the body text
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
@@ -1020,6 +1020,10 @@ export default function Home() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropProvider>
|
<DragDropProvider>
|
||||||
<div className="flex flex-col h-dvh bg-background overflow-hidden">
|
<div className="flex flex-col h-dvh bg-background overflow-hidden">
|
||||||
@@ -1170,6 +1174,7 @@ export default function Home() {
|
|||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||||
data-search-input
|
data-search-input
|
||||||
|
data-tour="search-input"
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
@@ -1575,8 +1580,8 @@ export default function Home() {
|
|||||||
onNavigatePrev={handleNavigatePrev}
|
onNavigatePrev={handleNavigatePrev}
|
||||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||||
onEditDraft={handleEditDraft}
|
onEditDraft={handleEditDraft}
|
||||||
currentUserEmail={client?.["username"]}
|
currentUserEmail={client?.getUsername()}
|
||||||
currentUserName={client?.["username"]?.split("@")[0]}
|
currentUserName={client?.getUsername()?.split("@")[0]}
|
||||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
||||||
mailboxes={mailboxes}
|
mailboxes={mailboxes}
|
||||||
selectedMailbox={selectedMailbox}
|
selectedMailbox={selectedMailbox}
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ export default function SettingsPage() {
|
|||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
<div className="border-t border-border px-5 py-3">
|
<div className="border-t border-border px-5 py-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => { logout(); router.push('/login'); }}
|
onClick={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||||
className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive hover:bg-muted rounded-md px-2 transition-colors duration-150"
|
className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive hover:bg-muted rounded-md px-2 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
@@ -317,7 +317,7 @@ export default function SettingsPage() {
|
|||||||
collapsed
|
collapsed
|
||||||
quota={quota}
|
quota={quota}
|
||||||
isPushConnected={isPushConnected}
|
isPushConnected={isPushConnected}
|
||||||
onLogout={() => { logout(); router.push('/login'); }}
|
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
onInlineApp={handleInlineApp}
|
onInlineApp={handleInlineApp}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onCloseInlineApp={closeInlineApp}
|
||||||
@@ -352,7 +352,7 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex-1 overflow-y-auto py-2">
|
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
|
||||||
<div className="px-2 space-y-0.5">
|
<div className="px-2 space-y-0.5">
|
||||||
{groupedTabs.map((group, groupIndex) => (
|
{groupedTabs.map((group, groupIndex) => (
|
||||||
<div key={group.group}>
|
<div key={group.group}>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
||||||
import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
|
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -12,20 +12,30 @@ const COOKIE_OPTIONS = {
|
|||||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getSlot(request: NextRequest): number {
|
||||||
|
const raw = request.nextUrl.searchParams.get('slot');
|
||||||
|
if (raw === null) return 0;
|
||||||
|
const slot = parseInt(raw, 10);
|
||||||
|
if (isNaN(slot) || slot < 0 || slot > 4) return 0;
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') {
|
if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') {
|
||||||
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
|
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { serverUrl, username, password } = await request.json();
|
const { serverUrl, username, password, slot: bodySlot } = await request.json();
|
||||||
if (!serverUrl || !username || !password) {
|
if (!serverUrl || !username || !password) {
|
||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||||
|
const cookieName = sessionCookieName(slot);
|
||||||
const token = encryptSession(serverUrl, username, password);
|
const token = encryptSession(serverUrl, username, password);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(SESSION_COOKIE, token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -34,10 +44,12 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const slot = getSlot(request);
|
||||||
|
const cookieName = sessionCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
const token = cookieStore.get(cookieName)?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json({ error: 'No session' }, { status: 401 });
|
return NextResponse.json({ error: 'No session' }, { status: 401 });
|
||||||
@@ -45,7 +57,7 @@ export async function GET() {
|
|||||||
|
|
||||||
const credentials = decryptSession(token);
|
const credentials = decryptSession(token);
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
cookieStore.delete(SESSION_COOKIE);
|
cookieStore.delete(cookieName);
|
||||||
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,10 +70,21 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE() {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.delete(SESSION_COOKIE);
|
const all = request.nextUrl.searchParams.get('all') === 'true';
|
||||||
|
|
||||||
|
if (all) {
|
||||||
|
// Delete all session cookies (slots 0-4)
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
cookieStore.delete(sessionCookieName(i));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const slot = getSlot(request);
|
||||||
|
cookieStore.delete(sessionCookieName(slot));
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Session clear error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('Session clear error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
|||||||
+52
-10
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
import { REFRESH_TOKEN_COOKIE } from '@/lib/oauth/tokens';
|
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||||
|
|
||||||
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || '';
|
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || '';
|
||||||
|
|
||||||
@@ -14,6 +14,15 @@ const COOKIE_OPTIONS = {
|
|||||||
maxAge: 30 * 24 * 60 * 60,
|
maxAge: 30 * 24 * 60 * 60,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getSlot(request: NextRequest): number {
|
||||||
|
const raw = request.nextUrl.searchParams.get('slot');
|
||||||
|
if (raw === null) return 0;
|
||||||
|
const slot = parseInt(raw, 10);
|
||||||
|
if (isNaN(slot) || slot < 0 || slot > 4) return 0;
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function getRequiredConfig() {
|
function getRequiredConfig() {
|
||||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||||
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
||||||
@@ -53,12 +62,13 @@ function buildOAuthParams(base: Record<string, string>): URLSearchParams {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { code, code_verifier, redirect_uri } = await request.json();
|
const { code, code_verifier, redirect_uri, slot: bodySlot } = await request.json();
|
||||||
|
|
||||||
if (!code || !code_verifier || !redirect_uri) {
|
if (!code || !code_verifier || !redirect_uri) {
|
||||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||||
const tokenEndpoint = await getTokenEndpoint();
|
const tokenEndpoint = await getTokenEndpoint();
|
||||||
|
|
||||||
const params = buildOAuthParams({
|
const params = buildOAuthParams({
|
||||||
@@ -93,8 +103,9 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, tokens.refresh_token, COOKIE_OPTIONS);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -104,10 +115,12 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT() {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const slot = getSlot(request);
|
||||||
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
|
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||||
|
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
|
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
|
||||||
@@ -129,7 +142,7 @@ export async function PUT() {
|
|||||||
if (!tokenResponse.ok) {
|
if (!tokenResponse.ok) {
|
||||||
const errorText = await tokenResponse.text();
|
const errorText = await tokenResponse.text();
|
||||||
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
||||||
cookieStore.delete(REFRESH_TOKEN_COOKIE);
|
cookieStore.delete(cookieName);
|
||||||
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +154,7 @@ export async function PUT() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, tokens.refresh_token, COOKIE_OPTIONS);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -154,10 +167,39 @@ export async function PUT() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE() {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const all = request.nextUrl.searchParams.get('all') === 'true';
|
||||||
|
|
||||||
|
if (all) {
|
||||||
|
// Revoke and delete all refresh token cookies (slots 0-4)
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
const name = refreshTokenCookieName(i);
|
||||||
|
const token = cookieStore.get(name)?.value;
|
||||||
|
if (token) {
|
||||||
|
// Best-effort revocation
|
||||||
|
try {
|
||||||
|
const metadata = await getMetadata().catch(() => null);
|
||||||
|
if (metadata?.revocation_endpoint) {
|
||||||
|
const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' });
|
||||||
|
await fetch(metadata.revocation_endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString(),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
cookieStore.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = getSlot(request);
|
||||||
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
|
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||||
const metadata = await getMetadata().catch((err) => {
|
const metadata = await getMetadata().catch((err) => {
|
||||||
logger.warn('Failed to discover OAuth metadata during logout', {
|
logger.warn('Failed to discover OAuth metadata during logout', {
|
||||||
error: err instanceof Error ? err.message : 'Unknown error',
|
error: err instanceof Error ? err.message : 'Unknown error',
|
||||||
@@ -186,7 +228,7 @@ export async function DELETE() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cookieStore.delete(REFRESH_TOKEN_COOKIE);
|
cookieStore.delete(cookieName);
|
||||||
}
|
}
|
||||||
|
|
||||||
let end_session_url: string | undefined;
|
let end_session_url: string | undefined;
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
|
interface DiscoveryAccountRequest {
|
||||||
|
key: string;
|
||||||
|
candidates: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscoveryResult {
|
||||||
|
url: string | null;
|
||||||
|
resolvedAccount: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPublicUrl(serverUrl: string, path: string): string {
|
||||||
|
return new URL(path, serverUrl).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeCalendarHome(serverUrl: string, authHeader: string, accountName: string): Promise<string | null> {
|
||||||
|
const targetUrl = buildPublicUrl(serverUrl, `/dav/cal/${encodeURIComponent(accountName)}`);
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
method: 'PROPFIND',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
Depth: '0',
|
||||||
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
|
},
|
||||||
|
body: `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propfind xmlns:D="DAV:">
|
||||||
|
<D:prop>
|
||||||
|
<D:resourcetype/>
|
||||||
|
<D:displayname/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>`,
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 207) {
|
||||||
|
return targetUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status >= 300 && response.status < 400) {
|
||||||
|
const location = response.headers.get('Location');
|
||||||
|
if (location) {
|
||||||
|
return new URL(location, targetUrl).toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const creds = await getStalwartCredentials(request);
|
||||||
|
if (!creds) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const accounts = Array.isArray(body.accounts) ? body.accounts as DiscoveryAccountRequest[] : [];
|
||||||
|
const wellKnownUrl = buildPublicUrl(creds.serverUrl, '/.well-known/caldav');
|
||||||
|
const discovered: Record<string, DiscoveryResult> = {};
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
if (!account?.key) continue;
|
||||||
|
|
||||||
|
const candidates = Array.from(new Set(
|
||||||
|
(account.candidates || [])
|
||||||
|
.map((candidate) => candidate?.trim())
|
||||||
|
.filter((candidate): candidate is string => Boolean(candidate))
|
||||||
|
));
|
||||||
|
|
||||||
|
let url: string | null = null;
|
||||||
|
let resolvedAccount: string | null = null;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
url = await probeCalendarHome(creds.serverUrl, creds.authHeader, candidate);
|
||||||
|
if (url) {
|
||||||
|
resolvedAccount = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('CalDAV discovery probe failed', {
|
||||||
|
accountKey: account.key,
|
||||||
|
candidate,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
discovered[account.key] = { url, resolvedAccount };
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
wellKnownUrl,
|
||||||
|
accounts: discovered,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('CalDAV discovery failed', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,5 +35,6 @@ export async function GET() {
|
|||||||
loginImprintUrl: process.env.LOGIN_IMPRINT_URL || '',
|
loginImprintUrl: process.env.LOGIN_IMPRINT_URL || '',
|
||||||
loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '',
|
loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '',
|
||||||
loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '',
|
loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '',
|
||||||
|
demoMode: process.env.DEMO_MODE === 'true',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-35
@@ -1,10 +1,21 @@
|
|||||||
|
import v8 from 'node:v8';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
// Health check thresholds
|
const MEMORY_WARNING_THRESHOLD = 0.85;
|
||||||
const MEMORY_WARNING_THRESHOLD = 0.85; // 85% heap usage
|
const MEMORY_CRITICAL_THRESHOLD = 0.95;
|
||||||
const MEMORY_CRITICAL_THRESHOLD = 0.95; // 95% heap usage
|
|
||||||
|
function getHeapUsagePercent(heapUsed: number, heapTotal: number): number {
|
||||||
|
const heapSizeLimit = v8.getHeapStatistics().heap_size_limit;
|
||||||
|
const denominator = heapSizeLimit > 0 ? heapSizeLimit : heapTotal;
|
||||||
|
|
||||||
|
if (denominator <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (heapUsed / denominator) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
interface HealthStatus {
|
interface HealthStatus {
|
||||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||||
@@ -14,6 +25,7 @@ interface HealthStatus {
|
|||||||
memory?: {
|
memory?: {
|
||||||
heapUsed: number;
|
heapUsed: number;
|
||||||
heapTotal: number;
|
heapTotal: number;
|
||||||
|
heapSizeLimit: number;
|
||||||
rss: number;
|
rss: number;
|
||||||
external: number;
|
external: number;
|
||||||
heapUsagePercent: number;
|
heapUsagePercent: number;
|
||||||
@@ -27,14 +39,9 @@ interface HealthStatus {
|
|||||||
/**
|
/**
|
||||||
* Health check endpoint for container orchestration
|
* Health check endpoint for container orchestration
|
||||||
*
|
*
|
||||||
* GET /api/health - Basic health check (returns 200 OK or 503 Service Unavailable)
|
* GET /api/health - Liveness probe for container orchestration
|
||||||
* GET /api/health?detailed=true - Detailed diagnostics with memory stats
|
* GET /api/health?detailed=true - Diagnostics with advisory memory warnings
|
||||||
* HEAD /api/health - Lightweight health check (status code only)
|
* HEAD /api/health - Lightweight liveness probe (status code only)
|
||||||
*
|
|
||||||
* Health status based on Node.js heap usage:
|
|
||||||
* - Healthy (200): < 85% heap usage
|
|
||||||
* - Degraded (200): 85-95% heap usage (warnings in detailed mode)
|
|
||||||
* - Unhealthy (503): > 95% heap usage
|
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
@@ -43,38 +50,31 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const memUsage = process.memoryUsage();
|
const memUsage = process.memoryUsage();
|
||||||
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
|
const heapSizeLimit = v8.getHeapStatistics().heap_size_limit;
|
||||||
|
const heapUsagePercent = getHeapUsagePercent(memUsage.heapUsed, memUsage.heapTotal);
|
||||||
// Determine health status based on memory usage
|
|
||||||
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
|
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
let httpStatus = 200;
|
|
||||||
|
|
||||||
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
|
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
|
||||||
status = 'unhealthy';
|
status = 'degraded';
|
||||||
httpStatus = 503;
|
warnings.push(`V8 heap usage is very high: ${heapUsagePercent.toFixed(1)}% of heap limit`);
|
||||||
} else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) {
|
} else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) {
|
||||||
status = 'degraded';
|
status = 'degraded';
|
||||||
warnings.push(`Memory usage high: ${heapUsagePercent.toFixed(1)}%`);
|
warnings.push(`V8 heap usage is high: ${heapUsagePercent.toFixed(1)}% of heap limit`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build response
|
|
||||||
const response: HealthStatus = {
|
const response: HealthStatus = {
|
||||||
status,
|
status: detailed ? status : 'healthy',
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status === 'unhealthy') {
|
|
||||||
response.reason = `Memory usage critical: ${heapUsagePercent.toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add detailed information if requested
|
|
||||||
if (detailed) {
|
if (detailed) {
|
||||||
response.uptime = process.uptime();
|
response.uptime = process.uptime();
|
||||||
response.version = process.env.npm_package_version || '0.1.0';
|
response.version = process.env.npm_package_version || '0.1.0';
|
||||||
response.memory = {
|
response.memory = {
|
||||||
heapUsed: memUsage.heapUsed,
|
heapUsed: memUsage.heapUsed,
|
||||||
heapTotal: memUsage.heapTotal,
|
heapTotal: memUsage.heapTotal,
|
||||||
|
heapSizeLimit,
|
||||||
rss: memUsage.rss,
|
rss: memUsage.rss,
|
||||||
external: memUsage.external,
|
external: memUsage.external,
|
||||||
heapUsagePercent: Number(heapUsagePercent.toFixed(2)),
|
heapUsagePercent: Number(heapUsagePercent.toFixed(2)),
|
||||||
@@ -87,10 +87,8 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Health check', { status, detailed });
|
|
||||||
|
|
||||||
return NextResponse.json(response, {
|
return NextResponse.json(response, {
|
||||||
status: httpStatus,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||||
'Pragma': 'no-cache',
|
'Pragma': 'no-cache',
|
||||||
@@ -116,13 +114,6 @@ export async function GET(request: NextRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function HEAD() {
|
export async function HEAD() {
|
||||||
try {
|
try {
|
||||||
const memUsage = process.memoryUsage();
|
|
||||||
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
|
|
||||||
|
|
||||||
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
|
|
||||||
return new Response(null, { status: 503 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
} catch {
|
} catch {
|
||||||
return new Response(null, { status: 503 });
|
return new Response(null, { status: 503 });
|
||||||
|
|||||||
+26
-11
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { decryptSession } from '@/lib/auth/crypto';
|
import { decryptSession } from '@/lib/auth/crypto';
|
||||||
import { SESSION_COOKIE } from '@/lib/auth/session-cookie';
|
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
||||||
|
|
||||||
function isEnabled(): boolean {
|
function isEnabled(): boolean {
|
||||||
@@ -10,19 +10,30 @@ function isEnabled(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify identity against the session cookie if available.
|
* Verify identity against session cookies across all account slots.
|
||||||
* Returns true if no session cookie exists (can't verify) or if identity matches.
|
* With multi-account, the requesting account may be on any slot (0-4).
|
||||||
* Returns false if session cookie exists but identity doesn't match.
|
* Returns true if any slot matches OR if no session cookies exist at all.
|
||||||
*/
|
*/
|
||||||
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 sessionToken = cookieStore.get(SESSION_COOKIE)?.value;
|
let hasAnyCookie = false;
|
||||||
if (!sessionToken) return true; // No session cookie, can't verify (same-origin protection applies)
|
|
||||||
|
|
||||||
const session = decryptSession(sessionToken);
|
for (let slot = 0; slot <= 4; slot++) {
|
||||||
if (!session) return true; // Invalid session cookie, skip verification
|
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||||
|
if (!token) continue;
|
||||||
|
hasAnyCookie = true;
|
||||||
|
|
||||||
return session.username === username && session.serverUrl === serverUrl;
|
const session = decryptSession(token);
|
||||||
|
if (session && session.username === username && session.serverUrl === serverUrl) {
|
||||||
|
return true; // Found a matching slot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cookies at all → can't verify, allow (same-origin protection applies)
|
||||||
|
if (!hasAnyCookie) return true;
|
||||||
|
|
||||||
|
// Cookies exist but none matched → identity mismatch
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -47,7 +58,9 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
return NextResponse.json({ settings });
|
return NextResponse.json({ settings });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Settings load error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
logger.error('Settings load error', { error: message, code });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,7 +87,9 @@ export async function POST(request: NextRequest) {
|
|||||||
await saveUserSettings(username, serverUrl, settings);
|
await saveUserSettings(username, serverUrl, settings);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Settings save error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
logger.error('Settings save error', { error: message, code });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
--color-accent-foreground: #1e40af;
|
--color-accent-foreground: #1e40af;
|
||||||
--color-destructive: #ef4444;
|
--color-destructive: #ef4444;
|
||||||
--color-destructive-foreground: #ffffff;
|
--color-destructive-foreground: #ffffff;
|
||||||
|
--color-popover: #ffffff;
|
||||||
|
--color-popover-foreground: #0f172a;
|
||||||
|
|
||||||
/* Settings variables */
|
/* Settings variables */
|
||||||
--font-size-base: 16px;
|
--font-size-base: 16px;
|
||||||
@@ -50,6 +52,8 @@
|
|||||||
--color-accent-foreground: #dbeafe;
|
--color-accent-foreground: #dbeafe;
|
||||||
--color-destructive: #ef4444;
|
--color-destructive: #ef4444;
|
||||||
--color-destructive-foreground: #fafafa;
|
--color-destructive-foreground: #fafafa;
|
||||||
|
--color-popover: #1c1c1c;
|
||||||
|
--color-popover-foreground: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -68,6 +72,8 @@
|
|||||||
--color-accent-foreground: var(--color-accent-foreground);
|
--color-accent-foreground: var(--color-accent-foreground);
|
||||||
--color-destructive: var(--color-destructive);
|
--color-destructive: var(--color-destructive);
|
||||||
--color-destructive-foreground: var(--color-destructive-foreground);
|
--color-destructive-foreground: var(--color-destructive-foreground);
|
||||||
|
--color-popover: var(--color-popover);
|
||||||
|
--color-popover-foreground: var(--color-popover-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="text-center max-w-md px-4">
|
||||||
|
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
|
||||||
|
<p className="text-muted-foreground mb-6">This page could not be found.</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
Go home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useRef, useEffect, useCallback } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { format, parseISO, isToday, isTomorrow } from "date-fns";
|
import { format, parseISO, isToday, isTomorrow, startOfDay } from "date-fns";
|
||||||
import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react";
|
import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { getEventDayBounds } from "@/lib/calendar-utils";
|
import { getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ interface DayGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarAgendaView({
|
export function CalendarAgendaView({
|
||||||
|
selectedDate,
|
||||||
events,
|
events,
|
||||||
calendars,
|
calendars,
|
||||||
onSelectEvent,
|
onSelectEvent,
|
||||||
@@ -43,6 +44,9 @@ export function CalendarAgendaView({
|
|||||||
return map;
|
return map;
|
||||||
}, [calendars]);
|
}, [calendars]);
|
||||||
|
|
||||||
|
const todayRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const sorted = [...events].sort((a, b) =>
|
const sorted = [...events].sort((a, b) =>
|
||||||
new Date(a.start).getTime() - new Date(b.start).getTime()
|
new Date(a.start).getTime() - new Date(b.start).getTime()
|
||||||
@@ -69,10 +73,38 @@ export function CalendarAgendaView({
|
|||||||
} catch { /* skip invalid dates */ }
|
} catch { /* skip invalid dates */ }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Always include today's date in the groups so the view has a "Today" anchor
|
||||||
|
const todayKey = format(new Date(), "yyyy-MM-dd");
|
||||||
|
if (!groupMap.has(todayKey)) {
|
||||||
|
const todayGroup = { date: startOfDay(new Date()), dateKey: todayKey, events: [] as CalendarEvent[] };
|
||||||
|
groupMap.set(todayKey, todayGroup);
|
||||||
|
groups.push(todayGroup);
|
||||||
|
}
|
||||||
|
|
||||||
groups.sort((a, b) => a.date.getTime() - b.date.getTime());
|
groups.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||||
return groups;
|
return groups;
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
|
// Auto-scroll to today's section on mount and when selectedDate changes to today
|
||||||
|
const scrollToToday = useCallback(() => {
|
||||||
|
if (todayRef.current) {
|
||||||
|
todayRef.current.scrollIntoView({ block: "start" });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Scroll to today on mount
|
||||||
|
const frame = requestAnimationFrame(scrollToToday);
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [scrollToToday]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Scroll to today when selectedDate changes to today
|
||||||
|
if (isToday(selectedDate)) {
|
||||||
|
scrollToToday();
|
||||||
|
}
|
||||||
|
}, [selectedDate, scrollToToday]);
|
||||||
|
|
||||||
const formatDateHeader = (date: Date): string => {
|
const formatDateHeader = (date: Date): string => {
|
||||||
if (isToday(date)) return t("events.today_header");
|
if (isToday(date)) return t("events.today_header");
|
||||||
if (isTomorrow(date)) return t("events.tomorrow_header");
|
if (isTomorrow(date)) return t("events.tomorrow_header");
|
||||||
@@ -86,19 +118,10 @@ export function CalendarAgendaView({
|
|||||||
return format(date, "HH:mm");
|
return format(date, "HH:mm");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (grouped.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground">
|
|
||||||
<CalendarIcon className="w-12 h-12 mb-3 opacity-30" />
|
|
||||||
<p className="text-sm">{t("events.no_events")}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
|
||||||
{grouped.map((group) => (
|
{grouped.map((group) => (
|
||||||
<div key={group.dateKey}>
|
<div key={group.dateKey} ref={isToday(group.date) ? todayRef : undefined}>
|
||||||
<div className="sticky top-0 bg-muted/80 backdrop-blur-sm px-4 py-2 border-b border-border">
|
<div className="sticky top-0 bg-muted/80 backdrop-blur-sm px-4 py-2 border-b border-border">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-sm font-medium",
|
"text-sm font-medium",
|
||||||
@@ -111,10 +134,15 @@ export function CalendarAgendaView({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{group.events.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t("events.no_events")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{group.events.map((ev) => {
|
{group.events.map((ev) => {
|
||||||
const calId = Object.keys(ev.calendarIds)[0];
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const calendar = calendarMap.get(calId);
|
const calendar = calId ? calendarMap.get(calId) : undefined;
|
||||||
const color = getEventColor(ev, calendar);
|
const color = getEventColor(ev, calendar);
|
||||||
const start = parseISO(ev.start);
|
const start = parseISO(ev.start);
|
||||||
const durMin = parseDuration(ev.duration);
|
const durMin = parseDuration(ev.duration);
|
||||||
@@ -176,6 +204,7 @@ export function CalendarAgendaView({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useMemo, useEffect, useRef, useState } from "react";
|
import { useMemo, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { format, isToday, parseISO } from "date-fns";
|
import { format, isSameDay, isToday, parseISO } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { EventCard, parseDuration } from "./event-card";
|
||||||
import { QuickEventInput } from "./quick-event-input";
|
import { QuickEventInput } from "./quick-event-input";
|
||||||
import { getEventDayBounds, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils";
|
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
|
|
||||||
interface CalendarDayViewProps {
|
interface CalendarDayViewProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -20,6 +21,7 @@ interface CalendarDayViewProps {
|
|||||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
pendingPreview?: PendingEventPreview | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOUR_HEIGHT = 64;
|
const HOUR_HEIGHT = 64;
|
||||||
@@ -35,6 +37,7 @@ export function CalendarDayView({
|
|||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
isMobile,
|
isMobile,
|
||||||
|
pendingPreview,
|
||||||
}: CalendarDayViewProps) {
|
}: CalendarDayViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
@@ -127,12 +130,12 @@ export function CalendarDayView({
|
|||||||
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{allDayEvents.map((ev) => {
|
{allDayEvents.map((ev) => {
|
||||||
const calId = Object.keys(ev.calendarIds)[0];
|
const calId = getPrimaryCalendarId(ev);
|
||||||
return (
|
return (
|
||||||
<EventCard
|
<EventCard
|
||||||
key={ev.id}
|
key={ev.id}
|
||||||
event={ev}
|
event={ev}
|
||||||
calendar={calendarMap.get(calId)}
|
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||||
variant="chip"
|
variant="chip"
|
||||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||||
@@ -192,7 +195,7 @@ export function CalendarDayView({
|
|||||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
const top = (startMin / 60) * HOUR_HEIGHT;
|
||||||
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||||
const calId = Object.keys(ev.calendarIds)[0];
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const leftPct = (column / totalColumns) * 100;
|
const leftPct = (column / totalColumns) * 100;
|
||||||
const widthPct = (1 / totalColumns) * 100;
|
const widthPct = (1 / totalColumns) * 100;
|
||||||
|
|
||||||
@@ -205,7 +208,7 @@ export function CalendarDayView({
|
|||||||
>
|
>
|
||||||
<EventCard
|
<EventCard
|
||||||
event={ev}
|
event={ev}
|
||||||
calendar={calendarMap.get(calId)}
|
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||||
variant="block"
|
variant="block"
|
||||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||||
@@ -274,6 +277,34 @@ export function CalendarDayView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, selectedDate) && (
|
||||||
|
(() => {
|
||||||
|
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
|
||||||
|
const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
|
||||||
|
const durationMin = Math.max(15, endMin - startMin);
|
||||||
|
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
|
||||||
|
const color = cal?.color || "hsl(var(--primary))";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute left-2 right-2 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
|
||||||
|
style={{
|
||||||
|
top: (startMin / 60) * HOUR_HEIGHT,
|
||||||
|
height: Math.max(24, (durationMin / 60) * HOUR_HEIGHT),
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: `${color}10`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-medium px-1.5 py-0.5 truncate" style={{ color }}>
|
||||||
|
{pendingPreview.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] px-1.5 opacity-70" style={{ color }}>
|
||||||
|
{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import {
|
|||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard } from "./event-card";
|
import { EventCard } from "./event-card";
|
||||||
import { buildWeekSegments, getEventDayBounds } from "@/lib/calendar-utils";
|
import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
interface CalendarMonthViewProps {
|
interface CalendarMonthViewProps {
|
||||||
@@ -25,6 +26,7 @@ interface CalendarMonthViewProps {
|
|||||||
onCreateAtTime?: (date: Date) => void;
|
onCreateAtTime?: (date: Date) => void;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
pendingPreview?: PendingEventPreview | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarMonthView({
|
export function CalendarMonthView({
|
||||||
@@ -38,6 +40,7 @@ export function CalendarMonthView({
|
|||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
isMobile,
|
isMobile,
|
||||||
|
pendingPreview,
|
||||||
}: CalendarMonthViewProps) {
|
}: CalendarMonthViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
@@ -194,35 +197,67 @@ export function CalendarMonthView({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
dayEvents.length > 0 && (
|
<div className="flex items-center justify-center gap-0.5 flex-wrap">
|
||||||
<div className="flex items-center justify-center gap-0.5 flex-wrap">
|
{dayEvents.slice(0, 3).map((ev) => {
|
||||||
{dayEvents.slice(0, 3).map((ev) => {
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const calId = Object.keys(ev.calendarIds)[0];
|
const cal = calId ? calendarMap.get(calId) : undefined;
|
||||||
const cal = calendarMap.get(calId);
|
const evColor = ev.color || cal?.color || "#3b82f6";
|
||||||
const evColor = ev.color || cal?.color || "#3b82f6";
|
return (
|
||||||
return (
|
<span
|
||||||
<span
|
key={ev.id}
|
||||||
key={ev.id}
|
className="w-1.5 h-1.5 rounded-full"
|
||||||
className="w-1.5 h-1.5 rounded-full"
|
style={{ backgroundColor: evColor }}
|
||||||
style={{ backgroundColor: evColor }}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
{dayEvents.length > 3 && (
|
||||||
{dayEvents.length > 3 && (
|
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
)}
|
||||||
)}
|
{pendingPreview && isSameDay(pendingPreview.start, day) && (
|
||||||
</div>
|
<span
|
||||||
)
|
className="w-1.5 h-1.5 rounded-full border border-dashed"
|
||||||
|
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isMobile && pendingPreview && (() => {
|
||||||
|
const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start));
|
||||||
|
if (previewDayIdx === -1) return null;
|
||||||
|
const previewRow = rowCount;
|
||||||
|
const cal = calendarMap.get(pendingPreview.calendarId);
|
||||||
|
const color = cal?.color || "#3b82f6";
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
||||||
|
<div
|
||||||
|
className="absolute px-0.5"
|
||||||
|
style={{
|
||||||
|
left: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
|
||||||
|
width: `calc(${(1 / 7) * 100}% - 2px)`,
|
||||||
|
top: previewRow * 22,
|
||||||
|
height: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate border-2 border-dashed"
|
||||||
|
style={{ borderColor: color, color, backgroundColor: `${color}10` }}
|
||||||
|
>
|
||||||
|
{pendingPreview.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{!isMobile && segments.length > 0 && (
|
{!isMobile && segments.length > 0 && (
|
||||||
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
||||||
{segments.map((segment) => {
|
{segments.map((segment) => {
|
||||||
const calId = Object.keys(segment.event.calendarIds)[0];
|
const calId = getPrimaryCalendarId(segment.event);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
|
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
|
||||||
@@ -236,7 +271,7 @@ export function CalendarMonthView({
|
|||||||
>
|
>
|
||||||
<EventCard
|
<EventCard
|
||||||
event={segment.event}
|
event={segment.event}
|
||||||
calendar={calendarMap.get(calId)}
|
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||||
variant="span"
|
variant="span"
|
||||||
continuesBefore={segment.continuesBefore}
|
continuesBefore={segment.continuesBefore}
|
||||||
continuesAfter={segment.continuesAfter}
|
continuesAfter={segment.continuesAfter}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { CalendarColorPicker } from "@/components/settings/calendar-management-s
|
|||||||
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 { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import type { JMAPClient } from "@/lib/jmap/client";
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
|
||||||
interface CalendarSidebarPanelProps {
|
interface CalendarSidebarPanelProps {
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
@@ -17,7 +17,7 @@ interface CalendarSidebarPanelProps {
|
|||||||
onToggleVisibility: (id: string) => void;
|
onToggleVisibility: (id: string) => void;
|
||||||
onColorChange?: (calendarId: string, color: string) => void;
|
onColorChange?: (calendarId: string, color: string) => void;
|
||||||
onSubscribe?: () => void;
|
onSubscribe?: () => void;
|
||||||
client?: JMAPClient | null;
|
client?: IJMAPClient | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarSidebarPanel({
|
export function CalendarSidebarPanel({
|
||||||
@@ -47,7 +47,7 @@ export function CalendarSidebarPanel({
|
|||||||
const shared = calendars.filter(c => c.isShared);
|
const shared = calendars.filter(c => c.isShared);
|
||||||
const groups = new Map<string, { accountName: string; calendars: Calendar[] }>();
|
const groups = new Map<string, { accountName: string; calendars: Calendar[] }>();
|
||||||
for (const cal of shared) {
|
for (const cal of shared) {
|
||||||
const key = cal.accountId!;
|
const key = cal.accountId || cal.accountName || cal.id;
|
||||||
if (!groups.has(key)) {
|
if (!groups.has(key)) {
|
||||||
groups.set(key, { accountName: cal.accountName || key, calendars: [] });
|
groups.set(key, { accountName: cal.accountName || key, calendars: [] });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,20 @@ export function CalendarToolbar({
|
|||||||
{t("views.today")}
|
{t("views.today")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-base font-semibold ml-2 select-none">
|
||||||
|
{getDateLabel()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isMobile && calendars && selectedCalendarIds && onToggleVisibility && (
|
{isMobile && calendars && selectedCalendarIds && onToggleVisibility && (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<Button
|
<Button
|
||||||
@@ -183,7 +197,7 @@ export function CalendarToolbar({
|
|||||||
const shared = calendars.filter(c => c.isShared);
|
const shared = calendars.filter(c => c.isShared);
|
||||||
const groups = new Map<string, { accountName: string; cals: typeof shared }>();
|
const groups = new Map<string, { accountName: string; cals: typeof shared }>();
|
||||||
for (const c of shared) {
|
for (const c of shared) {
|
||||||
const key = c.accountId!;
|
const key = c.accountId || c.accountName || c.id;
|
||||||
if (!groups.has(key)) groups.set(key, { accountName: c.accountName || key, cals: [] });
|
if (!groups.has(key)) groups.set(key, { accountName: c.accountName || key, cals: [] });
|
||||||
groups.get(key)!.cals.push(c);
|
groups.get(key)!.cals.push(c);
|
||||||
}
|
}
|
||||||
@@ -312,7 +326,7 @@ export function CalendarToolbar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Button size="sm" onClick={onCreateEvent}>
|
<Button size="sm" onClick={onCreateEvent} data-tour="create-event-button">
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
{t("events.create")}
|
{t("events.create")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { EventCard, parseDuration } from "./event-card";
|
||||||
import { QuickEventInput } from "./quick-event-input";
|
import { QuickEventInput } from "./quick-event-input";
|
||||||
import { buildWeekSegments, getEventDayBounds, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils";
|
import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
|
|
||||||
interface CalendarWeekViewProps {
|
interface CalendarWeekViewProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -24,6 +25,7 @@ interface CalendarWeekViewProps {
|
|||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
pendingPreview?: PendingEventPreview | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOUR_HEIGHT = 60;
|
const HOUR_HEIGHT = 60;
|
||||||
@@ -41,6 +43,7 @@ export function CalendarWeekView({
|
|||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
isMobile,
|
isMobile,
|
||||||
|
pendingPreview,
|
||||||
}: CalendarWeekViewProps) {
|
}: CalendarWeekViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
@@ -162,7 +165,7 @@ export function CalendarWeekView({
|
|||||||
|
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
{allDaySegments.map((segment) => {
|
{allDaySegments.map((segment) => {
|
||||||
const calId = Object.keys(segment.event.calendarIds)[0];
|
const calId = getPrimaryCalendarId(segment.event);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
|
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
|
||||||
@@ -176,7 +179,7 @@ export function CalendarWeekView({
|
|||||||
>
|
>
|
||||||
<EventCard
|
<EventCard
|
||||||
event={segment.event}
|
event={segment.event}
|
||||||
calendar={calendarMap.get(calId)}
|
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||||
variant="span"
|
variant="span"
|
||||||
continuesBefore={segment.continuesBefore}
|
continuesBefore={segment.continuesBefore}
|
||||||
continuesAfter={segment.continuesAfter}
|
continuesAfter={segment.continuesAfter}
|
||||||
@@ -284,7 +287,7 @@ export function CalendarWeekView({
|
|||||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
const top = (startMin / 60) * HOUR_HEIGHT;
|
||||||
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
||||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||||
const calId = Object.keys(ev.calendarIds)[0];
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const leftPct = (column / totalColumns) * 100;
|
const leftPct = (column / totalColumns) * 100;
|
||||||
const widthPct = (1 / totalColumns) * 100;
|
const widthPct = (1 / totalColumns) * 100;
|
||||||
|
|
||||||
@@ -297,7 +300,7 @@ export function CalendarWeekView({
|
|||||||
>
|
>
|
||||||
<EventCard
|
<EventCard
|
||||||
event={ev}
|
event={ev}
|
||||||
calendar={calendarMap.get(calId)}
|
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||||
variant="block"
|
variant="block"
|
||||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||||
@@ -366,6 +369,34 @@ export function CalendarWeekView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && (
|
||||||
|
(() => {
|
||||||
|
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
|
||||||
|
const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
|
||||||
|
const durationMin = Math.max(15, endMin - startMin);
|
||||||
|
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
|
||||||
|
const color = cal?.color || "hsl(var(--primary))";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute left-1 right-1 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
|
||||||
|
style={{
|
||||||
|
top: (startMin / 60) * HOUR_HEIGHT,
|
||||||
|
height: Math.max(20, (durationMin / 60) * HOUR_HEIGHT),
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: `${color}10`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-medium px-1.5 py-0.5 truncate" style={{ color }}>
|
||||||
|
{pendingPreview.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] px-1.5 opacity-70" style={{ color }}>
|
||||||
|
{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
const color = getEventColor(event, calendar);
|
const color = getEventColor(event, calendar);
|
||||||
const startDate = parseISO(event.start);
|
const startDate = parseISO(event.start);
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
|
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
||||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||||
|
|
||||||
const calendarName = calendar?.name || "";
|
const calendarName = calendar?.name || "";
|
||||||
@@ -156,6 +157,9 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
|
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 min-w-0">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
|
{showTimeInMonthView && !event.showWithoutTime && (
|
||||||
|
<span className="flex-shrink-0 opacity-80">{format(startDate, timeFmt)}</span>
|
||||||
|
)}
|
||||||
<span className="truncate font-medium">{event.title || t("events.no_title")}</span>
|
<span className="truncate font-medium">{event.title || t("events.no_title")}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Vid
|
|||||||
import { format, parseISO, addHours, addDays } from "date-fns";
|
import { format, parseISO, addHours, addDays } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { buildAllDayDuration, getEventDisplayEndDate } from "@/lib/calendar-utils";
|
import { buildAllDayDuration, getEventDisplayEndDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { ParticipantInput } from "./participant-input";
|
import { ParticipantInput } from "./participant-input";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
@@ -20,16 +20,25 @@ import {
|
|||||||
} from "@/lib/calendar-participants";
|
} from "@/lib/calendar-participants";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
|
export interface PendingEventPreview {
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
title: string;
|
||||||
|
allDay: boolean;
|
||||||
|
calendarId: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface EventModalProps {
|
interface EventModalProps {
|
||||||
event?: CalendarEvent | null;
|
event?: CalendarEvent | null;
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
defaultDate?: Date;
|
defaultDate?: Date;
|
||||||
defaultEndDate?: Date;
|
defaultEndDate?: Date;
|
||||||
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void;
|
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
|
||||||
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
||||||
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
||||||
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
|
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onPreviewChange?: (preview: PendingEventPreview | null) => void;
|
||||||
currentUserEmails?: string[];
|
currentUserEmails?: string[];
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
}
|
}
|
||||||
@@ -105,6 +114,7 @@ export function EventModal({
|
|||||||
onDuplicate,
|
onDuplicate,
|
||||||
onRsvp,
|
onRsvp,
|
||||||
onClose,
|
onClose,
|
||||||
|
onPreviewChange,
|
||||||
currentUserEmails = [],
|
currentUserEmails = [],
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
}: EventModalProps) {
|
}: EventModalProps) {
|
||||||
@@ -184,7 +194,7 @@ export function EventModal({
|
|||||||
const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd()));
|
const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd()));
|
||||||
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
|
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
|
||||||
const [calendarId, setCalendarId] = useState<string>(() => {
|
const [calendarId, setCalendarId] = useState<string>(() => {
|
||||||
if (event?.calendarIds) return Object.keys(event.calendarIds)[0] || calendars[0]?.id || "";
|
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
|
||||||
const defaultCal = calendars.find(c => c.isDefault);
|
const defaultCal = calendars.find(c => c.isDefault);
|
||||||
return defaultCal?.id || calendars[0]?.id || "";
|
return defaultCal?.id || calendars[0]?.id || "";
|
||||||
});
|
});
|
||||||
@@ -209,6 +219,7 @@ export function EventModal({
|
|||||||
return "none";
|
return "none";
|
||||||
});
|
});
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
const [attendees, setAttendees] = useState<{ name: string; email: string }[]>(() => {
|
const [attendees, setAttendees] = useState<{ name: string; email: string }[]>(() => {
|
||||||
if (!event?.participants) return [];
|
if (!event?.participants) return [];
|
||||||
@@ -218,6 +229,18 @@ export function EventModal({
|
|||||||
});
|
});
|
||||||
const [sendInvitations, setSendInvitations] = useState(true);
|
const [sendInvitations, setSendInvitations] = useState(true);
|
||||||
|
|
||||||
|
// Report live preview to parent for grid outline
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onPreviewChange || isEdit) return;
|
||||||
|
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
|
||||||
|
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
|
||||||
|
const s = new Date(startStr);
|
||||||
|
const e = new Date(endStr);
|
||||||
|
if (isNaN(s.getTime()) || isNaN(e.getTime())) return;
|
||||||
|
onPreviewChange({ start: s, end: e, title: title || "(No title)", allDay, calendarId });
|
||||||
|
return () => onPreviewChange(null);
|
||||||
|
}, [startDate, startTime, endDate, endTime, allDay, title, calendarId, isEdit, onPreviewChange]);
|
||||||
|
|
||||||
const statusCounts = useMemo(() => {
|
const statusCounts = useMemo(() => {
|
||||||
if (!event?.participants) return null;
|
if (!event?.participants) return null;
|
||||||
return getStatusCounts(event);
|
return getStatusCounts(event);
|
||||||
@@ -231,9 +254,9 @@ export function EventModal({
|
|||||||
setAttendees(prev => prev.filter(a => a.email.toLowerCase() !== email.toLowerCase()));
|
setAttendees(prev => prev.filter(a => a.email.toLowerCase() !== email.toLowerCase()));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(async () => {
|
||||||
const trimmedTitle = title.trim();
|
const trimmedTitle = title.trim();
|
||||||
if (!trimmedTitle) return;
|
if (!trimmedTitle || isSaving) return;
|
||||||
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return;
|
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return;
|
||||||
|
|
||||||
const startStr = allDay
|
const startStr = allDay
|
||||||
@@ -343,8 +366,13 @@ export function EventModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shouldSendScheduling = attendees.length > 0 && sendInvitations;
|
const shouldSendScheduling = attendees.length > 0 && sendInvitations;
|
||||||
onSave(data, shouldSendScheduling);
|
setIsSaving(true);
|
||||||
}, [title, description, location, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave]);
|
try {
|
||||||
|
await onSave(data, shouldSendScheduling);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [title, description, location, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
||||||
|
|
||||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||||
if (!event || !userParticipantId || !onRsvp) return;
|
if (!event || !userParticipantId || !onRsvp) return;
|
||||||
@@ -701,7 +729,7 @@ export function EventModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-lg font-semibold">
|
||||||
{isEdit ? t("events.edit") : t("events.create")}
|
{isEdit ? t("events.edit") : t("events.create")}
|
||||||
@@ -945,7 +973,7 @@ export function EventModal({
|
|||||||
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
|
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
|
||||||
{t("form.cancel")}
|
{t("form.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={!title.trim()}>
|
<Button onClick={handleSave} disabled={!title.trim() || isSaving}>
|
||||||
{t("form.save")}
|
{t("form.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
|
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import type { JMAPClient } from "@/lib/jmap/client";
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
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 { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
interface ICalImportModalProps {
|
interface ICalImportModalProps {
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
client: JMAPClient;
|
client: IJMAPClient;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { useState, useRef, useEffect, useCallback } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { X, Loader2, Globe } from "lucide-react";
|
import { X, Loader2, Globe } from "lucide-react";
|
||||||
import type { JMAPClient } from "@/lib/jmap/client";
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
interface ICalSubscriptionModalProps {
|
interface ICalSubscriptionModalProps {
|
||||||
client: JMAPClient;
|
client: IJMAPClient;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Co
|
|||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard } from "@/lib/jmap/types";
|
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
||||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||||
import { useSmimeStore } from "@/stores/smime-store";
|
import { useSmimeStore } from "@/stores/smime-store";
|
||||||
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
||||||
@@ -26,12 +26,23 @@ function formatPhoneFeatures(features?: Record<string, boolean>): string {
|
|||||||
return Object.keys(features).filter(k => features[k]).join(", ");
|
return Object.keys(features).filter(k => features[k]).join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateInput: string | Record<string, unknown>): string {
|
function formatDate(dateInput: AnniversaryDate): string {
|
||||||
// Handle RFC 9553 PartialDate objects: { year?, month?, day?, calendarScale? }
|
// Handle RFC 9553 PartialDate objects: { year?, month?, day?, calendarScale? }
|
||||||
|
// Handle RFC 9553 Timestamp objects: { "@type": "Timestamp", utc: "..." }
|
||||||
if (typeof dateInput === 'object' && dateInput !== null) {
|
if (typeof dateInput === 'object' && dateInput !== null) {
|
||||||
const year = dateInput.year as number | undefined;
|
if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') {
|
||||||
const month = dateInput.month as number | undefined;
|
try {
|
||||||
const day = dateInput.day as number | undefined;
|
const d = new Date(dateInput.utc as string);
|
||||||
|
if (!isNaN(d.getTime())) {
|
||||||
|
return d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
|
||||||
|
}
|
||||||
|
} catch { /* fallback */ }
|
||||||
|
return String(dateInput.utc);
|
||||||
|
}
|
||||||
|
const pd = dateInput as PartialDate;
|
||||||
|
const year = pd.year;
|
||||||
|
const month = pd.month;
|
||||||
|
const day = pd.day;
|
||||||
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]);
|
if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]);
|
||||||
@@ -282,9 +293,11 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
{addresses.map((a, i) => (
|
{addresses.map((a, i) => (
|
||||||
<div key={i} className="text-sm space-y-0.5 rounded-md border border-border/60 bg-muted/30 p-3">
|
<div key={i} className="text-sm space-y-0.5 rounded-md border border-border/60 bg-muted/30 p-3">
|
||||||
<div>
|
<div>
|
||||||
{a.fullAddress
|
{a.full || a.fullAddress
|
||||||
? a.fullAddress
|
? (a.full || a.fullAddress)
|
||||||
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
|
: a.components && a.components.length > 0
|
||||||
|
? a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ")
|
||||||
|
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
|
||||||
{a.contexts && <ContextBadge contexts={a.contexts} />}
|
{a.contexts && <ContextBadge contexts={a.contexts} />}
|
||||||
</div>
|
</div>
|
||||||
{a.timeZone && (
|
{a.timeZone && (
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book } from "lucide-react";
|
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book } from "lucide-react";
|
||||||
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 { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook } from "@/lib/jmap/types";
|
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress } from "@/lib/jmap/types";
|
||||||
|
|
||||||
interface EmailEntry {
|
interface EmailEntry {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -48,6 +48,7 @@ interface AddressEntry {
|
|||||||
interface ContactFormProps {
|
interface ContactFormProps {
|
||||||
contact?: ContactCard | null;
|
contact?: ContactCard | null;
|
||||||
addressBooks?: AddressBook[];
|
addressBooks?: AddressBook[];
|
||||||
|
allKeywords?: string[];
|
||||||
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
@@ -123,12 +124,73 @@ function Select({ value, onChange, children, className }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactForm({ contact, addressBooks, onSave, onCancel }: ContactFormProps) {
|
export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCancel }: ContactFormProps) {
|
||||||
const t = useTranslations("contacts.form");
|
const t = useTranslations("contacts.form");
|
||||||
const isEditing = !!contact;
|
const isEditing = !!contact;
|
||||||
|
|
||||||
const findComponent = (kind: string) => contact?.name?.components?.find(c => c.kind === kind)?.value || "";
|
const findComponent = (kind: string) => contact?.name?.components?.find(c => c.kind === kind)?.value || "";
|
||||||
|
|
||||||
|
// Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input
|
||||||
|
function anniversaryDateToString(date: AnniversaryDate): string {
|
||||||
|
if (typeof date === 'string') return date;
|
||||||
|
if (date && typeof date === 'object') {
|
||||||
|
if ('@type' in date && date['@type'] === 'Timestamp' && 'utc' in date) {
|
||||||
|
return (date as { utc: string }).utc.split('T')[0];
|
||||||
|
}
|
||||||
|
const pd = date as PartialDate;
|
||||||
|
if (pd.year && pd.month && pd.day) {
|
||||||
|
return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
if (pd.month && pd.day) {
|
||||||
|
return `--${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
if (pd.year && pd.month) {
|
||||||
|
return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
if (pd.year) return String(pd.year);
|
||||||
|
}
|
||||||
|
return String(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert ISO date string back to RFC 9553 PartialDate for the server
|
||||||
|
function stringToPartialDate(str: string): PartialDate {
|
||||||
|
if (str.startsWith('--')) {
|
||||||
|
const parts = str.substring(2).split('-');
|
||||||
|
const pd: PartialDate = { month: parseInt(parts[0], 10) };
|
||||||
|
if (parts[1]) pd.day = parseInt(parts[1], 10);
|
||||||
|
return pd;
|
||||||
|
}
|
||||||
|
const parts = str.split('-');
|
||||||
|
const pd: PartialDate = {};
|
||||||
|
if (parts[0]) pd.year = parseInt(parts[0], 10);
|
||||||
|
if (parts[1]) pd.month = parseInt(parts[1], 10);
|
||||||
|
if (parts[2]) pd.day = parseInt(parts[2], 10);
|
||||||
|
return pd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract flat address fields from RFC 9553 components format
|
||||||
|
function addressToFlat(a: ContactAddress): AddressEntry {
|
||||||
|
if (a.components && a.components.length > 0) {
|
||||||
|
const findComp = (kind: string) => a.components!.filter(c => c.kind === kind).map(c => c.value).join(' ');
|
||||||
|
return {
|
||||||
|
street: findComp('name') || findComp('number') ? [findComp('number'), findComp('name')].filter(Boolean).join(' ') : '',
|
||||||
|
locality: findComp('locality'),
|
||||||
|
region: findComp('region'),
|
||||||
|
postcode: findComp('postcode'),
|
||||||
|
country: findComp('country'),
|
||||||
|
context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
street: a.street || '',
|
||||||
|
locality: a.locality || '',
|
||||||
|
region: a.region || '',
|
||||||
|
postcode: a.postcode || '',
|
||||||
|
country: a.country || '',
|
||||||
|
context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const [prefix, setPrefix] = useState(findComponent("prefix"));
|
const [prefix, setPrefix] = useState(findComponent("prefix"));
|
||||||
const [givenName, setGivenName] = useState(findComponent("given"));
|
const [givenName, setGivenName] = useState(findComponent("given"));
|
||||||
const [additionalName, setAdditionalName] = useState(findComponent("additional"));
|
const [additionalName, setAdditionalName] = useState(findComponent("additional"));
|
||||||
@@ -184,14 +246,7 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
|||||||
|
|
||||||
const [addresses, setAddresses] = useState<AddressEntry[]>(() => {
|
const [addresses, setAddresses] = useState<AddressEntry[]>(() => {
|
||||||
if (contact?.addresses) {
|
if (contact?.addresses) {
|
||||||
return Object.values(contact.addresses).map(a => ({
|
return Object.values(contact.addresses).map(a => addressToFlat(a));
|
||||||
street: a.street || "",
|
|
||||||
locality: a.locality || "",
|
|
||||||
region: a.region || "",
|
|
||||||
postcode: a.postcode || "",
|
|
||||||
country: a.country || "",
|
|
||||||
context: a.contexts?.work ? "work" : a.contexts?.private ? "private" : "",
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
@@ -210,7 +265,7 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
|||||||
const [anniversaries, setAnniversaries] = useState<AnniversaryEntry[]>(() => {
|
const [anniversaries, setAnniversaries] = useState<AnniversaryEntry[]>(() => {
|
||||||
if (contact?.anniversaries) {
|
if (contact?.anniversaries) {
|
||||||
return Object.values(contact.anniversaries).map(a => ({
|
return Object.values(contact.anniversaries).map(a => ({
|
||||||
date: a.date,
|
date: anniversaryDateToString(a.date),
|
||||||
kind: a.kind,
|
kind: a.kind,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -336,12 +391,13 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
|||||||
|
|
||||||
const addressesMap: Record<string, ContactCard["addresses"] extends Record<string, infer V> ? V : never> = {};
|
const addressesMap: Record<string, ContactCard["addresses"] extends Record<string, infer V> ? V : never> = {};
|
||||||
addresses.filter(a => a.street.trim() || a.locality.trim() || a.country.trim()).forEach((a, i) => {
|
addresses.filter(a => a.street.trim() || a.locality.trim() || a.country.trim()).forEach((a, i) => {
|
||||||
const obj: Record<string, unknown> = {};
|
const components: Array<{ kind: string; value: string }> = [];
|
||||||
if (a.street.trim()) obj.street = a.street.trim();
|
if (a.street.trim()) components.push({ kind: "name", value: a.street.trim() });
|
||||||
if (a.locality.trim()) obj.locality = a.locality.trim();
|
if (a.locality.trim()) components.push({ kind: "locality", value: a.locality.trim() });
|
||||||
if (a.region.trim()) obj.region = a.region.trim();
|
if (a.region.trim()) components.push({ kind: "region", value: a.region.trim() });
|
||||||
if (a.postcode.trim()) obj.postcode = a.postcode.trim();
|
if (a.postcode.trim()) components.push({ kind: "postcode", value: a.postcode.trim() });
|
||||||
if (a.country.trim()) obj.country = a.country.trim();
|
if (a.country.trim()) components.push({ kind: "country", value: a.country.trim() });
|
||||||
|
const obj: Record<string, unknown> = { components, isOrdered: true, defaultSeparator: ", " };
|
||||||
if (a.context) obj.contexts = { [a.context]: true };
|
if (a.context) obj.contexts = { [a.context]: true };
|
||||||
// @ts-expect-error - dynamic build
|
// @ts-expect-error - dynamic build
|
||||||
addressesMap[`a${i}`] = obj;
|
addressesMap[`a${i}`] = obj;
|
||||||
@@ -357,7 +413,7 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
|||||||
|
|
||||||
const anniversariesMap: Record<string, ContactAnniversary> = {};
|
const anniversariesMap: Record<string, ContactAnniversary> = {};
|
||||||
anniversaries.filter(a => a.date.trim()).forEach((a, i) => {
|
anniversaries.filter(a => a.date.trim()).forEach((a, i) => {
|
||||||
anniversariesMap[`an${i}`] = { date: a.date.trim(), kind: a.kind };
|
anniversariesMap[`an${i}`] = { date: stringToPartialDate(a.date.trim()), kind: a.kind };
|
||||||
});
|
});
|
||||||
|
|
||||||
const personalInfoMap: Record<string, ContactPersonalInfo> = {};
|
const personalInfoMap: Record<string, ContactPersonalInfo> = {};
|
||||||
@@ -764,14 +820,14 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
|||||||
|
|
||||||
{/* Categories */}
|
{/* Categories */}
|
||||||
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital">
|
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital">
|
||||||
<div>
|
<CategoryComboBox
|
||||||
<Input
|
keywordsStr={keywordsStr}
|
||||||
value={keywordsStr}
|
onChange={setKeywordsStr}
|
||||||
onChange={(e) => setKeywordsStr(e.target.value)}
|
allKeywords={allKeywords || []}
|
||||||
placeholder={t("categories_placeholder")}
|
placeholder={t("categories_placeholder")}
|
||||||
/>
|
hint={t("categories_hint")}
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">{t("categories_hint")}</p>
|
addLabel={t("category_add")}
|
||||||
</div>
|
/>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Gender */}
|
{/* Gender */}
|
||||||
@@ -840,3 +896,142 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
|||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CategoryComboBox({
|
||||||
|
keywordsStr,
|
||||||
|
onChange,
|
||||||
|
allKeywords,
|
||||||
|
placeholder,
|
||||||
|
hint,
|
||||||
|
addLabel,
|
||||||
|
}: {
|
||||||
|
keywordsStr: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
allKeywords: string[];
|
||||||
|
placeholder: string;
|
||||||
|
hint: string;
|
||||||
|
addLabel: string;
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Parse current keywords from comma-separated string
|
||||||
|
const currentKeywords = useMemo(() => {
|
||||||
|
return keywordsStr.split(",").map(k => k.trim()).filter(Boolean);
|
||||||
|
}, [keywordsStr]);
|
||||||
|
|
||||||
|
// Suggestions: existing keywords not already selected
|
||||||
|
const suggestions = useMemo(() => {
|
||||||
|
const lower = inputValue.toLowerCase();
|
||||||
|
return allKeywords.filter(kw =>
|
||||||
|
!currentKeywords.includes(kw) &&
|
||||||
|
(!lower || kw.toLowerCase().includes(lower))
|
||||||
|
);
|
||||||
|
}, [allKeywords, currentKeywords, inputValue]);
|
||||||
|
|
||||||
|
// Can add a new keyword if typed text is non-empty and not already in the list
|
||||||
|
const canAddNew = inputValue.trim() &&
|
||||||
|
!currentKeywords.includes(inputValue.trim()) &&
|
||||||
|
!allKeywords.some(kw => kw.toLowerCase() === inputValue.trim().toLowerCase());
|
||||||
|
|
||||||
|
const addKeyword = useCallback((keyword: string) => {
|
||||||
|
const trimmed = keyword.trim();
|
||||||
|
if (!trimmed || currentKeywords.includes(trimmed)) return;
|
||||||
|
const next = [...currentKeywords, trimmed].join(", ");
|
||||||
|
onChange(next);
|
||||||
|
setInputValue("");
|
||||||
|
}, [currentKeywords, onChange]);
|
||||||
|
|
||||||
|
const removeKeyword = useCallback((keyword: string) => {
|
||||||
|
const next = currentKeywords.filter(k => k !== keyword).join(", ");
|
||||||
|
onChange(next);
|
||||||
|
}, [currentKeywords, onChange]);
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (inputValue.trim()) {
|
||||||
|
addKeyword(inputValue);
|
||||||
|
}
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef} className="relative">
|
||||||
|
{/* Keyword badges */}
|
||||||
|
{currentKeywords.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||||
|
{currentKeywords.map(kw => (
|
||||||
|
<span
|
||||||
|
key={kw}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary border border-primary/20"
|
||||||
|
>
|
||||||
|
{kw}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeKeyword(kw)}
|
||||||
|
className="hover:text-destructive transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input with dropdown */}
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }}
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={currentKeywords.length === 0 ? placeholder : ""}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5">{hint}</p>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{isOpen && (suggestions.length > 0 || canAddNew) && (
|
||||||
|
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1">
|
||||||
|
{suggestions.map(kw => (
|
||||||
|
<button
|
||||||
|
key={kw}
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||||
|
onClick={() => { addKeyword(kw); inputRef.current?.focus(); }}
|
||||||
|
>
|
||||||
|
<Tag className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
{kw}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{canAddNew && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left text-primary"
|
||||||
|
onClick={() => { addKeyword(inputValue); inputRef.current?.focus(); }}
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
|
{addLabel}: "{inputValue.trim()}"
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
|||||||
? Array.from(selectedContactIds)
|
? Array.from(selectedContactIds)
|
||||||
: [contact.id];
|
: [contact.id];
|
||||||
|
|
||||||
e.dataTransfer.effectAllowed = "move";
|
e.dataTransfer.effectAllowed = "copyMove";
|
||||||
e.dataTransfer.setData("application/x-contact-ids", JSON.stringify(ids));
|
e.dataTransfer.setData("application/x-contact-ids", JSON.stringify(ids));
|
||||||
e.dataTransfer.setData("text/plain", name || email || contact.id);
|
e.dataTransfer.setData("text/plain", name || email || contact.id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, useCallback, 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, UserPlus, Share2, Book } from "lucide-react";
|
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||||
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||||
import { getContactDisplayName } from "@/stores/contact-store";
|
import { getContactDisplayName } from "@/stores/contact-store";
|
||||||
|
|
||||||
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string };
|
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized";
|
||||||
|
|
||||||
interface ContactsSidebarProps {
|
interface ContactsSidebarProps {
|
||||||
groups: ContactCard[];
|
groups: ContactCard[];
|
||||||
@@ -18,10 +20,31 @@ interface ContactsSidebarProps {
|
|||||||
onSelectCategory: (category: ContactCategory) => void;
|
onSelectCategory: (category: ContactCategory) => void;
|
||||||
onCreateGroup: () => void;
|
onCreateGroup: () => void;
|
||||||
onCreateContact: () => void;
|
onCreateContact: () => void;
|
||||||
|
onImport?: () => void;
|
||||||
|
onEditGroup?: (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;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COLLAPSED_KEY = "contacts-sidebar-collapsed";
|
||||||
|
|
||||||
|
function loadCollapsed(): Record<string, boolean> {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(COLLAPSED_KEY);
|
||||||
|
return v ? JSON.parse(v) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCollapsed(state: Record<string, boolean>) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(COLLAPSED_KEY, JSON.stringify(state));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
export function ContactsSidebar({
|
export function ContactsSidebar({
|
||||||
groups,
|
groups,
|
||||||
individuals,
|
individuals,
|
||||||
@@ -30,10 +53,43 @@ export function ContactsSidebar({
|
|||||||
onSelectCategory,
|
onSelectCategory,
|
||||||
onCreateGroup,
|
onCreateGroup,
|
||||||
onCreateContact,
|
onCreateContact,
|
||||||
|
onImport,
|
||||||
|
onEditGroup,
|
||||||
|
onDeleteGroup,
|
||||||
onDropContacts,
|
onDropContacts,
|
||||||
|
onDropContactsToCategory,
|
||||||
className,
|
className,
|
||||||
}: ContactsSidebarProps) {
|
}: ContactsSidebarProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
|
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
|
||||||
|
|
||||||
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
|
||||||
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const menuBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const toggleSection = useCallback((key: string) => {
|
||||||
|
setCollapsed(prev => {
|
||||||
|
const next = { ...prev, [key]: !prev[key] };
|
||||||
|
saveCollapsed(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showMenu) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
menuRef.current && !menuRef.current.contains(e.target as Node) &&
|
||||||
|
menuBtnRef.current && !menuBtnRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setShowMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [showMenu]);
|
||||||
|
|
||||||
const sortedGroups = useMemo(() => {
|
const sortedGroups = useMemo(() => {
|
||||||
return [...groups].sort((a, b) =>
|
return [...groups].sort((a, b) =>
|
||||||
@@ -73,25 +129,101 @@ export function ContactsSidebar({
|
|||||||
if (!contact.addressBookIds) continue;
|
if (!contact.addressBookIds) continue;
|
||||||
for (const bookId of Object.keys(contact.addressBookIds)) {
|
for (const bookId of Object.keys(contact.addressBookIds)) {
|
||||||
if (!contact.addressBookIds[bookId]) continue;
|
if (!contact.addressBookIds[bookId]) continue;
|
||||||
// Build the full namespaced key
|
counts[bookId] = (counts[bookId] || 0) + 1;
|
||||||
const key = contact.isShared && contact.accountId ? `${contact.accountId}:${bookId}` : bookId;
|
|
||||||
counts[key] = (counts[key] || 0) + 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}, [individuals]);
|
}, [individuals]);
|
||||||
|
|
||||||
|
// Auto-collect keywords from all contacts
|
||||||
|
const allKeywords = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const contact of individuals) {
|
||||||
|
if (!contact.keywords) continue;
|
||||||
|
for (const [kw, active] of Object.entries(contact.keywords)) {
|
||||||
|
if (!active) continue;
|
||||||
|
counts[kw] = (counts[kw] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.entries(counts).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
}, [individuals]);
|
||||||
|
|
||||||
|
// Count of contacts without any keywords
|
||||||
|
const uncategorizedCount = useMemo(() => {
|
||||||
|
return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0).length;
|
||||||
|
}, [individuals]);
|
||||||
|
|
||||||
|
// Resolve actual group member counts against living contacts
|
||||||
|
const memberCountByGroup = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const group of groups) {
|
||||||
|
if (!group.members) {
|
||||||
|
counts[group.id] = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const memberKeys = Object.keys(group.members).filter(k => group.members![k]);
|
||||||
|
const normalizedKeys = memberKeys.map(k => k.startsWith('urn:uuid:') ? k.slice(9) : k);
|
||||||
|
counts[group.id] = individuals.filter(c => {
|
||||||
|
if (memberKeys.includes(c.id) || normalizedKeys.includes(c.id)) return true;
|
||||||
|
if (c.uid) {
|
||||||
|
const bareUid = c.uid.startsWith('urn:uuid:') ? c.uid.slice(9) : c.uid;
|
||||||
|
return memberKeys.includes(c.uid) || normalizedKeys.includes(bareUid);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [groups, individuals]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col h-full bg-secondary", className)}>
|
<div className={cn("flex flex-col h-full bg-secondary", className)}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-3 border-b border-border flex items-center justify-between" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
<div className="px-3 border-b border-border flex items-center justify-between" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||||
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
||||||
<Button size="icon" variant="ghost" onClick={onCreateContact} className="h-7 w-7 flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
<UserPlus className="w-4 h-4" />
|
<Button
|
||||||
</Button>
|
ref={menuBtnRef}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowMenu(v => !v)}
|
||||||
|
className="h-7 w-7"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{showMenu && (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-background text-foreground shadow-md z-50 py-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||||
|
onClick={() => { setShowMenu(false); onCreateContact(); }}
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4" />
|
||||||
|
{t("create_new")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||||
|
onClick={() => { setShowMenu(false); onCreateGroup(); }}
|
||||||
|
>
|
||||||
|
<UsersRound className="w-4 h-4" />
|
||||||
|
{t("groups.create")}
|
||||||
|
</button>
|
||||||
|
{onImport && (
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||||
|
onClick={() => { setShowMenu(false); onImport(); }}
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
{t("import.title")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Categories */}
|
{/* Navigation */}
|
||||||
<div className="flex-1 overflow-y-auto py-1">
|
<div className="flex-1 overflow-y-auto py-1">
|
||||||
{/* All contacts */}
|
{/* All contacts */}
|
||||||
<button
|
<button
|
||||||
@@ -107,19 +239,27 @@ export function ContactsSidebar({
|
|||||||
<BookUser className="w-4 h-4 flex-shrink-0" />
|
<BookUser className="w-4 h-4 flex-shrink-0" />
|
||||||
<span className="truncate">{t("tabs.all")}</span>
|
<span className="truncate">{t("tabs.all")}</span>
|
||||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||||
{individuals.filter(c => !c.isShared).length}
|
{individuals.length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Personal address books */}
|
{/* My Address Books */}
|
||||||
{personalBooks.length > 0 && (
|
{personalBooks.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="flex items-center justify-between px-3 py-1">
|
<button
|
||||||
|
onClick={() => toggleSection("addressBooks")}
|
||||||
|
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||||
|
>
|
||||||
|
{collapsed.addressBooks ? (
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
{t("address_books.title")}
|
{t("address_books.title")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
{personalBooks.map((book) => (
|
{!collapsed.addressBooks && personalBooks.map((book) => (
|
||||||
<AddressBookItem
|
<AddressBookItem
|
||||||
key={book.id}
|
key={book.id}
|
||||||
book={book}
|
book={book}
|
||||||
@@ -133,29 +273,33 @@ export function ContactsSidebar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Groups section */}
|
{/* Groups section */}
|
||||||
{(sortedGroups.length > 0) && (
|
{sortedGroups.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="flex items-center justify-between px-3 py-1">
|
<button
|
||||||
|
onClick={() => toggleSection("groups")}
|
||||||
|
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||||
|
>
|
||||||
|
{collapsed.groups ? (
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
{t("tabs.groups")}
|
{t("tabs.groups")}
|
||||||
</span>
|
</span>
|
||||||
<Button size="icon" variant="ghost" onClick={onCreateGroup} className="h-5 w-5">
|
</button>
|
||||||
<Plus className="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sortedGroups.map((group) => {
|
{!collapsed.groups && sortedGroups.map((group) => {
|
||||||
const isActive = typeof activeCategory === "object" && "groupId" in activeCategory && activeCategory.groupId === group.id;
|
const isActive = typeof activeCategory === "object" && "groupId" in activeCategory && activeCategory.groupId === group.id;
|
||||||
const memberCount = group.members
|
const memberCount = memberCountByGroup[group.id] || 0;
|
||||||
? Object.values(group.members).filter(Boolean).length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={group.id}
|
key={group.id}
|
||||||
onClick={() => onSelectCategory({ groupId: group.id })}
|
onClick={() => onSelectCategory({ groupId: group.id })}
|
||||||
|
onContextMenu={(e) => openGroupContextMenu(e, group)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 px-3 text-sm transition-colors",
|
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "bg-accent text-accent-foreground font-medium"
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
: "text-foreground/80 hover:bg-muted"
|
: "text-foreground/80 hover:bg-muted"
|
||||||
@@ -173,35 +317,76 @@ export function ContactsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sortedGroups.length === 0 && (
|
{/* Categories section (from contact keywords) */}
|
||||||
<div className="mt-2 px-3">
|
<div className="mt-2">
|
||||||
<div className="flex items-center justify-between py-1">
|
<button
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
onClick={() => toggleSection("categories")}
|
||||||
{t("tabs.groups")}
|
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||||
</span>
|
>
|
||||||
</div>
|
{collapsed.categories ? (
|
||||||
<Button
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
size="sm"
|
) : (
|
||||||
variant="ghost"
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
onClick={onCreateGroup}
|
)}
|
||||||
className="w-full justify-start text-xs text-muted-foreground h-7"
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
>
|
{t("detail.categories")}
|
||||||
<Plus className="w-3 h-3 mr-1.5" />
|
</span>
|
||||||
{t("groups.create")}
|
</button>
|
||||||
</Button>
|
|
||||||
</div>
|
{!collapsed.categories && (
|
||||||
)}
|
<>
|
||||||
|
{/* No Category item */}
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectCategory("uncategorized")}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||||
|
activeCategory === "uncategorized"
|
||||||
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
|
: "text-foreground/80 hover:bg-muted"
|
||||||
|
)}
|
||||||
|
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
|
||||||
|
>
|
||||||
|
<Tag className="w-3.5 h-3.5 flex-shrink-0 opacity-50" />
|
||||||
|
<span className="truncate italic">{t("no_category")}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||||
|
{uncategorizedCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{allKeywords.map(([keyword, count]) => {
|
||||||
|
const isActive = typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword;
|
||||||
|
return (
|
||||||
|
<CategoryItem
|
||||||
|
key={keyword}
|
||||||
|
keyword={keyword}
|
||||||
|
count={count}
|
||||||
|
isActive={isActive}
|
||||||
|
onSelect={() => onSelectCategory({ keyword })}
|
||||||
|
onDropContacts={onDropContactsToCategory}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 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">
|
||||||
<div className="flex items-center justify-between px-3 py-1">
|
<button
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1">
|
onClick={() => toggleSection(`shared-${group.accountId}`)}
|
||||||
<Share2 className="w-3 h-3" />
|
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||||
{group.accountName}
|
>
|
||||||
|
{collapsed[`shared-${group.accountId}`] ? (
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<Share2 className="w-3 h-3 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
|
||||||
|
{t("address_books.shared_prefix", { name: group.accountName })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
{group.books.map((book) => (
|
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
|
||||||
<AddressBookItem
|
<AddressBookItem
|
||||||
key={book.id}
|
key={book.id}
|
||||||
book={book}
|
book={book}
|
||||||
@@ -214,10 +399,104 @@ export function ContactsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Group context menu */}
|
||||||
|
{groupContextMenu.data && (
|
||||||
|
<ContextMenu
|
||||||
|
ref={groupMenuRef}
|
||||||
|
isOpen={groupContextMenu.isOpen}
|
||||||
|
position={groupContextMenu.position}
|
||||||
|
onClose={closeGroupContextMenu}
|
||||||
|
>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Pencil}
|
||||||
|
label={t("groups.edit")}
|
||||||
|
onClick={() => {
|
||||||
|
closeGroupContextMenu();
|
||||||
|
onEditGroup?.(groupContextMenu.data!.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={t("form.delete")}
|
||||||
|
onClick={() => {
|
||||||
|
closeGroupContextMenu();
|
||||||
|
onDeleteGroup?.(groupContextMenu.data!.id);
|
||||||
|
}}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CategoryItem({
|
||||||
|
keyword,
|
||||||
|
count,
|
||||||
|
isActive,
|
||||||
|
onSelect,
|
||||||
|
onDropContacts,
|
||||||
|
}: {
|
||||||
|
keyword: string;
|
||||||
|
count: number;
|
||||||
|
isActive: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onDropContacts?: (contactIds: string[], keyword: string) => void;
|
||||||
|
}) {
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: DragEvent<HTMLButtonElement>) => {
|
||||||
|
if (!e.dataTransfer.types.includes("application/x-contact-ids")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "copy";
|
||||||
|
setIsDragOver(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback(() => {
|
||||||
|
setIsDragOver(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: DragEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
const data = e.dataTransfer.getData("application/x-contact-ids");
|
||||||
|
if (!data || !onDropContacts) return;
|
||||||
|
try {
|
||||||
|
const contactIds = JSON.parse(data) as string[];
|
||||||
|
if (contactIds.length > 0) {
|
||||||
|
onDropContacts(contactIds, keyword);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore invalid data
|
||||||
|
}
|
||||||
|
}, [keyword, onDropContacts]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
|
: "text-foreground/80 hover:bg-muted",
|
||||||
|
isDragOver && "bg-primary/20 ring-2 ring-primary/50"
|
||||||
|
)}
|
||||||
|
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
|
||||||
|
>
|
||||||
|
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
|
<span className="truncate">{keyword}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AddressBookItem({
|
function AddressBookItem({
|
||||||
book,
|
book,
|
||||||
isActive,
|
isActive,
|
||||||
@@ -266,7 +545,7 @@ function AddressBookItem({
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 px-3 text-sm transition-colors",
|
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "bg-accent text-accent-foreground font-medium"
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
: "text-foreground/80 hover:bg-muted",
|
: "text-foreground/80 hover:bg-muted",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { substitutePlaceholders } from "@/lib/template-utils";
|
|||||||
import { TemplatePicker } from "@/components/templates/template-picker";
|
import { TemplatePicker } from "@/components/templates/template-picker";
|
||||||
import { TemplateForm } from "@/components/templates/template-form";
|
import { TemplateForm } from "@/components/templates/template-form";
|
||||||
import type { EmailTemplate } from "@/lib/template-types";
|
import type { EmailTemplate } from "@/lib/template-types";
|
||||||
|
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||||
|
|
||||||
export interface ComposerDraftData {
|
export interface ComposerDraftData {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -199,6 +200,14 @@ export function EmailComposer({
|
|||||||
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;
|
||||||
|
const currentIdentity = selectedIdentityId
|
||||||
|
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
|
||||||
|
: primaryIdentity;
|
||||||
|
const composerSignatureHtml = currentIdentity?.htmlSignature
|
||||||
|
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
|
||||||
|
: currentIdentity?.textSignature
|
||||||
|
? `<div>${getPlainTextSignature(currentIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||||
|
: '';
|
||||||
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
||||||
const addTemplate = useTemplateStore((s) => s.addTemplate);
|
const addTemplate = useTemplateStore((s) => s.addTemplate);
|
||||||
const sendRawEmail = useEmailStore((s) => s.sendRawEmail);
|
const sendRawEmail = useEmailStore((s) => s.sendRawEmail);
|
||||||
@@ -527,10 +536,6 @@ export function EmailComposer({
|
|||||||
setSaveStatus('saving');
|
setSaveStatus('saving');
|
||||||
|
|
||||||
// Get the selected identity or primary identity
|
// Get the selected identity or primary identity
|
||||||
const currentIdentity = selectedIdentityId
|
|
||||||
? identities.find(id => id.id === selectedIdentityId)
|
|
||||||
: primaryIdentity;
|
|
||||||
|
|
||||||
// Generate sub-addressed email if tag is set
|
// Generate sub-addressed email if tag is set
|
||||||
const fromEmail = currentIdentity?.email
|
const fromEmail = currentIdentity?.email
|
||||||
? subAddressTag
|
? subAddressTag
|
||||||
@@ -649,10 +654,6 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentIdentity = selectedIdentityId
|
|
||||||
? identities.find(id => id.id === selectedIdentityId)
|
|
||||||
: primaryIdentity;
|
|
||||||
|
|
||||||
const fromEmail = currentIdentity?.email
|
const fromEmail = currentIdentity?.email
|
||||||
? subAddressTag
|
? subAddressTag
|
||||||
? generateSubAddress(currentIdentity.email, subAddressTag)
|
? generateSubAddress(currentIdentity.email, subAddressTag)
|
||||||
@@ -660,10 +661,7 @@ export function EmailComposer({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Append signature from the selected identity
|
// Append signature from the selected identity
|
||||||
let finalBody = body;
|
let finalBody = appendPlainTextSignature(body, currentIdentity);
|
||||||
if (currentIdentity?.textSignature) {
|
|
||||||
finalBody = body + '\n\n-- \n' + currentIdentity.textSignature;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append quoted original text for the plain text part in reply/forward
|
// Append quoted original text for the plain text part in reply/forward
|
||||||
if (replyTo && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
if (replyTo && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||||
@@ -888,6 +886,7 @@ export function EmailComposer({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("flex flex-col h-full bg-background relative", className)}
|
className={cn("flex flex-col h-full bg-background relative", className)}
|
||||||
|
data-tour="composer"
|
||||||
onDragEnter={handleDragEnter}
|
onDragEnter={handleDragEnter}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
@@ -1126,6 +1125,13 @@ export function EmailComposer({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{composerSignatureHtml && (
|
||||||
|
<div
|
||||||
|
className="px-4 pb-3 text-sm leading-6 text-foreground break-words [&_a]:text-primary [&_a]:underline-offset-2 [&_a:hover]:underline"
|
||||||
|
dangerouslySetInnerHTML={{ __html: `<div>-- </div>${composerSignatureHtml}` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Quoted original HTML */}
|
{/* Quoted original HTML */}
|
||||||
{replyTo?.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward') && (
|
{replyTo?.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward') && (
|
||||||
<div className="border-t border-border">
|
<div className="border-t border-border">
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Email } from "@/lib/jmap/types";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import type { HoverAction } from "@/stores/settings-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
interface EmailHoverActionsProps {
|
||||||
|
email: Email;
|
||||||
|
onToggleStar?: () => void;
|
||||||
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
onArchive?: () => void;
|
||||||
|
onSetColorTag?: (color: string | null) => void;
|
||||||
|
onMarkAsSpam?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_CONFIG: Record<HoverAction, {
|
||||||
|
icon: typeof Trash2;
|
||||||
|
titleKey: string;
|
||||||
|
className?: string;
|
||||||
|
}> = {
|
||||||
|
delete: {
|
||||||
|
icon: Trash2,
|
||||||
|
titleKey: "delete",
|
||||||
|
className: "hover:text-red-600 dark:hover:text-red-400",
|
||||||
|
},
|
||||||
|
star: {
|
||||||
|
icon: Star,
|
||||||
|
titleKey: "star",
|
||||||
|
className: "hover:text-amber-500 dark:hover:text-amber-400",
|
||||||
|
},
|
||||||
|
markRead: {
|
||||||
|
icon: Mail,
|
||||||
|
titleKey: "mark_read",
|
||||||
|
className: "hover:text-blue-600 dark:hover:text-blue-400",
|
||||||
|
},
|
||||||
|
archive: {
|
||||||
|
icon: Archive,
|
||||||
|
titleKey: "archive",
|
||||||
|
className: "hover:text-green-600 dark:hover:text-green-400",
|
||||||
|
},
|
||||||
|
tag: {
|
||||||
|
icon: Tag,
|
||||||
|
titleKey: "tag",
|
||||||
|
className: "hover:text-purple-600 dark:hover:text-purple-400",
|
||||||
|
},
|
||||||
|
spam: {
|
||||||
|
icon: ShieldAlert,
|
||||||
|
titleKey: "spam",
|
||||||
|
className: "hover:text-orange-600 dark:hover:text-orange-400",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmailHoverActions({
|
||||||
|
email,
|
||||||
|
onToggleStar,
|
||||||
|
onMarkAsRead,
|
||||||
|
onDelete,
|
||||||
|
onArchive,
|
||||||
|
onSetColorTag,
|
||||||
|
onMarkAsSpam,
|
||||||
|
}: EmailHoverActionsProps) {
|
||||||
|
const hoverActions = useSettingsStore((state) => state.hoverActions);
|
||||||
|
const t = useTranslations("settings.email_behavior.hover_actions");
|
||||||
|
|
||||||
|
const isUnread = !email.keywords?.$seen;
|
||||||
|
const isStarred = email.keywords?.$flagged;
|
||||||
|
|
||||||
|
if (hoverActions.length === 0) return null;
|
||||||
|
|
||||||
|
const handleAction = (e: React.MouseEvent, action: HoverAction) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
switch (action) {
|
||||||
|
case "delete":
|
||||||
|
onDelete?.();
|
||||||
|
break;
|
||||||
|
case "star":
|
||||||
|
onToggleStar?.();
|
||||||
|
break;
|
||||||
|
case "markRead":
|
||||||
|
onMarkAsRead?.(!isUnread);
|
||||||
|
break;
|
||||||
|
case "archive":
|
||||||
|
onArchive?.();
|
||||||
|
break;
|
||||||
|
case "tag":
|
||||||
|
onSetColorTag?.(null);
|
||||||
|
break;
|
||||||
|
case "spam":
|
||||||
|
onMarkAsSpam?.();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-full bg-gradient-to-r from-transparent to-muted" />
|
||||||
|
<div className="flex items-center gap-0.5 h-full bg-muted pr-3 pl-0.5">
|
||||||
|
{hoverActions.map((actionId) => {
|
||||||
|
const config = ACTION_CONFIG[actionId];
|
||||||
|
if (!config) return null;
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
const DisplayIcon = actionId === "markRead"
|
||||||
|
? (isUnread ? MailOpen : Mail)
|
||||||
|
: actionId === "star" && isStarred
|
||||||
|
? Star
|
||||||
|
: Icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={actionId}
|
||||||
|
onClick={(e) => handleAction(e, actionId)}
|
||||||
|
title={t(config.titleKey)}
|
||||||
|
className={cn(
|
||||||
|
"p-1.5 rounded-md transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10",
|
||||||
|
config.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DisplayIcon
|
||||||
|
className={cn(
|
||||||
|
"w-4 h-4",
|
||||||
|
actionId === "star" && isStarred && "fill-amber-400 text-amber-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { useEmailDrag } from "@/hooks/use-email-drag";
|
|||||||
import { useLongPress } from "@/hooks/use-long-press";
|
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 { getEmailColorTag } from "@/lib/thread-utils";
|
import { getEmailColorTag } from "@/lib/thread-utils";
|
||||||
|
|
||||||
interface EmailListItemProps {
|
interface EmailListItemProps {
|
||||||
@@ -21,9 +22,15 @@ interface EmailListItemProps {
|
|||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||||
|
onToggleStar?: () => void;
|
||||||
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
onArchive?: () => void;
|
||||||
|
onSetColorTag?: (color: string | null) => void;
|
||||||
|
onMarkAsSpam?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmailListItem({ email, selected, onClick, onContextMenu }: 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, clearSelection } = useEmailStore();
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
@@ -74,7 +81,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
|
|||||||
{...dragHandlers}
|
{...dragHandlers}
|
||||||
{...longPressHandlers}
|
{...longPressHandlers}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative group cursor-pointer select-none transition-all duration-200 border-b border-border",
|
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||||
// Apply color tag as background, with selected and unread states
|
// Apply color tag as background, with selected and unread states
|
||||||
colorTag ? colorTag : (
|
colorTag ? colorTag : (
|
||||||
selected
|
selected
|
||||||
@@ -217,6 +224,17 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hover Quick Actions */}
|
||||||
|
<EmailHoverActions
|
||||||
|
email={email}
|
||||||
|
onToggleStar={onToggleStar}
|
||||||
|
onMarkAsRead={onMarkAsRead}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onArchive={onArchive}
|
||||||
|
onSetColorTag={onSetColorTag}
|
||||||
|
onMarkAsSpam={onMarkAsSpam}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -358,7 +358,7 @@ export function EmailList({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Email List */}
|
{/* Email List */}
|
||||||
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative">
|
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative" data-tour="email-list">
|
||||||
{/* Loading overlay */}
|
{/* Loading overlay */}
|
||||||
{isLoading && emails.length > 0 && (
|
{isLoading && emails.length > 0 && (
|
||||||
<div className="absolute inset-0 bg-background/50 z-10 flex items-center justify-center animate-in fade-in duration-150">
|
<div className="absolute inset-0 bg-background/50 z-10 flex items-center justify-center animate-in fade-in duration-150">
|
||||||
@@ -422,6 +422,12 @@ export function EmailList({
|
|||||||
onEmailSelect={(email) => onEmailSelect?.(email)}
|
onEmailSelect={(email) => onEmailSelect?.(email)}
|
||||||
onContextMenu={openContextMenu}
|
onContextMenu={openContextMenu}
|
||||||
onOpenConversation={onOpenConversation}
|
onOpenConversation={onOpenConversation}
|
||||||
|
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
|
||||||
|
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
|
||||||
|
onDelete={onDelete ? (email) => onDelete(email) : undefined}
|
||||||
|
onArchive={onArchive ? (email) => onArchive(email) : undefined}
|
||||||
|
onSetColorTag={onSetColorTag}
|
||||||
|
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+490
-140
@@ -5,6 +5,7 @@ import ReactDOM from "react-dom";
|
|||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
||||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
||||||
|
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 } from "@/lib/utils";
|
||||||
@@ -65,6 +66,7 @@ import {
|
|||||||
Moon,
|
Moon,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
|
PlayCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||||
@@ -79,6 +81,7 @@ import { useThemeStore } from "@/stores/theme-store";
|
|||||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||||
|
import { useTour } from "@/components/tour/tour-provider";
|
||||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||||
import { findCalendarAttachment } from "@/lib/calendar-invitation";
|
import { findCalendarAttachment } from "@/lib/calendar-invitation";
|
||||||
import { RecipientPopover } from "./recipient-popover";
|
import { RecipientPopover } from "./recipient-popover";
|
||||||
@@ -104,7 +107,7 @@ interface EmailViewerProps {
|
|||||||
onToggleStar?: () => void;
|
onToggleStar?: () => void;
|
||||||
onMarkAsRead?: (emailId: string, read: boolean) => void;
|
onMarkAsRead?: (emailId: string, read: boolean) => void;
|
||||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||||
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
|
onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean) => void;
|
||||||
onQuickReply?: (body: string) => Promise<void>;
|
onQuickReply?: (body: string) => Promise<void>;
|
||||||
onMarkAsSpam?: () => void;
|
onMarkAsSpam?: () => void;
|
||||||
onUndoSpam?: () => void;
|
onUndoSpam?: () => void;
|
||||||
@@ -148,6 +151,42 @@ const getFileIcon = (name?: string, type?: string) => {
|
|||||||
return File;
|
return File;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MIME_TYPE_LABELS: Record<string, string> = {
|
||||||
|
'application/pdf': 'Document.pdf',
|
||||||
|
'application/zip': 'Archive.zip',
|
||||||
|
'application/x-zip-compressed': 'Archive.zip',
|
||||||
|
'application/gzip': 'Archive.gz',
|
||||||
|
'application/x-rar-compressed': 'Archive.rar',
|
||||||
|
'application/x-7z-compressed': 'Archive.7z',
|
||||||
|
'application/msword': 'Document.doc',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Document.docx',
|
||||||
|
'application/vnd.ms-excel': 'Spreadsheet.xls',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Spreadsheet.xlsx',
|
||||||
|
'application/vnd.ms-powerpoint': 'Presentation.ppt',
|
||||||
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Presentation.pptx',
|
||||||
|
'text/plain': 'Text.txt',
|
||||||
|
'text/html': 'Document.html',
|
||||||
|
'text/csv': 'Data.csv',
|
||||||
|
'application/json': 'Data.json',
|
||||||
|
'application/xml': 'Data.xml',
|
||||||
|
'application/octet-stream': 'Attachment',
|
||||||
|
'message/rfc822': 'Email.eml',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: string): string => {
|
||||||
|
if (name) return name;
|
||||||
|
if (mimeType) {
|
||||||
|
const label = MIME_TYPE_LABELS[mimeType.toLowerCase()];
|
||||||
|
if (label) return label;
|
||||||
|
const sub = mimeType.split('/')[1];
|
||||||
|
if (sub) {
|
||||||
|
const clean = sub.replace(/^x-/, '').replace(/^vnd\./, '');
|
||||||
|
return `Attachment.${clean}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Attachment';
|
||||||
|
};
|
||||||
|
|
||||||
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
|
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
|
||||||
if (!keywords) return null;
|
if (!keywords) return null;
|
||||||
for (const key of Object.keys(keywords)) {
|
for (const key of Object.keys(keywords)) {
|
||||||
@@ -706,7 +745,11 @@ function ContactSidebarPanel({
|
|||||||
<SidebarSection icon={MapPin} title="Addresses">
|
<SidebarSection icon={MapPin} title="Addresses">
|
||||||
{addresses.map((a, i) => (
|
{addresses.map((a, i) => (
|
||||||
<div key={i} className="text-sm text-muted-foreground">
|
<div key={i} className="text-sm text-muted-foreground">
|
||||||
{[a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
|
{a.full || a.fullAddress
|
||||||
|
? (a.full || a.fullAddress)
|
||||||
|
: a.components && a.components.length > 0
|
||||||
|
? a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ")
|
||||||
|
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
@@ -830,8 +873,11 @@ export function EmailViewer({
|
|||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const tSmime = useTranslations('smime');
|
const tSmime = useTranslations('smime');
|
||||||
const tFiles = useTranslations('files');
|
const tFiles = useTranslations('files');
|
||||||
|
const tDemoWelcome = useTranslations('demo_welcome');
|
||||||
|
const tWelcome = useTranslations('welcome');
|
||||||
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
||||||
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
||||||
|
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 emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
@@ -856,9 +902,12 @@ export function EmailViewer({
|
|||||||
// Tablet list visibility
|
// Tablet list visibility
|
||||||
const { isTablet, isMobile } = useDeviceDetection();
|
const { isTablet, isMobile } = useDeviceDetection();
|
||||||
const { tabletListVisible } = useUIStore();
|
const { tabletListVisible } = useUIStore();
|
||||||
const { identities, client } = useAuthStore();
|
const { identities, client, isDemoMode } = useAuthStore();
|
||||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
|
const { startTour } = useTour();
|
||||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||||
|
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
|
||||||
|
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
|
||||||
const [allowExternalContent, setAllowExternalContent] = useState(false);
|
const [allowExternalContent, setAllowExternalContent] = useState(false);
|
||||||
const [hasBlockedContent, setHasBlockedContent] = useState(false);
|
const [hasBlockedContent, setHasBlockedContent] = useState(false);
|
||||||
const [cidBlobUrls, setCidBlobUrls] = useState<Record<string, string>>({});
|
const [cidBlobUrls, setCidBlobUrls] = useState<Record<string, string>>({});
|
||||||
@@ -2144,9 +2193,7 @@ export function EmailViewer({
|
|||||||
// Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines.
|
// Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines.
|
||||||
const hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId];
|
const hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId];
|
||||||
if (hasTextBody && htmlContent) {
|
if (hasTextBody && htmlContent) {
|
||||||
const stripped = htmlContent.replace(/<\/?(html|head|body|meta|!doctype|!DOCTYPE|br\s*\/?)[^>]*>/gi, '').trim();
|
useHtmlVersion = hasMeaningfulHtmlBody(htmlContent);
|
||||||
const hasRichContent = /<(table|tr|td|th|img|style|link|div\s+[^>]*class|span\s+[^>]*class|font|center|blockquote|ul|ol|li|h[1-6])\b/i.test(stripped);
|
|
||||||
useHtmlVersion = hasRichContent;
|
|
||||||
} else {
|
} else {
|
||||||
useHtmlVersion = !!htmlContent;
|
useHtmlVersion = !!htmlContent;
|
||||||
}
|
}
|
||||||
@@ -2391,6 +2438,44 @@ export function EmailViewer({
|
|||||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||||
}, [mailAttachmentAction, onDownloadAttachment]);
|
}, [mailAttachmentAction, onDownloadAttachment]);
|
||||||
|
|
||||||
|
const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => {
|
||||||
|
if (attachment.blobId && onDownloadAttachment) {
|
||||||
|
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachment.tnefData) {
|
||||||
|
const buffer = attachment.tnefData.buffer.slice(
|
||||||
|
attachment.tnefData.byteOffset,
|
||||||
|
attachment.tnefData.byteOffset + attachment.tnefData.byteLength,
|
||||||
|
) as ArrayBuffer;
|
||||||
|
const blob = new Blob([buffer], { type: attachment.type || 'application/octet-stream' });
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = objectUrl;
|
||||||
|
anchor.download = attachment.name || 'download';
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 60000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!attachment.decryptedAttachment) return;
|
||||||
|
const bytes = getAttachmentContentBytes(attachment.decryptedAttachment);
|
||||||
|
if (!bytes || bytes.byteLength === 0) return;
|
||||||
|
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||||
|
const blob = new Blob([buffer], { type: attachment.type || 'application/octet-stream' });
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = objectUrl;
|
||||||
|
anchor.download = attachment.name || 'download';
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||||
|
}, [onDownloadAttachment]);
|
||||||
|
|
||||||
// Iframe for rendering HTML emails true-to-life
|
// Iframe for rendering HTML emails true-to-life
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
@@ -2604,6 +2689,52 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
|
if (isDemoMode) {
|
||||||
|
const logoSrc = resolvedTheme === 'dark'
|
||||||
|
? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg'
|
||||||
|
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg';
|
||||||
|
return (
|
||||||
|
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
|
||||||
|
<div className="text-center p-8 max-w-md">
|
||||||
|
<img
|
||||||
|
src={logoSrc}
|
||||||
|
alt="Bulwark Mail"
|
||||||
|
className="h-12 mx-auto mb-6"
|
||||||
|
/>
|
||||||
|
<h3 className="text-xl font-semibold text-foreground mb-3">{tDemoWelcome('title')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">{tDemoWelcome('description')}</p>
|
||||||
|
<div className="flex flex-col gap-3 items-center">
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-left text-sm text-muted-foreground w-full">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mail className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
<span>{tDemoWelcome('feature_email')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Star className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
<span>{tDemoWelcome('feature_organize')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Keyboard className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
<span>{tDemoWelcome('feature_shortcuts')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
<span>{tDemoWelcome('feature_privacy')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={startTour}
|
||||||
|
className="mt-4 inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
<PlayCircle className="w-4 h-4" />
|
||||||
|
{tWelcome('start_tour')}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-muted-foreground/60 mt-2">{tDemoWelcome('hint')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
|
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
|
||||||
<div className="text-center p-8">
|
<div className="text-center p-8">
|
||||||
@@ -2910,6 +3041,21 @@ export function EmailViewer({
|
|||||||
<Code className="w-4 h-4" />
|
<Code className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Dark/light mode toggle for HTML emails */}
|
||||||
|
{effectiveEmailContent.isHtml && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev)}
|
||||||
|
data-overflow-item
|
||||||
|
data-overflow-priority="11"
|
||||||
|
className="hidden sm:inline-flex h-8 gap-1.5"
|
||||||
|
title={isDark ? 'View in light mode' : 'View in dark mode'}
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* More menu — click-based */}
|
{/* More menu — click-based */}
|
||||||
<div ref={moreMenuRef} className="relative">
|
<div ref={moreMenuRef} className="relative">
|
||||||
<Button
|
<Button
|
||||||
@@ -3092,6 +3238,16 @@ export function EmailViewer({
|
|||||||
<Code className="w-4 h-4" />
|
<Code className="w-4 h-4" />
|
||||||
{t('view_source')}
|
{t('view_source')}
|
||||||
</button>
|
</button>
|
||||||
|
{/* Overflow: dark/light mode toggle */}
|
||||||
|
{effectiveEmailContent.isHtml && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
|
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(11) ? "" : "sm:hidden")}
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||||
|
{isDark ? 'View in light mode' : 'View in dark mode'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
{/* Export email */}
|
{/* Export email */}
|
||||||
<button
|
<button
|
||||||
@@ -3128,6 +3284,7 @@ export function EmailViewer({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={email.id}
|
key={email.id}
|
||||||
|
data-tour="email-viewer"
|
||||||
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)}
|
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)}
|
||||||
>
|
>
|
||||||
{/* Mobile More menu sidebar overlay */}
|
{/* Mobile More menu sidebar overlay */}
|
||||||
@@ -3265,6 +3422,15 @@ export function EmailViewer({
|
|||||||
<Code className="w-5 h-5" />
|
<Code className="w-5 h-5" />
|
||||||
{t('view_source')}
|
{t('view_source')}
|
||||||
</button>
|
</button>
|
||||||
|
{effectiveEmailContent.isHtml && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||||
|
{isDark ? 'View in light mode' : 'View in dark mode'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<button
|
||||||
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
|
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
|
||||||
@@ -3338,7 +3504,7 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<h1 className="text-lg lg:text-2xl font-bold text-foreground tracking-tight break-words min-w-0">
|
<h1 className="text-lg lg:text-xl font-bold text-foreground tracking-tight break-words min-w-0">
|
||||||
{email.subject || t('no_subject')}
|
{email.subject || t('no_subject')}
|
||||||
</h1>
|
</h1>
|
||||||
{/* Star inline with subject (top toolbar mode) */}
|
{/* Star inline with subject (top toolbar mode) */}
|
||||||
@@ -3362,19 +3528,24 @@ export function EmailViewer({
|
|||||||
<span className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
|
<span className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
|
||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 lg:gap-3 mt-1 lg:mt-1.5 text-xs lg:text-sm text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1 lg:gap-1.5 whitespace-nowrap">
|
|
||||||
<Clock className="w-3.5 h-3.5 lg:w-4 lg:h-4" />
|
|
||||||
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
|
||||||
</span>
|
|
||||||
{isImportant && (
|
{isImportant && (
|
||||||
<span className="px-1.5 lg:px-2 py-0.5 bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full text-xs font-medium whitespace-nowrap">
|
<span className="px-1.5 lg:px-2 py-0.5 bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
|
||||||
{t('important')}
|
{t('important')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Date/time on the right of subject row */}
|
||||||
|
<div className="flex-shrink-0 text-right">
|
||||||
|
<span className="text-xs lg:text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
{email.size > 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground/60">
|
||||||
|
{formatFileSize(email.size)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3405,13 +3576,14 @@ export function EmailViewer({
|
|||||||
name={sender?.name}
|
name={sender?.name}
|
||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
size="lg"
|
size="lg"
|
||||||
className="shadow-sm w-12 h-12 group-hover:ring-2 group-hover:ring-primary/30 transition-all"
|
className="shadow-sm w-10 h-10 group-hover:ring-2 group-hover:ring-primary/30 transition-all"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0 flex gap-4">
|
||||||
{/* Sender line with email and badges */}
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start justify-between gap-4">
|
{/* Row 1: Sender name + badges */}
|
||||||
|
<div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<button
|
<button
|
||||||
@@ -3422,103 +3594,80 @@ export function EmailViewer({
|
|||||||
{sender?.name || sender?.email || t('unknown_sender')}
|
{sender?.name || sender?.email || t('unknown_sender')}
|
||||||
</button>
|
</button>
|
||||||
<EmailIdentityBadge email={email} identities={identities} />
|
<EmailIdentityBadge email={email} identities={identities} />
|
||||||
|
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
|
||||||
|
<UnsubscribeBanner
|
||||||
|
listUnsubscribe={listHeaders.listUnsubscribe}
|
||||||
|
senderEmail={email?.from?.[0]?.email || ''}
|
||||||
|
onDismiss={() => {
|
||||||
|
const messageId = email?.messageId || '';
|
||||||
|
const newSet = new Set(dismissedUnsubBanners).add(messageId);
|
||||||
|
setDismissedUnsubBanners(newSet);
|
||||||
|
localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet]));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{sender?.email && (
|
{/* Email address under name */}
|
||||||
<div className="text-sm text-muted-foreground mt-0.5 flex items-center min-w-0">
|
{sender?.email && sender?.name && (
|
||||||
<span className="truncate">{sender.email}</span>
|
<div className="text-sm text-muted-foreground mt-0.5 truncate">{sender.email}</div>
|
||||||
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
|
|
||||||
<UnsubscribeBanner
|
|
||||||
listUnsubscribe={listHeaders.listUnsubscribe}
|
|
||||||
senderEmail={email?.from?.[0]?.email || ''}
|
|
||||||
onDismiss={() => {
|
|
||||||
const messageId = email?.messageId || '';
|
|
||||||
const newSet = new Set(dismissedUnsubBanners).add(messageId);
|
|
||||||
setDismissedUnsubBanners(newSet);
|
|
||||||
localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet]));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Date and size on the right */}
|
|
||||||
<div className="text-right flex-shrink-0">
|
|
||||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
|
||||||
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
|
||||||
</div>
|
|
||||||
{email.size > 0 && (
|
|
||||||
<div className="text-xs text-muted-foreground/70 mt-0.5">
|
|
||||||
{formatFileSize(email.size)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{effectiveEmailContent.isHtml && (
|
|
||||||
<button
|
|
||||||
onClick={() => setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev)}
|
|
||||||
className="inline-flex items-center rounded-full p-1 mt-1 text-muted-foreground/70 hover:text-foreground transition-colors hover:bg-muted"
|
|
||||||
title={isDark ? 'View in light mode' : 'View in dark mode'}
|
|
||||||
>
|
|
||||||
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recipient section - separate line */}
|
{/* Row 2: Recipients + Show details */}
|
||||||
<div className="mt-2 space-y-1">
|
<div className="mt-1 flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
|
||||||
{email.to && email.to.length > 0 && (
|
{email.to && email.to.length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-1 text-sm">
|
<>
|
||||||
<span className="text-muted-foreground">{t('recipient_to_prefix')}</span>
|
<span>{t('recipient_to_prefix')}</span>
|
||||||
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)}
|
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)}
|
||||||
{email.to.length > 2 && (
|
{email.to.length > 2 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFullHeaders(!showFullHeaders)}
|
onClick={() => setShowFullHeaders(!showFullHeaders)}
|
||||||
className="ml-1 text-blue-600 dark:text-blue-400 hover:underline text-sm"
|
className="text-blue-600 dark:text-blue-400 hover:underline text-sm"
|
||||||
>
|
>
|
||||||
{t('more_count', { count: email.to.length - 2 })}
|
{t('more_count', { count: email.to.length - 2 })}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{email.cc && email.cc.length > 0 && (
|
{email.cc && email.cc.length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-1 text-sm">
|
<>
|
||||||
<span className="text-muted-foreground">CC:</span>
|
<span className="text-muted-foreground/50">|</span>
|
||||||
|
<span>CC:</span>
|
||||||
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)}
|
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)}
|
||||||
{email.cc.length > 2 && (
|
{email.cc.length > 2 && (
|
||||||
<span className="text-muted-foreground text-sm">+{email.cc.length - 2}</span>
|
<span className="text-muted-foreground">+{email.cc.length - 2}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{email.bcc && email.bcc.length > 0 && (
|
{email.bcc && email.bcc.length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-1 text-sm">
|
<>
|
||||||
<span className="text-muted-foreground">{t('bcc')}:</span>
|
<span className="text-muted-foreground/50">|</span>
|
||||||
|
<span>{t('bcc')}:</span>
|
||||||
{renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar)}
|
{renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar)}
|
||||||
{email.bcc.length > 2 && (
|
{email.bcc.length > 2 && (
|
||||||
<span className="text-muted-foreground text-sm">+{email.bcc.length - 2}</span>
|
<span className="text-muted-foreground">+{email.bcc.length - 2}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFullHeaders(!showFullHeaders)}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors ml-1"
|
||||||
|
>
|
||||||
|
{showFullHeaders ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="w-3 h-3" />
|
||||||
|
{t('hide_details')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="w-3 h-3" />
|
||||||
|
{t('show_details')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details toggle - stays in place when expanded */}
|
|
||||||
<button
|
|
||||||
onClick={() => setShowFullHeaders(!showFullHeaders)}
|
|
||||||
className="mt-3 text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
|
|
||||||
>
|
|
||||||
{showFullHeaders ? (
|
|
||||||
<>
|
|
||||||
<ChevronUp className="w-3 h-3" />
|
|
||||||
{t('hide_details')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ChevronDown className="w-3 h-3" />
|
|
||||||
{t('show_details')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Expandable Details */}
|
{/* Expandable Details */}
|
||||||
{showFullHeaders && (
|
{showFullHeaders && (
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-3">
|
||||||
@@ -3890,46 +4039,248 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{/* Attachments on the right (beside-sender mode) */}
|
||||||
|
{attachmentPosition === 'beside-sender' && effectiveAttachments.length > 0 && (
|
||||||
|
<div className="relative flex flex-col items-end justify-start gap-1 flex-shrink-0 max-w-[50%]">
|
||||||
|
{effectiveAttachments.slice(0, 2).map((attachment) => {
|
||||||
|
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||||
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={attachment.id}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2 py-1 bg-muted/60 rounded-md border border-border/50 group relative cursor-default"
|
||||||
|
>
|
||||||
|
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
<span className="text-xs text-foreground truncate max-w-[140px]">
|
||||||
|
{getAttachmentDisplayName(attachment.name, attachment.type)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{formatFileSize(attachment.size)}
|
||||||
|
</span>
|
||||||
|
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||||
|
<button
|
||||||
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
|
title={t('download')}
|
||||||
|
onClick={() => handleEffectiveAttachmentDownload(attachment)}
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5 text-foreground" />
|
||||||
|
</button>
|
||||||
|
{opensPreview && (
|
||||||
|
<button
|
||||||
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
|
title={tFiles('preview')}
|
||||||
|
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5 text-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{effectiveAttachments.length > 2 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAllBesideAttachments(!showAllBesideAttachments)}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-0.5"
|
||||||
|
>
|
||||||
|
+{effectiveAttachments.length - 2} {t('more')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Floating popup for remaining attachments */}
|
||||||
|
{showAllBesideAttachments && effectiveAttachments.length > 2 && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setShowAllBesideAttachments(false)} />
|
||||||
|
<div className="absolute top-full right-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
|
||||||
|
{effectiveAttachments.slice(2).map((attachment) => {
|
||||||
|
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||||
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={attachment.id}
|
||||||
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md group relative cursor-default w-full"
|
||||||
|
>
|
||||||
|
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
<span className="text-xs text-foreground truncate max-w-[180px]">
|
||||||
|
{getAttachmentDisplayName(attachment.name, attachment.type)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground ml-auto flex-shrink-0">
|
||||||
|
{formatFileSize(attachment.size)}
|
||||||
|
</span>
|
||||||
|
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||||
|
<button
|
||||||
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
|
title={t('download')}
|
||||||
|
onClick={() => { handleEffectiveAttachmentDownload(attachment); setShowAllBesideAttachments(false); }}
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5 text-foreground" />
|
||||||
|
</button>
|
||||||
|
{opensPreview && (
|
||||||
|
<button
|
||||||
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
|
title={tFiles('preview')}
|
||||||
|
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllBesideAttachments(false); }}
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5 text-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* === ATTACHMENTS (integrated into header) === */}
|
{/* === ATTACHMENTS below header (below-header mode, desktop only) === */}
|
||||||
{effectiveAttachments.length > 0 && (
|
{attachmentPosition === 'below-header' && effectiveAttachments.length > 0 && (
|
||||||
<div className="bg-background border-b border-border px-4 lg:px-6 py-3">
|
<div className="hidden lg:block bg-background border-b border-border px-4 lg:px-6 py-2">
|
||||||
<div className="flex items-start gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{effectiveAttachments.map((attachment) => {
|
{effectiveAttachments.map((attachment) => {
|
||||||
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={attachment.id}
|
key={attachment.id}
|
||||||
className="inline-flex items-center gap-2 px-3 py-2 bg-muted/60 hover:bg-accent rounded-lg transition-colors group border border-border/50"
|
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-muted/60 rounded-md border border-border/50 group relative cursor-default"
|
||||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${attachment.name || 'Unnamed'} (${formatFileSize(attachment.size)})`}
|
|
||||||
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
|
||||||
>
|
>
|
||||||
<FileIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<FileIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
<div className="flex flex-col items-start min-w-0">
|
<span className="text-sm text-foreground truncate max-w-[200px]">
|
||||||
<span className="text-sm text-foreground truncate max-w-[200px]">
|
{getAttachmentDisplayName(attachment.name, attachment.type)}
|
||||||
{attachment.name || "Unnamed"}
|
</span>
|
||||||
</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
<span className="text-xs text-muted-foreground">
|
{formatFileSize(attachment.size)}
|
||||||
{formatFileSize(attachment.size)}
|
</span>
|
||||||
</span>
|
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||||
|
<button
|
||||||
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
|
title={t('download')}
|
||||||
|
onClick={() => handleEffectiveAttachmentDownload(attachment)}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 text-foreground" />
|
||||||
|
</button>
|
||||||
|
{opensPreview && (
|
||||||
|
<button
|
||||||
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
|
title={tFiles('preview')}
|
||||||
|
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4 text-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{opensPreview ? (
|
</div>
|
||||||
<Eye className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<Download className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mobile/Tablet Attachments */}
|
||||||
|
{effectiveAttachments.length > 0 && (
|
||||||
|
<div className="lg:hidden bg-background border-b border-border px-4 py-2">
|
||||||
|
<div className="relative flex items-center gap-1.5 flex-wrap">
|
||||||
|
{effectiveAttachments.slice(0, 2).map((attachment) => {
|
||||||
|
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||||
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={attachment.id}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-muted/60 rounded-md border border-border/50 group relative cursor-default"
|
||||||
|
>
|
||||||
|
<FileIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<span className="text-sm text-foreground truncate max-w-[200px]">
|
||||||
|
{getAttachmentDisplayName(attachment.name, attachment.type)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatFileSize(attachment.size)}
|
||||||
|
</span>
|
||||||
|
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||||
|
<button
|
||||||
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
|
title={t('download')}
|
||||||
|
onClick={() => handleEffectiveAttachmentDownload(attachment)}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 text-foreground" />
|
||||||
|
</button>
|
||||||
|
{opensPreview && (
|
||||||
|
<button
|
||||||
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
|
title={tFiles('preview')}
|
||||||
|
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4 text-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{effectiveAttachments.length > 2 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAllMobileAttachments(!showAllMobileAttachments)}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-0.5"
|
||||||
|
>
|
||||||
|
+{effectiveAttachments.length - 2} {t('more')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showAllMobileAttachments && effectiveAttachments.length > 2 && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setShowAllMobileAttachments(false)} />
|
||||||
|
<div className="absolute top-full left-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
|
||||||
|
{effectiveAttachments.slice(2).map((attachment) => {
|
||||||
|
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||||
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={attachment.id}
|
||||||
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md group relative cursor-default w-full"
|
||||||
|
>
|
||||||
|
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
<span className="text-xs text-foreground truncate max-w-[180px]">
|
||||||
|
{getAttachmentDisplayName(attachment.name, attachment.type)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground ml-auto flex-shrink-0">
|
||||||
|
{formatFileSize(attachment.size)}
|
||||||
|
</span>
|
||||||
|
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||||
|
<button
|
||||||
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
|
title={t('download')}
|
||||||
|
onClick={() => { handleEffectiveAttachmentDownload(attachment); setShowAllMobileAttachments(false); }}
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5 text-foreground" />
|
||||||
|
</button>
|
||||||
|
{opensPreview && (
|
||||||
|
<button
|
||||||
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
|
title={tFiles('preview')}
|
||||||
|
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllMobileAttachments(false); }}
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5 text-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mobile/Tablet Sender Info - scrolls with content */}
|
{/* Mobile/Tablet Sender Info - scrolls with content */}
|
||||||
<div className="lg:hidden bg-background border-b border-border px-4" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
<div className="lg:hidden bg-background border-b border-border px-4" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||||
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
|
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
|
||||||
@@ -3946,8 +4297,8 @@ export function EmailViewer({
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Mobile 2-line layout */}
|
{/* Row 1: Sender name + badges */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
|
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
|
||||||
className="text-sm font-semibold text-foreground hover:text-primary hover:underline transition-colors cursor-pointer text-left"
|
className="text-sm font-semibold text-foreground hover:text-primary hover:underline transition-colors cursor-pointer text-left"
|
||||||
@@ -3955,43 +4306,42 @@ export function EmailViewer({
|
|||||||
{sender?.name || sender?.email || t('unknown_sender')}
|
{sender?.name || sender?.email || t('unknown_sender')}
|
||||||
</button>
|
</button>
|
||||||
<EmailIdentityBadge email={email} identities={identities} />
|
<EmailIdentityBadge email={email} identities={identities} />
|
||||||
</div>
|
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
|
||||||
<div className="mt-1 flex items-center gap-1 text-sm text-muted-foreground flex-wrap">
|
<UnsubscribeBanner
|
||||||
{sender?.email && sender?.name && (
|
listUnsubscribe={listHeaders.listUnsubscribe}
|
||||||
<>
|
senderEmail={email?.from?.[0]?.email || ''}
|
||||||
<span className="truncate">{sender.email}</span>
|
onDismiss={() => {
|
||||||
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
|
const messageId = email?.messageId || '';
|
||||||
<UnsubscribeBanner
|
const newSet = new Set(dismissedUnsubBanners).add(messageId);
|
||||||
listUnsubscribe={listHeaders.listUnsubscribe}
|
setDismissedUnsubBanners(newSet);
|
||||||
senderEmail={email?.from?.[0]?.email || ''}
|
localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet]));
|
||||||
onDismiss={() => {
|
}}
|
||||||
const messageId = email?.messageId || '';
|
/>
|
||||||
const newSet = new Set(dismissedUnsubBanners).add(messageId);
|
|
||||||
setDismissedUnsubBanners(newSet);
|
|
||||||
localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet]));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span>·</span>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Email address under name */}
|
||||||
|
{sender?.email && sender?.name && (
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5 truncate">{sender.email}</div>
|
||||||
|
)}
|
||||||
|
{/* Row 2: Recipients */}
|
||||||
|
<div className="mt-0.5 flex items-center gap-1 text-sm text-muted-foreground flex-wrap">
|
||||||
{email.to && email.to.length > 0 && (
|
{email.to && email.to.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<span>→ {t('recipient_to_prefix')}</span>
|
<span>→ {t('recipient_to_prefix')}</span>
|
||||||
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)}
|
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{email.cc && email.cc.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground/50">|</span>
|
||||||
|
<span>CC:</span>
|
||||||
|
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)}
|
||||||
|
{email.cc.length > 2 && (
|
||||||
|
<span>+{email.cc.length - 2}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* CC line (mobile - only if present) */}
|
|
||||||
{email.cc && email.cc.length > 0 && (
|
|
||||||
<div className="mt-1 flex items-center gap-1 text-sm">
|
|
||||||
<span className="text-muted-foreground">CC:</span>
|
|
||||||
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)}
|
|
||||||
{email.cc.length > 2 && (
|
|
||||||
<span className="text-muted-foreground">+{email.cc.length - 2}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from "react";
|
|||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
||||||
|
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||||
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
@@ -320,9 +321,7 @@ function EmailCard({
|
|||||||
// Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines.
|
// Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines.
|
||||||
const hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId];
|
const hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId];
|
||||||
if (hasTextBody && htmlContent) {
|
if (hasTextBody && htmlContent) {
|
||||||
const stripped = htmlContent.replace(/<\/?(html|head|body|meta|!doctype|!DOCTYPE|br\s*\/?)[^>]*>/gi, '').trim();
|
useHtmlVersion = hasMeaningfulHtmlBody(htmlContent);
|
||||||
const hasRichContent = /<(table|tr|td|th|img|style|link|div\s+[^>]*class|span\s+[^>]*class|font|center|blockquote|ul|ol|li|h[1-6])\b/i.test(stripped);
|
|
||||||
useHtmlVersion = hasRichContent;
|
|
||||||
} else {
|
} else {
|
||||||
useHtmlVersion = !!htmlContent;
|
useHtmlVersion = !!htmlContent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { getThreadColorTag, getEmailColorTag } 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";
|
||||||
|
import { EmailHoverActions } from "./email-hover-actions";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
interface ThreadListItemProps {
|
interface ThreadListItemProps {
|
||||||
@@ -25,6 +26,12 @@ interface ThreadListItemProps {
|
|||||||
onEmailSelect: (email: Email) => void;
|
onEmailSelect: (email: Email) => void;
|
||||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||||
|
onToggleStar?: (email: Email) => void;
|
||||||
|
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||||
|
onDelete?: (email: Email) => void;
|
||||||
|
onArchive?: (email: Email) => void;
|
||||||
|
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||||
|
onMarkAsSpam?: (email: Email) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SingleEmailItemProps {
|
interface SingleEmailItemProps {
|
||||||
@@ -34,10 +41,16 @@ interface SingleEmailItemProps {
|
|||||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||||
showPreview: boolean;
|
showPreview: boolean;
|
||||||
colorTag: string | null;
|
colorTag: string | null;
|
||||||
|
onToggleStar?: () => void;
|
||||||
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
onArchive?: () => void;
|
||||||
|
onSetColorTag?: (color: string | null) => void;
|
||||||
|
onMarkAsSpam?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||||
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag }, ref) {
|
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const sender = email.from?.[0];
|
const sender = email.from?.[0];
|
||||||
@@ -100,7 +113,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{...dragHandlers}
|
{...dragHandlers}
|
||||||
{...longPressHandlers}
|
{...longPressHandlers}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative group cursor-pointer select-none transition-all duration-200 border-b border-border",
|
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||||
resolvedColorTag ? resolvedColorTag : (
|
resolvedColorTag ? resolvedColorTag : (
|
||||||
selected
|
selected
|
||||||
? "bg-accent"
|
? "bg-accent"
|
||||||
@@ -216,6 +229,17 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hover Quick Actions */}
|
||||||
|
<EmailHoverActions
|
||||||
|
email={email}
|
||||||
|
onToggleStar={onToggleStar}
|
||||||
|
onMarkAsRead={onMarkAsRead}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onArchive={onArchive}
|
||||||
|
onSetColorTag={onSetColorTag}
|
||||||
|
onMarkAsSpam={onMarkAsSpam}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -232,6 +256,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
onEmailSelect,
|
onEmailSelect,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
onOpenConversation,
|
onOpenConversation,
|
||||||
|
onToggleStar,
|
||||||
|
onMarkAsRead,
|
||||||
|
onDelete,
|
||||||
|
onArchive,
|
||||||
|
onSetColorTag,
|
||||||
|
onMarkAsSpam,
|
||||||
}, ref) {
|
}, ref) {
|
||||||
const t = useTranslations('threads');
|
const t = useTranslations('threads');
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
@@ -278,6 +308,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
showPreview={showPreview}
|
showPreview={showPreview}
|
||||||
colorTag={colorTag}
|
colorTag={colorTag}
|
||||||
|
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||||
|
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||||
|
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||||
|
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||||
|
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||||
|
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -339,7 +375,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{...dragHandlers}
|
{...dragHandlers}
|
||||||
{...threadLongPressHandlers}
|
{...threadLongPressHandlers}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative group cursor-pointer select-none transition-all duration-200",
|
"relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
|
||||||
colorTag ? colorTag : (
|
colorTag ? colorTag : (
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-accent"
|
? "bg-accent"
|
||||||
@@ -493,6 +529,17 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hover Quick Actions for thread header */}
|
||||||
|
<EmailHoverActions
|
||||||
|
email={latestEmail}
|
||||||
|
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||||
|
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||||
|
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||||
|
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||||
|
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||||
|
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && !isMobile && (
|
{isExpanded && !isMobile && (
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export function SubAddressHelper({
|
|||||||
<div
|
<div
|
||||||
ref={popoverRef}
|
ref={popoverRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute top-full left-0 mt-1 z-50',
|
'absolute top-full right-0 mt-1 z-50',
|
||||||
'bg-background border border-border rounded-lg shadow-lg',
|
'bg-background border border-border rounded-lg shadow-lg',
|
||||||
'w-80 p-4 animate-in fade-in zoom-in-95 duration-150'
|
'w-80 p-4 animate-in fade-in zoom-in-95 duration-150'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { X, Keyboard } from "lucide-react";
|
|||||||
import { KEYBOARD_SHORTCUTS } from "@/hooks/use-keyboard-shortcuts";
|
import { KEYBOARD_SHORTCUTS } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||||
|
import { useTour } from "@/components/tour/tour-provider";
|
||||||
|
|
||||||
interface KeyboardShortcutsModalProps {
|
interface KeyboardShortcutsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -13,6 +14,7 @@ interface KeyboardShortcutsModalProps {
|
|||||||
|
|
||||||
export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) {
|
export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const { startTour } = useTour();
|
||||||
|
|
||||||
const modalRef = useFocusTrap({
|
const modalRef = useFocusTrap({
|
||||||
isActive: isOpen,
|
isActive: isOpen,
|
||||||
@@ -144,6 +146,14 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
|
|||||||
<p className="text-sm text-muted-foreground text-center">
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
{t("shortcuts.tip")}
|
{t("shortcuts.tip")}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-sm text-center mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { onClose(); startTour(); }}
|
||||||
|
className="text-primary hover:text-primary/80 underline underline-offset-2 transition-colors"
|
||||||
|
>
|
||||||
|
{t("tour.take_a_tour")}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useRouter } from "@/i18n/navigation";
|
||||||
|
|
||||||
|
interface AccountSwitcherProps {
|
||||||
|
/** "rail" = small avatar only (NavigationRail), "expanded" = avatar + name + email (Sidebar) */
|
||||||
|
variant?: "rail" | "expanded";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountAvatar({ account, size = "sm" }: { account: AccountEntry; size?: "sm" | "md" }) {
|
||||||
|
const initials = getInitials(account.displayName || account.label, account.email || account.username);
|
||||||
|
const sizeClasses = size === "sm" ? "w-8 h-8 text-xs" : "w-9 h-9 text-sm";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("rounded-full flex items-center justify-center text-white font-medium flex-shrink-0", sizeClasses)}
|
||||||
|
style={{ backgroundColor: account.avatarColor }}
|
||||||
|
title={account.label}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountSwitcher({ variant = "rail", className }: AccountSwitcherProps) {
|
||||||
|
const t = useTranslations("sidebar");
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
|
||||||
|
|
||||||
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
|
const activeAccountId = useAccountStore((s) => s.activeAccountId);
|
||||||
|
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
|
||||||
|
const activeAccount = accounts.find((a) => a.id === activeAccountId);
|
||||||
|
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
const logoutAll = useAuthStore((s) => s.logoutAll);
|
||||||
|
const primaryIdentity = useAuthStore((s) => s.primaryIdentity);
|
||||||
|
|
||||||
|
const updatePosition = useCallback(() => {
|
||||||
|
if (!buttonRef.current) return;
|
||||||
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
|
if (variant === "rail") {
|
||||||
|
setPopoverStyle({
|
||||||
|
position: "fixed",
|
||||||
|
left: rect.right + 8,
|
||||||
|
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setPopoverStyle({
|
||||||
|
position: "fixed",
|
||||||
|
left: rect.left,
|
||||||
|
top: rect.bottom + 4,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [variant]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
updatePosition();
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
buttonRef.current?.contains(e.target as Node) ||
|
||||||
|
popoverRef.current?.contains(e.target as Node)
|
||||||
|
) return;
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
document.removeEventListener("keydown", handleEscape);
|
||||||
|
};
|
||||||
|
}, [open, updatePosition]);
|
||||||
|
|
||||||
|
const handleSwitch = async (accountId: string) => {
|
||||||
|
if (accountId === activeAccountId) return;
|
||||||
|
setOpen(false);
|
||||||
|
await switchAccount(accountId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddAccount = () => {
|
||||||
|
setOpen(false);
|
||||||
|
router.push(`/login?mode=add-account` as never);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
setOpen(false);
|
||||||
|
logout();
|
||||||
|
if (useAccountStore.getState().accounts.length === 0) {
|
||||||
|
router.push("/login" as never);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogoutAll = () => {
|
||||||
|
setOpen(false);
|
||||||
|
logoutAll();
|
||||||
|
router.push("/login" as never);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetDefault = (accountId: string) => {
|
||||||
|
setDefaultAccount(accountId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Display name for the active account
|
||||||
|
const displayName = primaryIdentity?.name || activeAccount?.displayName || activeAccount?.label || "";
|
||||||
|
const displayEmail = primaryIdentity?.email || activeAccount?.email || activeAccount?.username || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-md transition-colors",
|
||||||
|
variant === "rail"
|
||||||
|
? "justify-center w-10 h-10 hover:bg-muted"
|
||||||
|
: "w-full px-2 py-1.5 hover:bg-muted text-left min-w-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
title={variant === "rail" ? (displayName || displayEmail) : undefined}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
{activeAccount ? (
|
||||||
|
<>
|
||||||
|
<AccountAvatar account={activeAccount} size={variant === "rail" ? "sm" : "md"} />
|
||||||
|
{variant === "expanded" && (
|
||||||
|
<>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">{displayName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{displayEmail}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className={cn("w-3.5 h-3.5 text-muted-foreground flex-shrink-0 transition-transform", open && "rotate-180")} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={cn(
|
||||||
|
"rounded-full bg-muted flex items-center justify-center text-muted-foreground",
|
||||||
|
variant === "rail" ? "w-8 h-8 text-xs" : "w-9 h-9 text-sm"
|
||||||
|
)}>
|
||||||
|
?
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && createPortal(
|
||||||
|
<div
|
||||||
|
ref={popoverRef}
|
||||||
|
style={popoverStyle}
|
||||||
|
className="w-72 rounded-lg border border-border bg-background text-foreground shadow-lg z-50 overflow-hidden"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
{/* Account List */}
|
||||||
|
<div className="py-1 max-h-64 overflow-y-auto">
|
||||||
|
{accounts.map((account) => {
|
||||||
|
const isActive = account.id === activeAccountId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={account.id}
|
||||||
|
onClick={() => handleSwitch(account.id)}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-start gap-3 px-3 py-2.5 text-left transition-colors",
|
||||||
|
isActive ? "bg-accent/50" : "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
role="menuitem"
|
||||||
|
disabled={isActive}
|
||||||
|
>
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<AccountAvatar account={account} size="md" />
|
||||||
|
{isActive && (
|
||||||
|
<div className="absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-full bg-primary flex items-center justify-center">
|
||||||
|
<Check className="w-2.5 h-2.5 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm font-medium truncate">
|
||||||
|
{account.displayName || account.label}
|
||||||
|
</span>
|
||||||
|
{account.isDefault && (
|
||||||
|
<Star className="w-3 h-3 text-amber-500 flex-shrink-0 fill-amber-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{account.email || account.username}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1 mt-0.5">
|
||||||
|
{account.hasError ? (
|
||||||
|
<AlertCircle className="w-3 h-3 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<span className={cn(
|
||||||
|
"w-1.5 h-1.5 rounded-full",
|
||||||
|
account.isConnected ? "bg-green-500" : "bg-muted-foreground/40"
|
||||||
|
)} />
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-muted-foreground truncate">
|
||||||
|
{new URL(account.serverUrl).hostname}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Separator + Add Account */}
|
||||||
|
{accounts.length < MAX_ACCOUNTS && (
|
||||||
|
<div className="border-t border-border">
|
||||||
|
<button
|
||||||
|
onClick={handleAddAccount}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
{t("add_account")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Separator + Actions */}
|
||||||
|
<div className="border-t border-border">
|
||||||
|
{activeAccount && !activeAccount.isDefault && accounts.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSetDefault(activeAccount.id)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<Star className="w-4 h-4" />
|
||||||
|
{t("set_as_default")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
{t("sign_out_of", { account: displayEmail })}
|
||||||
|
</button>
|
||||||
|
{accounts.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={handleLogoutAll}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-muted transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
{t("sign_out_all")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut, Keyboard, Plus } from "lucide-react";
|
import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut, Keyboard, Plus } from "lucide-react";
|
||||||
|
import { AccountSwitcher } from "./account-switcher";
|
||||||
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
||||||
import { usePathname, Link } from "@/i18n/navigation";
|
import { usePathname, Link } from "@/i18n/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -216,8 +217,8 @@ export function NavigationRail({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Custom sidebar apps */}
|
{/* Custom sidebar apps (per-app mobile visibility) */}
|
||||||
{sidebarApps.map((app) => {
|
{sidebarApps.filter((app) => app.showOnMobile).map((app) => {
|
||||||
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
|
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
|
||||||
const isActive = activeAppId === app.id;
|
const isActive = activeAppId === app.id;
|
||||||
return (
|
return (
|
||||||
@@ -251,16 +252,27 @@ export function NavigationRail({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Manage apps button */}
|
{/* Settings */}
|
||||||
{onManageApps && (
|
<Link
|
||||||
<button
|
href="/settings"
|
||||||
onClick={onManageApps}
|
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
className={cn(
|
||||||
>
|
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]",
|
||||||
<Plus className="w-5 h-5" />
|
"transition-colors duration-150",
|
||||||
<span className="text-[10px] font-medium leading-tight">{t("add_app")}</span>
|
isSettingsActive
|
||||||
</button>
|
? "text-primary"
|
||||||
)}
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
aria-current={isSettingsActive ? "page" : undefined}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Settings className="w-5 h-5" />
|
||||||
|
{isSettingsActive && (
|
||||||
|
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-medium leading-tight">{t("settings")}</span>
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -291,6 +303,7 @@ export function NavigationRail({
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||||
|
data-tour={`nav-${item.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
|
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
|
||||||
collapsed
|
collapsed
|
||||||
@@ -389,6 +402,7 @@ export function NavigationRail({
|
|||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||||
|
data-tour="nav-settings"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-center w-10 h-10 rounded-md transition-colors",
|
"flex items-center justify-center w-10 h-10 rounded-md transition-colors",
|
||||||
isSettingsActive
|
isSettingsActive
|
||||||
@@ -406,6 +420,7 @@ export function NavigationRail({
|
|||||||
{onShowShortcuts && (
|
{onShowShortcuts && (
|
||||||
<button
|
<button
|
||||||
onClick={onShowShortcuts}
|
onClick={onShowShortcuts}
|
||||||
|
data-tour="nav-shortcuts"
|
||||||
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
title={t("keyboard_shortcuts")}
|
title={t("keyboard_shortcuts")}
|
||||||
>
|
>
|
||||||
@@ -414,7 +429,9 @@ export function NavigationRail({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{quota && quota.total > 0 && (
|
{quota && quota.total > 0 && (
|
||||||
<StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} />
|
<div data-tour="storage-quota">
|
||||||
|
<StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPushConnected != null && (
|
{isPushConnected != null && (
|
||||||
@@ -432,13 +449,7 @@ export function NavigationRail({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{onLogout && (
|
{onLogout && (
|
||||||
<button
|
<AccountSwitcher variant="rail" />
|
||||||
onClick={onLogout}
|
|
||||||
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
||||||
title={t("sign_out")}
|
|
||||||
>
|
|
||||||
<LogOut className="w-[18px] h-[18px]" />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface SidebarAppFormData {
|
|||||||
url: string;
|
url: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
openMode: 'tab' | 'inline';
|
openMode: 'tab' | 'inline';
|
||||||
|
showOnMobile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarAppForm({
|
function SidebarAppForm({
|
||||||
@@ -37,6 +38,7 @@ function SidebarAppForm({
|
|||||||
url: app?.url || '',
|
url: app?.url || '',
|
||||||
icon: app?.icon || 'Globe',
|
icon: app?.icon || 'Globe',
|
||||||
openMode: app?.openMode || 'tab',
|
openMode: app?.openMode || 'tab',
|
||||||
|
showOnMobile: app?.showOnMobile ?? false,
|
||||||
});
|
});
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
X,
|
X,
|
||||||
Tag,
|
Tag,
|
||||||
|
RotateCcw,
|
||||||
|
FlaskConical,
|
||||||
|
PlayCircle,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
import { Mailbox } from "@/lib/jmap/types";
|
import { Mailbox } from "@/lib/jmap/types";
|
||||||
@@ -39,6 +43,8 @@ import { toast } from "@/stores/toast-store";
|
|||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
|
import { AccountSwitcher } from "./account-switcher";
|
||||||
|
import { useTour } from "@/components/tour/tour-provider";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
mailboxes: Mailbox[];
|
mailboxes: Mailbox[];
|
||||||
@@ -327,6 +333,68 @@ function TagItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DemoBanner() {
|
||||||
|
const t = useTranslations('sidebar');
|
||||||
|
const { isDemoMode, loginDemo } = useAuthStore();
|
||||||
|
const { startTour, resetTourCompletion } = useTour();
|
||||||
|
const router = useRouter();
|
||||||
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
|
|
||||||
|
if (!isDemoMode) return null;
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
setIsResetting(true);
|
||||||
|
// Navigate to home first so the mail page re-fetches data
|
||||||
|
router.push('/');
|
||||||
|
await loginDemo();
|
||||||
|
setIsResetting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartTour = () => {
|
||||||
|
resetTourCompletion();
|
||||||
|
router.push('/');
|
||||||
|
setTimeout(() => startTour(), 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-tour="demo-banner"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-1.5 w-full px-3 py-2 text-xs",
|
||||||
|
"bg-primary/10 dark:bg-primary/10 text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FlaskConical className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
|
<span className="truncate font-medium">{t("demo_banner")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={handleStartTour}
|
||||||
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 hover:bg-primary/20 transition-colors"
|
||||||
|
title={t("demo_tour")}
|
||||||
|
>
|
||||||
|
<PlayCircle className="w-3 h-3" />
|
||||||
|
{t("demo_tour")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={isResetting}
|
||||||
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 hover:bg-primary/20 transition-colors disabled:opacity-50"
|
||||||
|
title={t("demo_reset")}
|
||||||
|
>
|
||||||
|
{isResetting ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
{t("demo_reset")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function VacationBanner() {
|
function VacationBanner() {
|
||||||
const t = useTranslations('sidebar');
|
const t = useTranslations('sidebar');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -453,12 +521,12 @@ export function Sidebar({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={cn("flex items-center border-b border-border", isCollapsed ? "justify-center px-2 py-3" : "gap-2 px-4 py-3")}>
|
<div className={cn("flex items-center border-b border-border", isCollapsed ? "justify-center px-2 py-2" : "gap-1 px-2 py-2")}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onSidebarClose}
|
onClick={onSidebarClose}
|
||||||
className="lg:hidden h-11 w-11 flex-shrink-0"
|
className="lg:hidden h-9 w-9 flex-shrink-0"
|
||||||
aria-label={t("close")}
|
aria-label={t("close")}
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
@@ -479,29 +547,25 @@ export function Sidebar({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={toggleSidebarCollapsed}
|
onClick={toggleSidebarCollapsed}
|
||||||
className="hidden lg:flex flex-shrink-0"
|
className="hidden lg:flex h-8 w-8 flex-shrink-0"
|
||||||
title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
|
title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
|
||||||
>
|
>
|
||||||
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
|
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{!isCollapsed && primaryIdentity && (
|
{!isCollapsed && (
|
||||||
<div className="min-w-0">
|
<AccountSwitcher variant="expanded" className="flex-1" />
|
||||||
<p className="text-sm font-medium text-foreground truncate" title={primaryIdentity.name}>
|
|
||||||
{primaryIdentity.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate" title={primaryIdentity.email}>
|
|
||||||
{primaryIdentity.email}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Demo Banner */}
|
||||||
|
{!isCollapsed && <DemoBanner />}
|
||||||
|
|
||||||
{/* Vacation Banner */}
|
{/* Vacation Banner */}
|
||||||
{!isCollapsed && <VacationBanner />}
|
{!isCollapsed && <VacationBanner />}
|
||||||
|
|
||||||
{/* Mailbox List */}
|
{/* Mailbox List */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{mailboxes.length === 0 ? (
|
{mailboxes.length === 0 ? (
|
||||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||||
@@ -588,7 +652,7 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
||||||
<div className="relative">
|
<div className="relative" data-tour="keyword-tags">
|
||||||
{emailKeywords.map((kw) => {
|
{emailKeywords.map((kw) => {
|
||||||
const isSelected = selectedKeyword === kw.id;
|
const isSelected = selectedKeyword === kw.id;
|
||||||
return (
|
return (
|
||||||
@@ -612,11 +676,11 @@ export function Sidebar({
|
|||||||
{/* Compose Button */}
|
{/* Compose Button */}
|
||||||
<div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}>
|
<div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<Button onClick={onCompose} variant="ghost" size="icon" title={t("compose_hint")}>
|
<Button onClick={onCompose} variant="ghost" size="icon" title={t("compose_hint")} data-tour="compose-button">
|
||||||
<PenSquare className="w-5 h-5" />
|
<PenSquare className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={onCompose} className="w-full" title={t("compose_hint")}>
|
<Button onClick={onCompose} className="w-full" title={t("compose_hint")} data-tour="compose-button">
|
||||||
<PenSquare className="w-4 h-4 mr-2" />
|
<PenSquare className="w-4 h-4 mr-2" />
|
||||||
{t("compose")}
|
{t("compose")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -8,13 +8,21 @@ import { formatFileSize } from '@/lib/utils';
|
|||||||
|
|
||||||
export function AccountSettings() {
|
export function AccountSettings() {
|
||||||
const t = useTranslations('settings.account');
|
const t = useTranslations('settings.account');
|
||||||
const { username, serverUrl } = useAuthStore();
|
const { username, serverUrl, isDemoMode, primaryIdentity } = useAuthStore();
|
||||||
const { quota } = useEmailStore();
|
const { quota } = useEmailStore();
|
||||||
|
|
||||||
const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0;
|
const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0;
|
||||||
|
const displayName = primaryIdentity?.name || (isDemoMode ? 'Demo User' : undefined);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title={t('title')} description={t('description')}>
|
<SettingsSection title={t('title')} description={t('description')}>
|
||||||
|
{/* Display Name (show in demo mode or when identity has a name) */}
|
||||||
|
{displayName && (
|
||||||
|
<SettingItem label={t('name_label')}>
|
||||||
|
<span className="text-sm text-foreground">{displayName}</span>
|
||||||
|
</SettingItem>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Email Address */}
|
{/* Email Address */}
|
||||||
<SettingItem label={t('email.label')}>
|
<SettingItem label={t('email.label')}>
|
||||||
<span className="text-sm text-foreground">{username || t('../../common.unknown')}</span>
|
<span className="text-sm text-foreground">{username || t('../../common.unknown')}</span>
|
||||||
@@ -49,6 +57,16 @@ export function AccountSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Demo mode indicator */}
|
||||||
|
{isDemoMode && (
|
||||||
|
<SettingItem label={t('account_type_label')}>
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-amber-600 dark:text-amber-400">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
|
||||||
|
{t('demo_account')}
|
||||||
|
</span>
|
||||||
|
</SettingItem>
|
||||||
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { useSettingsStore, type ToolbarPosition, type Density } from '@/stores/s
|
|||||||
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
||||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTour } from '@/components/tour/tour-provider';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { PlayCircle } from 'lucide-react';
|
||||||
|
|
||||||
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
|
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
|
||||||
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
|
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
|
||||||
@@ -61,8 +64,10 @@ function DensityPreview({ density }: { density: Density }) {
|
|||||||
|
|
||||||
export function AppearanceSettings() {
|
export function AppearanceSettings() {
|
||||||
const t = useTranslations('settings.appearance');
|
const t = useTranslations('settings.appearance');
|
||||||
|
const tTour = useTranslations('tour');
|
||||||
const { theme, setTheme } = useThemeStore();
|
const { theme, setTheme } = useThemeStore();
|
||||||
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, updateSetting } = useSettingsStore();
|
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, updateSetting } = useSettingsStore();
|
||||||
|
const { startTour, resetTourCompletion } = useTour();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title={t('title')} description={t('description')}>
|
<SettingsSection title={t('title')} description={t('description')}>
|
||||||
@@ -141,6 +146,19 @@ export function AppearanceSettings() {
|
|||||||
onChange={(checked) => updateSetting('animationsEnabled', checked)}
|
onChange={(checked) => updateSetting('animationsEnabled', checked)}
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Restart Tour */}
|
||||||
|
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { resetTourCompletion(); startTour(); }}
|
||||||
|
className="text-xs h-7"
|
||||||
|
>
|
||||||
|
<PlayCircle className="w-3.5 h-3.5 mr-1" />
|
||||||
|
{tTour('restart_button')}
|
||||||
|
</Button>
|
||||||
|
</SettingItem>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ export function CalendarManagementSettings() {
|
|||||||
const { client, serverUrl, username } = useAuthStore();
|
const { client, serverUrl, username } = useAuthStore();
|
||||||
const { calendars, updateCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
|
const { calendars, updateCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
|
||||||
|
|
||||||
|
const [discoveredCalDavUrls, setDiscoveredCalDavUrls] = useState<Record<string, string | null>>({});
|
||||||
|
const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
@@ -175,6 +178,60 @@ export function CalendarManagementSettings() {
|
|||||||
}
|
}
|
||||||
}, [client, calendars.length, fetchCalendars]);
|
}, [client, calendars.length, fetchCalendars]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!client || !serverUrl || !username) {
|
||||||
|
setDiscoveredCalDavUrls({});
|
||||||
|
setWellKnownCalDavUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryKey = username;
|
||||||
|
const accounts = new Map<string, string[]>();
|
||||||
|
accounts.set(primaryKey, [username]);
|
||||||
|
|
||||||
|
for (const calendar of calendars) {
|
||||||
|
if (!calendar.isShared) continue;
|
||||||
|
const key = calendar.accountId || calendar.accountName || calendar.id;
|
||||||
|
const candidates = accounts.get(key) || [];
|
||||||
|
if (calendar.accountId) candidates.push(calendar.accountId);
|
||||||
|
if (calendar.accountName) candidates.push(calendar.accountName);
|
||||||
|
accounts.set(key, candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
fetch('/api/caldav/discover', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
accounts: Array.from(accounts.entries()).map(([key, candidates]) => ({ key, candidates })),
|
||||||
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) throw new Error(`CalDAV discovery failed: ${response.status}`);
|
||||||
|
return response.json() as Promise<{
|
||||||
|
wellKnownUrl?: string;
|
||||||
|
accounts?: Record<string, { url: string | null }>;
|
||||||
|
}>;
|
||||||
|
})
|
||||||
|
.then((payload) => {
|
||||||
|
setWellKnownCalDavUrl(payload.wellKnownUrl || null);
|
||||||
|
const next: Record<string, string | null> = {};
|
||||||
|
for (const [key, value] of Object.entries(payload.accounts || {})) {
|
||||||
|
next[key] = value?.url || null;
|
||||||
|
}
|
||||||
|
setDiscoveredCalDavUrls(next);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
const fallbackWellKnown = new URL('/.well-known/caldav', serverUrl).toString();
|
||||||
|
setDiscoveredCalDavUrls({});
|
||||||
|
setWellKnownCalDavUrl(fallbackWellKnown);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [client, calendars, serverUrl, username]);
|
||||||
|
|
||||||
const handleRefreshSubscription = async (subId: string) => {
|
const handleRefreshSubscription = async (subId: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
setRefreshingSubId(subId);
|
setRefreshingSubId(subId);
|
||||||
@@ -295,8 +352,10 @@ export function CalendarManagementSettings() {
|
|||||||
|
|
||||||
const buildCalDavUrl = (calendarId: string) => {
|
const buildCalDavUrl = (calendarId: string) => {
|
||||||
if (!serverUrl || !username) return null;
|
if (!serverUrl || !username) return null;
|
||||||
const base = serverUrl.replace(/\/$/, '');
|
const calendar = calendars.find((entry) => entry.id === calendarId);
|
||||||
return `${base}/dav/calendars/user/${encodeURIComponent(username)}/${encodeURIComponent(calendarId)}/`;
|
if (!calendar) return null;
|
||||||
|
const accountKey = calendar.isShared ? (calendar.accountId || calendar.accountName || calendar.id) : username;
|
||||||
|
return discoveredCalDavUrls[accountKey] || wellKnownCalDavUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyUrl = async (url: string) => {
|
const handleCopyUrl = async (url: string) => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function CalendarSettings() {
|
|||||||
const {
|
const {
|
||||||
timeFormat,
|
timeFormat,
|
||||||
firstDayOfWeek,
|
firstDayOfWeek,
|
||||||
|
showTimeInMonthView,
|
||||||
calendarNotificationsEnabled,
|
calendarNotificationsEnabled,
|
||||||
calendarNotificationSound,
|
calendarNotificationSound,
|
||||||
calendarInvitationParsingEnabled,
|
calendarInvitationParsingEnabled,
|
||||||
@@ -57,6 +58,16 @@ export function CalendarSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem
|
||||||
|
label={t('show_time_in_month_view')}
|
||||||
|
description={t('show_time_in_month_view_desc')}
|
||||||
|
>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={showTimeInMonthView}
|
||||||
|
onChange={(checked) => updateSetting('showTimeInMonthView', checked)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={t('notifications_enabled')}
|
label={t('notifications_enabled')}
|
||||||
description={t('notifications_enabled_desc')}
|
description={t('notifications_enabled_desc')}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import type { ArchiveMode } from '@/stores/settings-store';
|
import type { ArchiveMode, HoverAction } from '@/stores/settings-store';
|
||||||
|
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { useEmailStore } from '@/stores/email-store';
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
import { 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 } from 'lucide-react';
|
import { ChevronRight, AlertTriangle, FolderSync, Loader2 } from 'lucide-react';
|
||||||
@@ -24,8 +26,10 @@ export function EmailSettings() {
|
|||||||
emailsPerPage,
|
emailsPerPage,
|
||||||
externalContentPolicy,
|
externalContentPolicy,
|
||||||
mailAttachmentAction,
|
mailAttachmentAction,
|
||||||
|
attachmentPosition,
|
||||||
emailAlwaysLightMode,
|
emailAlwaysLightMode,
|
||||||
archiveMode,
|
archiveMode,
|
||||||
|
hoverActions,
|
||||||
trustedSenders,
|
trustedSenders,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
} = useSettingsStore();
|
} = useSettingsStore();
|
||||||
@@ -184,6 +188,39 @@ export function EmailSettings() {
|
|||||||
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
|
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Quick Hover Actions */}
|
||||||
|
<div className="py-3 border-b border-border space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">{t('hover_actions.label')}</label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{t('hover_actions.description')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ALL_HOVER_ACTIONS.map((action) => {
|
||||||
|
const isEnabled = hoverActions.includes(action.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={action.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const newActions = isEnabled
|
||||||
|
? hoverActions.filter((a: HoverAction) => a !== action.id)
|
||||||
|
: [...hoverActions, action.id];
|
||||||
|
updateSetting('hoverActions', newActions);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-xs rounded-md transition-colors duration-150',
|
||||||
|
isEnabled
|
||||||
|
? 'bg-primary text-primary-foreground font-medium'
|
||||||
|
: 'bg-muted hover:bg-accent text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(`hover_actions.${action.labelKey}`)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SettingItem label={t('attachment_click_action.label')} description={t('attachment_click_action.description')}>
|
<SettingItem label={t('attachment_click_action.label')} description={t('attachment_click_action.description')}>
|
||||||
<Select
|
<Select
|
||||||
value={mailAttachmentAction}
|
value={mailAttachmentAction}
|
||||||
@@ -195,6 +232,17 @@ export function EmailSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem label={t('attachment_position.label')} description={t('attachment_position.description')}>
|
||||||
|
<Select
|
||||||
|
value={attachmentPosition}
|
||||||
|
onChange={(value) => updateSetting('attachmentPosition', value as 'beside-sender' | 'below-header')}
|
||||||
|
options={[
|
||||||
|
{ value: 'beside-sender', label: t('attachment_position.beside-sender') },
|
||||||
|
{ value: 'below-header', label: t('attachment_position.below-header') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
{/* Emails Per Page */}
|
{/* Emails Per Page */}
|
||||||
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')}>
|
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')}>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -490,21 +490,34 @@ export function FolderSettings() {
|
|||||||
|
|
||||||
{/* Standard Folder Roles — advanced section */}
|
{/* Standard Folder Roles — advanced section */}
|
||||||
<SettingsSection title={t('standard_roles')} description={t('standard_roles_description')}>
|
<SettingsSection title={t('standard_roles')} description={t('standard_roles_description')}>
|
||||||
{STANDARD_ROLES.map((role) => (
|
{STANDARD_ROLES.map((role) => {
|
||||||
<SettingItem key={role} label={t(`role_${role}`)}>
|
// Disambiguate duplicate folder names by appending parent path
|
||||||
<Select
|
const nameCounts = new Map<string, number>();
|
||||||
value={getRoleMailboxId(role)}
|
ownMailboxes.forEach(mb => nameCounts.set(mb.name, (nameCounts.get(mb.name) || 0) + 1));
|
||||||
onChange={(value) => handleRoleChange(role, value)}
|
const getParentPath = (mb: { parentId?: string; name: string }) => {
|
||||||
options={[
|
if (!mb.parentId) return '';
|
||||||
{ value: '', label: t('role_none') },
|
const parent = ownMailboxes.find(p => p.id === mb.parentId);
|
||||||
...ownMailboxes.map(mb => ({
|
return parent ? `${parent.name}/` : '';
|
||||||
value: mb.id,
|
};
|
||||||
label: mb.name,
|
|
||||||
})),
|
return (
|
||||||
]}
|
<SettingItem key={role} label={t(`role_${role}`)}>
|
||||||
/>
|
<Select
|
||||||
</SettingItem>
|
value={getRoleMailboxId(role)}
|
||||||
))}
|
onChange={(value) => handleRoleChange(role, value)}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: t('role_none') },
|
||||||
|
...ownMailboxes.map(mb => ({
|
||||||
|
value: mb.id,
|
||||||
|
label: (nameCounts.get(mb.name) || 0) > 1
|
||||||
|
? `${getParentPath(mb)}${mb.name} (${mb.id.slice(-6)})`
|
||||||
|
: mb.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
import { useState } from "react";
|
import { 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 { useEmailStore } from "@/stores/email-store";
|
||||||
import { SettingsSection } from "./settings-section";
|
import { SettingsSection } from "./settings-section";
|
||||||
import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw } from "lucide-react";
|
import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw, Loader2 } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE);
|
const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE);
|
||||||
@@ -91,16 +93,14 @@ function KeywordEditForm({
|
|||||||
const [color, setColor] = useState(initial?.color || "blue");
|
const [color, setColor] = useState(initial?.color || "blue");
|
||||||
const isEditing = !!initial;
|
const isEditing = !!initial;
|
||||||
|
|
||||||
const normalizedId = isEditing
|
const normalizedId = label
|
||||||
? initial.id
|
.trim()
|
||||||
: label
|
.toLowerCase()
|
||||||
.trim()
|
.replace(/[^a-z0-9_-]/g, "-")
|
||||||
.toLowerCase()
|
.replace(/-+/g, "-")
|
||||||
.replace(/[^a-z0-9_-]/g, "-")
|
.replace(/^-|-$/g, "");
|
||||||
.replace(/-+/g, "-")
|
|
||||||
.replace(/^-|-$/g, "");
|
|
||||||
|
|
||||||
const isDuplicate = !isEditing && normalizedId.length > 0 && existingIds.includes(normalizedId);
|
const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId);
|
||||||
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
|
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
@@ -159,10 +159,13 @@ function KeywordEditForm({
|
|||||||
|
|
||||||
export function KeywordSettings() {
|
export function KeywordSettings() {
|
||||||
const t = useTranslations("settings.keywords");
|
const t = useTranslations("settings.keywords");
|
||||||
const { emailKeywords, addKeyword, updateKeyword, removeKeyword, reorderKeywords } =
|
const { emailKeywords, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords } =
|
||||||
useSettingsStore();
|
useSettingsStore();
|
||||||
|
const { client } = useAuthStore();
|
||||||
|
const { fetchTagCounts } = useEmailStore();
|
||||||
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 existingIds = emailKeywords.map((k) => k.id);
|
const existingIds = emailKeywords.map((k) => k.id);
|
||||||
|
|
||||||
@@ -171,8 +174,32 @@ export function KeywordSettings() {
|
|||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (keyword: KeywordDefinition) => {
|
const handleEdit = async (keyword: KeywordDefinition) => {
|
||||||
updateKeyword(keyword.id, { label: keyword.label, color: keyword.color });
|
const oldId = editingId;
|
||||||
|
if (!oldId) return;
|
||||||
|
|
||||||
|
const idChanged = oldId !== keyword.id;
|
||||||
|
|
||||||
|
if (idChanged && client) {
|
||||||
|
setIsMigrating(true);
|
||||||
|
try {
|
||||||
|
const oldJmapKeyword = `$label:${oldId}`;
|
||||||
|
const newJmapKeyword = `$label:${keyword.id}`;
|
||||||
|
await client.migrateKeyword(oldJmapKeyword, newJmapKeyword);
|
||||||
|
renameKeyword(oldId, keyword);
|
||||||
|
fetchTagCounts(client);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to migrate keyword:", error);
|
||||||
|
const toastModule = await import('sonner');
|
||||||
|
toastModule.toast.error(t("migration_error"));
|
||||||
|
setIsMigrating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsMigrating(false);
|
||||||
|
} else {
|
||||||
|
updateKeyword(oldId, { label: keyword.label, color: keyword.color });
|
||||||
|
}
|
||||||
|
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -187,6 +214,12 @@ export function KeywordSettings() {
|
|||||||
return (
|
return (
|
||||||
<SettingsSection title={t("title")} description={t("description")}>
|
<SettingsSection title={t("title")} description={t("description")}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{isMigrating && (
|
||||||
|
<div className="flex items-center gap-2 p-2 text-xs text-muted-foreground bg-accent/50 rounded-md">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
{t("migrating")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{emailKeywords.map((keyword) =>
|
{emailKeywords.map((keyword) =>
|
||||||
editingId === keyword.id ? (
|
editingId === keyword.id ? (
|
||||||
<KeywordEditForm
|
<KeywordEditForm
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface SidebarAppFormData {
|
|||||||
url: string;
|
url: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
openMode: "tab" | "inline";
|
openMode: "tab" | "inline";
|
||||||
|
showOnMobile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppForm({
|
function AppForm({
|
||||||
@@ -37,6 +38,7 @@ function AppForm({
|
|||||||
url: app?.url || "",
|
url: app?.url || "",
|
||||||
icon: app?.icon || "Globe",
|
icon: app?.icon || "Globe",
|
||||||
openMode: app?.openMode || "tab",
|
openMode: app?.openMode || "tab",
|
||||||
|
showOnMobile: app?.showOnMobile ?? false,
|
||||||
});
|
});
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
@@ -144,6 +146,25 @@ function AppForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium">{t("show_on_mobile")}</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, showOnMobile: !formData.showOnMobile })}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||||
|
formData.showOnMobile ? "bg-primary" : "bg-muted-foreground/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||||
|
formData.showOnMobile ? "translate-x-4.5" : "translate-x-0.5"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useTour } from "./tour-provider";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||||
|
|
||||||
|
interface Rect {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PADDING = 8;
|
||||||
|
const TOOLTIP_GAP = 12;
|
||||||
|
const TOOLTIP_MAX_W = 360;
|
||||||
|
|
||||||
|
function getTargetRect(selector: string): Rect | null {
|
||||||
|
const el = document.querySelector(selector);
|
||||||
|
if (!el) return null;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
// Element might exist but be hidden (zero dimensions)
|
||||||
|
if (r.width === 0 && r.height === 0) return null;
|
||||||
|
return { top: r.top, left: r.left, width: r.width, height: r.height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeTooltipPosition(
|
||||||
|
target: Rect,
|
||||||
|
placement: "top" | "bottom" | "left" | "right",
|
||||||
|
tooltipSize: { width: number; height: number }
|
||||||
|
): { top: number; left: number; actualPlacement: string } {
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const tw = Math.max(tooltipSize.width, 200); // minimum fallback width
|
||||||
|
const th = Math.max(tooltipSize.height, 100); // minimum fallback height
|
||||||
|
|
||||||
|
const positions = {
|
||||||
|
bottom: {
|
||||||
|
top: target.top + target.height + PADDING + TOOLTIP_GAP,
|
||||||
|
left: target.left + target.width / 2 - tw / 2,
|
||||||
|
},
|
||||||
|
top: {
|
||||||
|
top: target.top - PADDING - TOOLTIP_GAP - th,
|
||||||
|
left: target.left + target.width / 2 - tw / 2,
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
top: target.top + target.height / 2 - th / 2,
|
||||||
|
left: target.left + target.width + PADDING + TOOLTIP_GAP,
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
top: target.top + target.height / 2 - th / 2,
|
||||||
|
left: target.left - PADDING - TOOLTIP_GAP - tw,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const fits = (p: { top: number; left: number }) =>
|
||||||
|
p.top >= 8 && p.left >= 8 && p.top + th <= vh - 8 && p.left + tw <= vw - 8;
|
||||||
|
|
||||||
|
// Try preferred placement first, then fallback order
|
||||||
|
const order: Array<"top" | "bottom" | "left" | "right"> = [placement, "bottom", "right", "left", "top"];
|
||||||
|
for (const dir of order) {
|
||||||
|
const pos = positions[dir];
|
||||||
|
if (fits(pos)) return { ...pos, actualPlacement: dir };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If nothing fits perfectly, use preferred but clamped
|
||||||
|
const pos = positions[placement];
|
||||||
|
return {
|
||||||
|
top: Math.max(8, Math.min(pos.top, vh - th - 8)),
|
||||||
|
left: Math.max(8, Math.min(pos.left, vw - tw - 8)),
|
||||||
|
actualPlacement: placement,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TourOverlay() {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { currentStep, totalSteps, steps, nextStep, prevStep, stopTour } = useTour();
|
||||||
|
const step = steps[currentStep];
|
||||||
|
|
||||||
|
const [targetRect, setTargetRect] = useState<Rect | null>(null);
|
||||||
|
const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Use refs for callbacks to avoid stale closures in timers/intervals
|
||||||
|
const updatePositionRef = useRef<() => void>(() => {});
|
||||||
|
const nextStepRef = useRef<() => void>(() => {});
|
||||||
|
nextStepRef.current = nextStep;
|
||||||
|
|
||||||
|
const focusTrapRef = useFocusTrap({
|
||||||
|
isActive: visible,
|
||||||
|
onEscape: stopTour,
|
||||||
|
restoreFocus: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set mounted for portal
|
||||||
|
useEffect(() => { setMounted(true); }, []);
|
||||||
|
|
||||||
|
const updatePosition = useCallback(() => {
|
||||||
|
if (!step) return;
|
||||||
|
const rect = getTargetRect(step.target);
|
||||||
|
|
||||||
|
if (rect) {
|
||||||
|
setTargetRect(rect);
|
||||||
|
if (tooltipRef.current) {
|
||||||
|
const { width, height } = tooltipRef.current.getBoundingClientRect();
|
||||||
|
const pos = computeTooltipPosition(rect, step.placement, { width, height });
|
||||||
|
setTooltipPos({ top: pos.top, left: pos.left });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If rect is null, keep previous targetRect (element temporarily hidden during scroll/resize)
|
||||||
|
// Only the step-change effect should null out targetRect
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
|
// Keep ref in sync
|
||||||
|
updatePositionRef.current = updatePosition;
|
||||||
|
|
||||||
|
// Wait for target element to appear, then show
|
||||||
|
useEffect(() => {
|
||||||
|
if (!step) return;
|
||||||
|
console.log(`[Tour] Step ${currentStep + 1}/${totalSteps}: "${step.id}" — target: ${step.target}, placement: ${step.placement}, interactive: ${!!step.interactive}`);
|
||||||
|
setVisible(false);
|
||||||
|
// Keep old targetRect and tooltipPos so the cutout/tooltip animate to the new position
|
||||||
|
// instead of disappearing and reappearing
|
||||||
|
|
||||||
|
// Clear any pending timer from a previous step
|
||||||
|
if (pendingTimerRef.current) {
|
||||||
|
clearTimeout(pendingTimerRef.current);
|
||||||
|
pendingTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run beforeAction if defined (e.g. click an email to open the viewer)
|
||||||
|
if (step.beforeAction) {
|
||||||
|
step.beforeAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 50; // 5 seconds
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const tryFind = () => {
|
||||||
|
if (cancelled) return true;
|
||||||
|
const el = document.querySelector(step.target);
|
||||||
|
if (el) {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": element FOUND (${rect.width}x${rect.height} at ${Math.round(rect.left)},${Math.round(rect.top)})`);
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||||
|
// Delay after scroll for layout to settle
|
||||||
|
pendingTimerRef.current = setTimeout(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": showing tooltip`);
|
||||||
|
updatePositionRef.current();
|
||||||
|
setVisible(true);
|
||||||
|
// Second position update after tooltip renders with final dimensions
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!cancelled) updatePositionRef.current();
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (attempts % 10 === 0) {
|
||||||
|
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": element NOT found (attempt ${attempts + 1}/${maxAttempts})`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tryFind()) return () => { cancelled = true; };
|
||||||
|
|
||||||
|
// Poll for element appearance (for page navigation)
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
attempts++;
|
||||||
|
if (tryFind() || attempts >= maxAttempts) {
|
||||||
|
clearInterval(interval);
|
||||||
|
if (attempts >= maxAttempts && !cancelled) {
|
||||||
|
// Skip this step if element never appears
|
||||||
|
console.warn(`[Tour] Step ${currentStep + 1} "${step.id}": SKIPPED — element never appeared after ${maxAttempts} attempts`);
|
||||||
|
nextStepRef.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(interval);
|
||||||
|
if (pendingTimerRef.current) {
|
||||||
|
clearTimeout(pendingTimerRef.current);
|
||||||
|
pendingTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [step, currentStep]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Recalculate on resize/scroll (debounced)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
let rafId: number | null = null;
|
||||||
|
const handler = () => {
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
updatePosition();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", handler);
|
||||||
|
window.addEventListener("scroll", handler, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", handler);
|
||||||
|
window.removeEventListener("scroll", handler, true);
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
|
}, [visible, updatePosition]);
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "ArrowRight" || e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
nextStep();
|
||||||
|
} else if (e.key === "ArrowLeft") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
prevStep();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
stopTour();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler, true);
|
||||||
|
return () => window.removeEventListener("keydown", handler, true);
|
||||||
|
}, [nextStep, prevStep, stopTour]);
|
||||||
|
|
||||||
|
// Re-position after tooltip content renders with new dimensions
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || !tooltipRef.current) return;
|
||||||
|
// Use rAF to wait for the browser to lay out the tooltip content
|
||||||
|
const id = requestAnimationFrame(() => {
|
||||||
|
updatePosition();
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}, [visible, updatePosition, currentStep]);
|
||||||
|
|
||||||
|
if (!mounted || !step) return null;
|
||||||
|
|
||||||
|
const cutout = targetRect
|
||||||
|
? {
|
||||||
|
x: targetRect.left - PADDING,
|
||||||
|
y: targetRect.top - PADDING,
|
||||||
|
w: targetRect.width + PADDING * 2,
|
||||||
|
h: targetRect.height + PADDING * 2,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isLast = currentStep >= totalSteps - 1;
|
||||||
|
const isFirst = currentStep === 0;
|
||||||
|
const isInteractive = step.interactive;
|
||||||
|
|
||||||
|
const reducedMotion =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
const transitionStyle = reducedMotion ? "none" : "all 300ms ease";
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
{/* SVG overlay with cutout */}
|
||||||
|
<svg
|
||||||
|
className="fixed inset-0 z-[9998]"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
style={{ pointerEvents: isInteractive ? "none" : "auto" }}
|
||||||
|
onClick={stopTour}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<mask id="tour-mask">
|
||||||
|
<rect fill="white" width="100%" height="100%" />
|
||||||
|
{cutout && (
|
||||||
|
<rect
|
||||||
|
fill="black"
|
||||||
|
x={cutout.x}
|
||||||
|
y={cutout.y}
|
||||||
|
width={cutout.w}
|
||||||
|
height={cutout.h}
|
||||||
|
rx="8"
|
||||||
|
style={{ transition: transitionStyle }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<rect
|
||||||
|
fill="black"
|
||||||
|
opacity="0.5"
|
||||||
|
mask="url(#tour-mask)"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Click-through cutout zone for interactive steps */}
|
||||||
|
{isInteractive && cutout && (
|
||||||
|
<div
|
||||||
|
className="fixed z-[9998]"
|
||||||
|
style={{
|
||||||
|
top: cutout.y,
|
||||||
|
left: cutout.x,
|
||||||
|
width: cutout.w,
|
||||||
|
height: cutout.h,
|
||||||
|
pointerEvents: "none",
|
||||||
|
transition: transitionStyle,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Non-interactive overlay click blocker around cutout */}
|
||||||
|
{!isInteractive && cutout && (
|
||||||
|
<div
|
||||||
|
className="fixed z-[9998]"
|
||||||
|
style={{
|
||||||
|
top: cutout.y,
|
||||||
|
left: cutout.x,
|
||||||
|
width: cutout.w,
|
||||||
|
height: cutout.h,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
<div
|
||||||
|
ref={(node) => {
|
||||||
|
(tooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||||
|
(focusTrapRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t(step.titleKey)}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-[9999] transition-all",
|
||||||
|
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: tooltipPos?.top ?? -9999,
|
||||||
|
left: tooltipPos?.left ?? -9999,
|
||||||
|
maxWidth: TOOLTIP_MAX_W,
|
||||||
|
transition: reducedMotion ? "none" : "opacity 200ms ease, transform 200ms ease, top 300ms ease, left 300ms ease",
|
||||||
|
pointerEvents: "auto",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="bg-background border border-border rounded-xl shadow-2xl p-4">
|
||||||
|
{/* Step counter */}
|
||||||
|
<p className="text-xs text-muted-foreground mb-1" aria-live="polite">
|
||||||
|
{t("tour.step_counter", { current: currentStep + 1, total: totalSteps })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="font-semibold text-sm text-foreground">{t(step.titleKey)}</h3>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{t(step.descriptionKey)}</p>
|
||||||
|
|
||||||
|
{/* Navigation buttons */}
|
||||||
|
<div className="flex items-center justify-between mt-3">
|
||||||
|
<button
|
||||||
|
onClick={stopTour}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded hover:bg-muted"
|
||||||
|
>
|
||||||
|
{t("tour.skip")}
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={prevStep}
|
||||||
|
disabled={isFirst}
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-3 py-1.5 rounded-md border border-border transition-colors",
|
||||||
|
isFirst
|
||||||
|
? "opacity-40 cursor-not-allowed"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("tour.back")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={nextStep}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
{isLast ? t("tour.finish") : t("tour.next")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress dots */}
|
||||||
|
<div className="flex justify-center gap-1 mt-2">
|
||||||
|
{steps.map((_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
"w-1.5 h-1.5 rounded-full transition-colors",
|
||||||
|
i === currentStep ? "bg-primary" : "bg-muted-foreground/30"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
|
||||||
|
import { useRouter } from "@/i18n/navigation";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
|
import { useWebDAVStore } from "@/stores/webdav-store";
|
||||||
|
import { getTourSteps, type TourStep } from "./tour-steps";
|
||||||
|
import { TourOverlay } from "./tour-overlay";
|
||||||
|
|
||||||
|
const TOUR_COMPLETED_KEY = "tour_completed";
|
||||||
|
const TOUR_CURRENT_STEP_KEY = "tour_current_step";
|
||||||
|
|
||||||
|
interface TourContextValue {
|
||||||
|
isActive: boolean;
|
||||||
|
currentStep: number;
|
||||||
|
totalSteps: number;
|
||||||
|
steps: TourStep[];
|
||||||
|
startTour: () => void;
|
||||||
|
stopTour: () => void;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
hasCompletedTour: boolean;
|
||||||
|
resetTourCompletion: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TourContext = createContext<TourContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useTour() {
|
||||||
|
const ctx = useContext(TourContext);
|
||||||
|
if (!ctx) throw new Error("useTour must be used within TourProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TourProvider({ children }: { children: ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isDemoMode } = useAuthStore();
|
||||||
|
const { supportsCalendar } = useCalendarStore();
|
||||||
|
const { supportsWebDAV } = useWebDAVStore();
|
||||||
|
|
||||||
|
const [isActive, setIsActive] = useState(false);
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [hasCompletedTour, setHasCompletedTour] = useState(false);
|
||||||
|
|
||||||
|
const steps = getTourSteps({ isDemoMode, supportsCalendar, supportsWebDAV: supportsWebDAV !== false });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
setHasCompletedTour(localStorage.getItem(TOUR_COMPLETED_KEY) === "true");
|
||||||
|
} catch { /* */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startTour = useCallback(() => {
|
||||||
|
let resumeStep = 0;
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(TOUR_CURRENT_STEP_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = parseInt(stored, 10);
|
||||||
|
if (!isNaN(parsed) && parsed >= 0) resumeStep = parsed;
|
||||||
|
}
|
||||||
|
} catch { /* */ }
|
||||||
|
|
||||||
|
// If the resume step is beyond the current steps, start from 0
|
||||||
|
if (resumeStep >= steps.length) resumeStep = 0;
|
||||||
|
|
||||||
|
setCurrentStep(resumeStep);
|
||||||
|
setIsActive(true);
|
||||||
|
}, [steps.length]);
|
||||||
|
|
||||||
|
const stopTour = useCallback(() => {
|
||||||
|
setIsActive(false);
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
|
||||||
|
} catch { /* */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const completeTour = useCallback(() => {
|
||||||
|
setIsActive(false);
|
||||||
|
setHasCompletedTour(true);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(TOUR_COMPLETED_KEY, "true");
|
||||||
|
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
|
||||||
|
} catch { /* */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const nextStep = useCallback(() => {
|
||||||
|
if (currentStep >= steps.length - 1) {
|
||||||
|
completeTour();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = currentStep + 1;
|
||||||
|
const nextStepDef = steps[next];
|
||||||
|
setCurrentStep(next);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(next));
|
||||||
|
} catch { /* */ }
|
||||||
|
|
||||||
|
// Navigate if the next step requires a different page
|
||||||
|
if (nextStepDef?.page) {
|
||||||
|
router.push(nextStepDef.page);
|
||||||
|
}
|
||||||
|
}, [currentStep, steps, completeTour, router]);
|
||||||
|
|
||||||
|
const prevStep = useCallback(() => {
|
||||||
|
if (currentStep <= 0) return;
|
||||||
|
const prev = currentStep - 1;
|
||||||
|
const prevStepDef = steps[prev];
|
||||||
|
setCurrentStep(prev);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(prev));
|
||||||
|
} catch { /* */ }
|
||||||
|
|
||||||
|
if (prevStepDef?.page) {
|
||||||
|
router.push(prevStepDef.page);
|
||||||
|
} else if (steps[currentStep]?.page) {
|
||||||
|
// Going back from a page-specific step to a non-page step => go to mail
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
|
}, [currentStep, steps, router]);
|
||||||
|
|
||||||
|
const resetTourCompletion = useCallback(() => {
|
||||||
|
setHasCompletedTour(false);
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(TOUR_COMPLETED_KEY);
|
||||||
|
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
|
||||||
|
} catch { /* */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value: TourContextValue = {
|
||||||
|
isActive,
|
||||||
|
currentStep,
|
||||||
|
totalSteps: steps.length,
|
||||||
|
steps,
|
||||||
|
startTour,
|
||||||
|
stopTour,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
hasCompletedTour,
|
||||||
|
resetTourCompletion,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TourContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
{isActive && <TourOverlay />}
|
||||||
|
</TourContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
export interface TourStep {
|
||||||
|
id: string;
|
||||||
|
target: string;
|
||||||
|
titleKey: string;
|
||||||
|
descriptionKey: string;
|
||||||
|
placement: "top" | "bottom" | "left" | "right";
|
||||||
|
interactive?: boolean;
|
||||||
|
spotlight?: "rect" | "circle";
|
||||||
|
page?: string;
|
||||||
|
demoOnly?: boolean;
|
||||||
|
beforeAction?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BASE_TOUR_STEPS: TourStep[] = [
|
||||||
|
{
|
||||||
|
id: "sidebar",
|
||||||
|
target: '[data-tour="sidebar"]',
|
||||||
|
titleKey: "tour.sidebar_title",
|
||||||
|
descriptionKey: "tour.sidebar_desc",
|
||||||
|
placement: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "compose",
|
||||||
|
target: '[data-tour="compose-button"]',
|
||||||
|
titleKey: "tour.compose_title",
|
||||||
|
descriptionKey: "tour.compose_desc",
|
||||||
|
placement: "right",
|
||||||
|
interactive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "search",
|
||||||
|
target: '[data-tour="search-input"]',
|
||||||
|
titleKey: "tour.search_title",
|
||||||
|
descriptionKey: "tour.search_desc",
|
||||||
|
placement: "bottom",
|
||||||
|
interactive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "email-list",
|
||||||
|
target: '[data-tour="email-list"]',
|
||||||
|
titleKey: "tour.email_list_title",
|
||||||
|
descriptionKey: "tour.email_list_desc",
|
||||||
|
placement: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "email-viewer",
|
||||||
|
target: '[data-tour="email-viewer"]',
|
||||||
|
titleKey: "tour.email_viewer_title",
|
||||||
|
descriptionKey: "tour.email_viewer_desc",
|
||||||
|
placement: "left",
|
||||||
|
beforeAction: () => {
|
||||||
|
// Click the "Welcome to Bulwark Mail!" email (or the first email) to open the viewer
|
||||||
|
const emailList = document.querySelector('[data-tour="email-list"]');
|
||||||
|
if (!emailList) return;
|
||||||
|
// Try to find the welcome email by subject text
|
||||||
|
const items = emailList.querySelectorAll('.cursor-pointer');
|
||||||
|
let target: HTMLElement | null = null;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.textContent?.includes("Welcome to Bulwark Mail")) {
|
||||||
|
target = item as HTMLElement;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to first email if welcome email not found
|
||||||
|
if (!target) target = emailList.querySelector('.cursor-pointer') as HTMLElement | null;
|
||||||
|
if (target) target.click();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "keywords",
|
||||||
|
target: '[data-tour="keyword-tags"]',
|
||||||
|
titleKey: "tour.keywords_title",
|
||||||
|
descriptionKey: "tour.keywords_desc",
|
||||||
|
placement: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nav-calendar",
|
||||||
|
target: '[data-tour="nav-calendar"]',
|
||||||
|
titleKey: "tour.calendar_title",
|
||||||
|
descriptionKey: "tour.calendar_desc",
|
||||||
|
placement: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nav-contacts",
|
||||||
|
target: '[data-tour="nav-contacts"]',
|
||||||
|
titleKey: "tour.contacts_title",
|
||||||
|
descriptionKey: "tour.contacts_desc",
|
||||||
|
placement: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nav-settings",
|
||||||
|
target: '[data-tour="nav-settings"]',
|
||||||
|
titleKey: "tour.settings_title",
|
||||||
|
descriptionKey: "tour.settings_desc",
|
||||||
|
placement: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "shortcuts",
|
||||||
|
target: '[data-tour="nav-shortcuts"]',
|
||||||
|
titleKey: "tour.shortcuts_title",
|
||||||
|
descriptionKey: "tour.shortcuts_desc",
|
||||||
|
placement: "right",
|
||||||
|
interactive: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEMO_TOUR_STEPS: TourStep[] = [
|
||||||
|
{
|
||||||
|
id: "compose-open",
|
||||||
|
target: '[data-tour="composer"]',
|
||||||
|
titleKey: "tour.compose_open_title",
|
||||||
|
descriptionKey: "tour.compose_open_desc",
|
||||||
|
placement: "left",
|
||||||
|
demoOnly: true,
|
||||||
|
beforeAction: () => {
|
||||||
|
// Click the compose button to open the composer
|
||||||
|
const btn = document.querySelector('[data-tour="compose-button"]') as HTMLElement | null;
|
||||||
|
if (btn) btn.click();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "calendar-view",
|
||||||
|
target: '[data-tour="calendar-view"]',
|
||||||
|
titleKey: "tour.calendar_view_title",
|
||||||
|
descriptionKey: "tour.calendar_view_desc",
|
||||||
|
placement: "bottom",
|
||||||
|
page: "/calendar",
|
||||||
|
demoOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "create-event",
|
||||||
|
target: '[data-tour="create-event-button"]',
|
||||||
|
titleKey: "tour.create_event_title",
|
||||||
|
descriptionKey: "tour.create_event_desc",
|
||||||
|
placement: "bottom",
|
||||||
|
page: "/calendar",
|
||||||
|
interactive: true,
|
||||||
|
demoOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "event-modal",
|
||||||
|
target: '[data-tour="event-modal"]',
|
||||||
|
titleKey: "tour.event_modal_title",
|
||||||
|
descriptionKey: "tour.event_modal_desc",
|
||||||
|
placement: "left",
|
||||||
|
page: "/calendar",
|
||||||
|
interactive: true,
|
||||||
|
demoOnly: true,
|
||||||
|
beforeAction: () => {
|
||||||
|
// Click the create event button to open the modal
|
||||||
|
const btn = document.querySelector('[data-tour="create-event-button"]') as HTMLElement | null;
|
||||||
|
if (btn) btn.click();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "contacts-list",
|
||||||
|
target: '[data-tour="contacts-list"]',
|
||||||
|
titleKey: "tour.contacts_list_title",
|
||||||
|
descriptionKey: "tour.contacts_list_desc",
|
||||||
|
placement: "right",
|
||||||
|
page: "/contacts",
|
||||||
|
demoOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "settings-tabs",
|
||||||
|
target: '[data-tour="settings-tabs"]',
|
||||||
|
titleKey: "tour.settings_tabs_title",
|
||||||
|
descriptionKey: "tour.settings_tabs_desc",
|
||||||
|
placement: "right",
|
||||||
|
page: "/settings",
|
||||||
|
demoOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nav-files",
|
||||||
|
target: '[data-tour="nav-files"]',
|
||||||
|
titleKey: "tour.files_title",
|
||||||
|
descriptionKey: "tour.files_desc",
|
||||||
|
placement: "right",
|
||||||
|
demoOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demo-banner",
|
||||||
|
target: '[data-tour="demo-banner"]',
|
||||||
|
titleKey: "tour.demo_banner_title",
|
||||||
|
descriptionKey: "tour.demo_banner_desc",
|
||||||
|
placement: "bottom",
|
||||||
|
page: "/",
|
||||||
|
demoOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quota",
|
||||||
|
target: '[data-tour="storage-quota"]',
|
||||||
|
titleKey: "tour.quota_title",
|
||||||
|
descriptionKey: "tour.quota_desc",
|
||||||
|
placement: "right",
|
||||||
|
demoOnly: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getTourSteps(options: {
|
||||||
|
isDemoMode: boolean;
|
||||||
|
supportsCalendar: boolean;
|
||||||
|
supportsWebDAV: boolean;
|
||||||
|
}): TourStep[] {
|
||||||
|
let steps = [...BASE_TOUR_STEPS];
|
||||||
|
|
||||||
|
if (!options.supportsCalendar) {
|
||||||
|
steps = steps.filter((s) => s.id !== "nav-calendar");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.isDemoMode) {
|
||||||
|
const demoSteps = DEMO_TOUR_STEPS.filter((s) => {
|
||||||
|
if (s.id === "nav-files" && !options.supportsWebDAV) return false;
|
||||||
|
if ((s.id === "calendar-view" || s.id === "create-event" || s.id === "event-modal") && !options.supportsCalendar) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
steps = [...steps, ...demoSteps];
|
||||||
|
}
|
||||||
|
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
@@ -2,15 +2,17 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { X, Lightbulb, Settings } from "lucide-react";
|
import { X, Lightbulb, Settings, PlayCircle } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
|
import { useTour } from "@/components/tour/tour-provider";
|
||||||
|
|
||||||
const ONBOARDING_KEY = "onboarding_completed";
|
const ONBOARDING_KEY = "onboarding_completed";
|
||||||
|
|
||||||
export function WelcomeBanner() {
|
export function WelcomeBanner() {
|
||||||
const t = useTranslations("welcome");
|
const t = useTranslations("welcome");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { startTour } = useTour();
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [dismissed, setDismissed] = useState(false);
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
@@ -78,6 +80,15 @@ export function WelcomeBanner() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2.5 flex justify-end gap-2">
|
<div className="mt-2.5 flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { dismiss(); startTour(); }}
|
||||||
|
className="text-xs h-7"
|
||||||
|
>
|
||||||
|
<PlayCircle className="w-3.5 h-3.5 mr-1" />
|
||||||
|
{t("start_tour")}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
+9
-5
@@ -28,15 +28,18 @@ export default [
|
|||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
"@typescript-eslint": tseslint,
|
"@typescript-eslint": tseslint,
|
||||||
"react": reactPlugin,
|
react: reactPlugin,
|
||||||
"react-hooks": reactHooksPlugin,
|
"react-hooks": reactHooksPlugin,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...tseslint.configs.recommended.rules,
|
...tseslint.configs.recommended.rules,
|
||||||
"@typescript-eslint/no-unused-vars": ["warn", {
|
"@typescript-eslint/no-unused-vars": [
|
||||||
argsIgnorePattern: "^_",
|
"warn",
|
||||||
varsIgnorePattern: "^_"
|
{
|
||||||
}],
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
"@typescript-eslint/no-explicit-any": "warn",
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
"@typescript-eslint/no-empty-object-type": "off",
|
"@typescript-eslint/no-empty-object-type": "off",
|
||||||
"react-hooks/rules-of-hooks": "error",
|
"react-hooks/rules-of-hooks": "error",
|
||||||
@@ -70,6 +73,7 @@ export default [
|
|||||||
"*.config.js",
|
"*.config.js",
|
||||||
"*.config.mjs",
|
"*.config.mjs",
|
||||||
"e2e/**",
|
"e2e/**",
|
||||||
|
"local-data/**/*.mjs",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface ConfigData {
|
|||||||
loginImprintUrl: string;
|
loginImprintUrl: string;
|
||||||
loginPrivacyPolicyUrl: string;
|
loginPrivacyPolicyUrl: string;
|
||||||
loginWebsiteUrl: string;
|
loginWebsiteUrl: string;
|
||||||
|
demoMode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppConfig extends ConfigData {
|
interface AppConfig extends ConfigData {
|
||||||
@@ -91,6 +92,7 @@ export function useConfig(): AppConfig {
|
|||||||
loginImprintUrl: configCache?.loginImprintUrl || '',
|
loginImprintUrl: configCache?.loginImprintUrl || '',
|
||||||
loginPrivacyPolicyUrl: configCache?.loginPrivacyPolicyUrl || '',
|
loginPrivacyPolicyUrl: configCache?.loginPrivacyPolicyUrl || '',
|
||||||
loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
|
loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
|
||||||
|
demoMode: configCache?.demoMode || false,
|
||||||
isLoading: !configCache,
|
isLoading: !configCache,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@@ -118,6 +120,7 @@ export function useConfig(): AppConfig {
|
|||||||
loginImprintUrl: configCache.loginImprintUrl,
|
loginImprintUrl: configCache.loginImprintUrl,
|
||||||
loginPrivacyPolicyUrl: configCache.loginPrivacyPolicyUrl,
|
loginPrivacyPolicyUrl: configCache.loginPrivacyPolicyUrl,
|
||||||
loginWebsiteUrl: configCache.loginWebsiteUrl,
|
loginWebsiteUrl: configCache.loginWebsiteUrl,
|
||||||
|
demoMode: configCache.demoMode,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@@ -146,6 +149,7 @@ export function useConfig(): AppConfig {
|
|||||||
loginImprintUrl: data.loginImprintUrl,
|
loginImprintUrl: data.loginImprintUrl,
|
||||||
loginPrivacyPolicyUrl: data.loginPrivacyPolicyUrl,
|
loginPrivacyPolicyUrl: data.loginPrivacyPolicyUrl,
|
||||||
loginWebsiteUrl: data.loginWebsiteUrl,
|
loginWebsiteUrl: data.loginWebsiteUrl,
|
||||||
|
demoMode: data.demoMode,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const loggerError = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('next/server', () => ({
|
||||||
|
NextResponse: {
|
||||||
|
json: (data: unknown, init?: { status?: number; headers?: unknown }) => ({
|
||||||
|
status: init?.status ?? 200,
|
||||||
|
headers: init?.headers,
|
||||||
|
json: async () => data,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/logger', () => ({
|
||||||
|
logger: {
|
||||||
|
error: loggerError,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('health route', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
loggerError.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns healthy for the basic liveness probe even when heap usage is high', async () => {
|
||||||
|
vi.spyOn(process, 'memoryUsage').mockReturnValue({
|
||||||
|
rss: 120_000_000,
|
||||||
|
heapTotal: 45_000_000,
|
||||||
|
heapUsed: 43_000_000,
|
||||||
|
external: 8_000_000,
|
||||||
|
arrayBuffers: 1_000_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { GET } = await import('@/app/api/health/route');
|
||||||
|
const response = await GET({ nextUrl: new URL('http://localhost/api/health') } as never);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
status: 'healthy',
|
||||||
|
});
|
||||||
|
expect(payload.warnings).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns degraded diagnostics in detailed mode without failing the probe', async () => {
|
||||||
|
vi.spyOn(process, 'memoryUsage').mockReturnValue({
|
||||||
|
rss: 120_000_000,
|
||||||
|
heapTotal: 4_100_000_000,
|
||||||
|
heapUsed: 4_000_000_000,
|
||||||
|
external: 8_000_000,
|
||||||
|
arrayBuffers: 1_000_000,
|
||||||
|
});
|
||||||
|
vi.spyOn(process, 'uptime').mockReturnValue(123.45);
|
||||||
|
|
||||||
|
const { GET } = await import('@/app/api/health/route');
|
||||||
|
const response = await GET({ nextUrl: new URL('http://localhost/api/health?detailed=true') } as never);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(payload.status).toBe('degraded');
|
||||||
|
expect(payload.memory).toMatchObject({
|
||||||
|
heapUsed: 4_000_000_000,
|
||||||
|
heapTotal: 4_100_000_000,
|
||||||
|
rss: 120_000_000,
|
||||||
|
external: 8_000_000,
|
||||||
|
});
|
||||||
|
expect(payload.memory.heapSizeLimit).toBeGreaterThan(0);
|
||||||
|
expect(payload.warnings).toEqual([
|
||||||
|
expect.stringContaining('V8 heap usage is high'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps HEAD as a stable liveness probe', async () => {
|
||||||
|
const { HEAD } = await import('@/app/api/health/route');
|
||||||
|
const response = await HEAD();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 503 when collecting health diagnostics throws', async () => {
|
||||||
|
vi.spyOn(process, 'memoryUsage').mockImplementation(() => {
|
||||||
|
throw new Error('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
const { GET } = await import('@/app/api/health/route');
|
||||||
|
const response = await GET({ nextUrl: new URL('http://localhost/api/health') } as never);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
status: 'unhealthy',
|
||||||
|
reason: 'boom',
|
||||||
|
});
|
||||||
|
expect(loggerError).toHaveBeenCalledWith('Health check failed', { error: 'boom' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
appendPlainTextSignature,
|
||||||
|
getPlainTextSignature,
|
||||||
|
hasMeaningfulHtmlBody,
|
||||||
|
} from '../signature-utils';
|
||||||
|
|
||||||
|
describe('signature-utils', () => {
|
||||||
|
describe('getPlainTextSignature', () => {
|
||||||
|
it('prefers text signatures when present', () => {
|
||||||
|
expect(getPlainTextSignature({ textSignature: 'Regards,\nAlice', htmlSignature: '<p>Ignored</p>' })).toBe('Regards,\nAlice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts html-only signatures into plain text', () => {
|
||||||
|
expect(getPlainTextSignature({ htmlSignature: '<p>Alice Example<br><a href="mailto:alice@example.com">alice@example.com</a></p>' })).toBe('Alice Example\nalice@example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('appendPlainTextSignature', () => {
|
||||||
|
it('appends a converted html signature to the text body', () => {
|
||||||
|
expect(appendPlainTextSignature('Hello there', { htmlSignature: '<p>Alice<br>Engineering</p>' })).toBe('Hello there\n\n-- \nAlice\nEngineering');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the body untouched when no signature exists', () => {
|
||||||
|
expect(appendPlainTextSignature('Hello there', {})).toBe('Hello there');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasMeaningfulHtmlBody', () => {
|
||||||
|
it('prefers html bodies that preserve signature formatting', () => {
|
||||||
|
expect(hasMeaningfulHtmlBody('<div>Hello</div><br><p>Alice</p>')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores minimal wrapper html with a single block', () => {
|
||||||
|
expect(hasMeaningfulHtmlBody('<div>Hello world</div>')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Manages per-account state snapshots for fast switching.
|
||||||
|
* When user switches from Account A → B, we snapshot A's store state
|
||||||
|
* into memory, clear stores, then restore B's cached state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
|
import { useContactStore } from '@/stores/contact-store';
|
||||||
|
import { useCalendarStore } from '@/stores/calendar-store';
|
||||||
|
import { useFilterStore } from '@/stores/filter-store';
|
||||||
|
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||||
|
import { useIdentityStore } from '@/stores/identity-store';
|
||||||
|
import { useVacationStore } from '@/stores/vacation-store';
|
||||||
|
|
||||||
|
// Minimal snapshot shapes — we only capture what we need
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type StoreSnapshot = Record<string, any>;
|
||||||
|
|
||||||
|
interface AccountSnapshot {
|
||||||
|
email: StoreSnapshot;
|
||||||
|
contact: StoreSnapshot;
|
||||||
|
calendar: StoreSnapshot;
|
||||||
|
filter: StoreSnapshot;
|
||||||
|
identity: StoreSnapshot;
|
||||||
|
vacation: StoreSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<string, AccountSnapshot>();
|
||||||
|
|
||||||
|
/** Capture current store states for the given account */
|
||||||
|
export function snapshotAccount(accountId: string): void {
|
||||||
|
const emailState = useEmailStore.getState();
|
||||||
|
const contactState = useContactStore.getState();
|
||||||
|
const calendarState = useCalendarStore.getState();
|
||||||
|
const filterState = useFilterStore.getState();
|
||||||
|
const identityState = useIdentityStore.getState();
|
||||||
|
const vacationState = useVacationStore.getState();
|
||||||
|
|
||||||
|
cache.set(accountId, {
|
||||||
|
email: {
|
||||||
|
emails: emailState.emails,
|
||||||
|
mailboxes: emailState.mailboxes,
|
||||||
|
selectedEmail: emailState.selectedEmail,
|
||||||
|
selectedMailbox: emailState.selectedMailbox,
|
||||||
|
searchQuery: emailState.searchQuery,
|
||||||
|
quota: emailState.quota,
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
contacts: contactState.contacts,
|
||||||
|
addressBooks: contactState.addressBooks,
|
||||||
|
supportsSync: contactState.supportsSync,
|
||||||
|
},
|
||||||
|
calendar: {
|
||||||
|
calendars: calendarState.calendars,
|
||||||
|
events: calendarState.events,
|
||||||
|
selectedCalendarIds: calendarState.selectedCalendarIds,
|
||||||
|
viewMode: calendarState.viewMode,
|
||||||
|
supportsCalendar: calendarState.supportsCalendar,
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
rules: filterState.rules,
|
||||||
|
isSupported: filterState.isSupported,
|
||||||
|
},
|
||||||
|
identity: {
|
||||||
|
identities: identityState.identities,
|
||||||
|
preferredPrimaryId: identityState.preferredPrimaryId,
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
isEnabled: vacationState.isEnabled,
|
||||||
|
isSupported: vacationState.isSupported,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore cached store states for the given account. Returns false if no cache exists. */
|
||||||
|
export function restoreAccount(accountId: string): boolean {
|
||||||
|
const snapshot = cache.get(accountId);
|
||||||
|
if (!snapshot) return false;
|
||||||
|
|
||||||
|
useEmailStore.setState(snapshot.email);
|
||||||
|
useContactStore.setState(snapshot.contact);
|
||||||
|
useCalendarStore.setState(snapshot.calendar);
|
||||||
|
useFilterStore.setState(snapshot.filter);
|
||||||
|
useIdentityStore.setState(snapshot.identity);
|
||||||
|
useVacationStore.setState(snapshot.vacation);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear all stores (used before restoring a different account) */
|
||||||
|
export function clearAllStores(): void {
|
||||||
|
useEmailStore.setState({
|
||||||
|
emails: [],
|
||||||
|
mailboxes: [],
|
||||||
|
selectedEmail: null,
|
||||||
|
selectedMailbox: '',
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
searchQuery: '',
|
||||||
|
quota: null,
|
||||||
|
isPushConnected: false,
|
||||||
|
lastPushUpdate: null,
|
||||||
|
newEmailNotification: null,
|
||||||
|
selectedEmailIds: new Set<string>(),
|
||||||
|
hasMoreEmails: false,
|
||||||
|
totalEmails: 0,
|
||||||
|
expandedThreadIds: new Set<string>(),
|
||||||
|
threadEmailsCache: new Map(),
|
||||||
|
isLoadingThread: null,
|
||||||
|
selectedKeyword: null,
|
||||||
|
tagCounts: {},
|
||||||
|
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
||||||
|
isAdvancedSearchOpen: false,
|
||||||
|
});
|
||||||
|
useIdentityStore.getState().clearIdentities();
|
||||||
|
useContactStore.getState().clearContacts();
|
||||||
|
useVacationStore.getState().clearState();
|
||||||
|
useCalendarStore.getState().clearState();
|
||||||
|
useFilterStore.getState().clearState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evict cached state for one account */
|
||||||
|
export function evictAccount(accountId: string): void {
|
||||||
|
cache.delete(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evict all cached states */
|
||||||
|
export function evictAll(): void {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Utilities for multi-account support:
|
||||||
|
* - Account ID generation
|
||||||
|
* - Deterministic avatar colors
|
||||||
|
* - Account-scoped localStorage keys
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Generate a unique, deterministic account ID from username and server URL */
|
||||||
|
export function generateAccountId(username: string, serverUrl: string): string {
|
||||||
|
const host = new URL(serverUrl).hostname;
|
||||||
|
return `${username}@${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic avatar/accent color from an email string */
|
||||||
|
export function generateAvatarColor(email: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < email.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash + email.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
// 12 distinct, accessible hues
|
||||||
|
const colors = [
|
||||||
|
'#2563eb', // blue
|
||||||
|
'#7c3aed', // violet
|
||||||
|
'#db2777', // pink
|
||||||
|
'#dc2626', // red
|
||||||
|
'#ea580c', // orange
|
||||||
|
'#d97706', // amber
|
||||||
|
'#65a30d', // lime
|
||||||
|
'#16a34a', // green
|
||||||
|
'#0d9488', // teal
|
||||||
|
'#0891b2', // cyan
|
||||||
|
'#6366f1', // indigo
|
||||||
|
'#9333ea', // purple
|
||||||
|
];
|
||||||
|
return colors[Math.abs(hash) % colors.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get initials for an avatar from a display name or email */
|
||||||
|
export function getInitials(name: string, email?: string): string {
|
||||||
|
if (name) {
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
return parts[0][0]?.toUpperCase() ?? '?';
|
||||||
|
}
|
||||||
|
if (email) {
|
||||||
|
return email[0]?.toUpperCase() ?? '?';
|
||||||
|
}
|
||||||
|
return '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build an account-scoped localStorage key */
|
||||||
|
export function getAccountScopedKey(baseKey: string, accountId: string): string {
|
||||||
|
return `${baseKey}::${accountId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maximum number of accounts allowed */
|
||||||
|
export const MAX_ACCOUNTS = 5;
|
||||||
@@ -1,2 +1,7 @@
|
|||||||
export const SESSION_COOKIE = 'jmap_session';
|
export const SESSION_COOKIE = 'jmap_session';
|
||||||
export const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
|
export const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
|
||||||
|
export function sessionCookieName(slot: number): string {
|
||||||
|
return slot === 0 ? SESSION_COOKIE : `${SESSION_COOKIE}_${slot}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function replaceWindowLocation(url: string): void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.replace(url);
|
||||||
|
}
|
||||||
@@ -142,3 +142,7 @@ export function formatSnapTime(minutes: number, timeFormat: "12h" | "24h"): stri
|
|||||||
}
|
}
|
||||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPrimaryCalendarId(event: Pick<CalendarEvent, 'calendarIds'>): string | undefined {
|
||||||
|
return Object.keys(event.calendarIds || {})[0];
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,812 @@
|
|||||||
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode } from '@/lib/jmap/types';
|
||||||
|
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||||
|
import { getDemoData, type DemoData } from './demo-data';
|
||||||
|
import { generateDemoId } from './demo-utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory JMAP client for demo mode.
|
||||||
|
* All data lives in memory — no network calls, no cookies.
|
||||||
|
*/
|
||||||
|
export class DemoJMAPClient implements IJMAPClient {
|
||||||
|
private data: DemoData;
|
||||||
|
private blobStore = new Map<string, Blob>();
|
||||||
|
private connectionCallback: ((connected: boolean) => void) | null = null;
|
||||||
|
private stateChangeCallback: ((change: StateChange) => void) | null = null;
|
||||||
|
private lastStates: AccountStates = {};
|
||||||
|
private incomingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.data = getDemoData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connection lifecycle ──────────────────────────────────────
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
// Start simulated incoming email timer
|
||||||
|
this.startIncomingEmailTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.stopIncomingEmailTimer();
|
||||||
|
this.connectionCallback = null;
|
||||||
|
this.stateChangeCallback = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconnect(): Promise<void> { /* no-op */ }
|
||||||
|
async ping(): Promise<void> { /* no-op */ }
|
||||||
|
|
||||||
|
// ── Session / auth accessors ──────────────────────────────────
|
||||||
|
|
||||||
|
getServerUrl(): string { return 'https://demo.example.com'; }
|
||||||
|
getAuthHeader(): string { return 'Bearer demo-token'; }
|
||||||
|
updateAccessToken(): void { /* no-op */ }
|
||||||
|
getAccountId(): string { return 'demo-account'; }
|
||||||
|
getUsername(): string { return 'demo@example.com'; }
|
||||||
|
|
||||||
|
// ── Capabilities ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
getCapabilities(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
|
||||||
|
'urn:ietf:params:jmap:mail': {},
|
||||||
|
'urn:ietf:params:jmap:submission': {},
|
||||||
|
'urn:ietf:params:jmap:vacationresponse': {},
|
||||||
|
'urn:ietf:params:jmap:contacts': {},
|
||||||
|
'urn:ietf:params:jmap:calendars': {},
|
||||||
|
'urn:ietf:params:jmap:sieve': {},
|
||||||
|
'urn:ietf:params:jmap:quota': {},
|
||||||
|
'urn:ietf:params:jmap:files': {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getMaxSizeUpload(): number { return 50_000_000; }
|
||||||
|
getMaxCallsInRequest(): number { return 16; }
|
||||||
|
getMaxObjectsInGet(): number { return 500; }
|
||||||
|
getEventSourceUrl(): string | null { return null; }
|
||||||
|
supportsEmailSubmission(): boolean { return true; }
|
||||||
|
supportsQuota(): boolean { return true; }
|
||||||
|
supportsVacationResponse(): boolean { return true; }
|
||||||
|
supportsContacts(): boolean { return true; }
|
||||||
|
supportsCalendars(): boolean { return true; }
|
||||||
|
supportsSieve(): boolean { return true; }
|
||||||
|
supportsFiles(): boolean { return true; }
|
||||||
|
|
||||||
|
// ── Push / state ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
setupPushNotifications(): boolean { return true; }
|
||||||
|
closePushNotifications(): void { /* no-op in demo */ }
|
||||||
|
onConnectionChange(callback: (connected: boolean) => void): void { this.connectionCallback = callback; }
|
||||||
|
onStateChange(callback: (change: StateChange) => void): void { this.stateChangeCallback = callback; }
|
||||||
|
getLastStates(): AccountStates { return { ...this.lastStates }; }
|
||||||
|
setLastStates(states: AccountStates): void { this.lastStates = { ...states }; }
|
||||||
|
|
||||||
|
// ── Quota ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getQuota(): Promise<{ used: number; total: number } | null> {
|
||||||
|
return { used: 245_366_784, total: 1_073_741_824 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mailboxes ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
|
||||||
|
async getAllMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
|
||||||
|
|
||||||
|
async createMailbox(name: string, parentId?: string): Promise<Mailbox> {
|
||||||
|
const mb: Mailbox = {
|
||||||
|
id: generateDemoId('mailbox'),
|
||||||
|
name,
|
||||||
|
sortOrder: 100,
|
||||||
|
totalEmails: 0,
|
||||||
|
unreadEmails: 0,
|
||||||
|
totalThreads: 0,
|
||||||
|
unreadThreads: 0,
|
||||||
|
parentId,
|
||||||
|
isSubscribed: true,
|
||||||
|
myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true },
|
||||||
|
};
|
||||||
|
this.data.mailboxes.push(mb);
|
||||||
|
return mb;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void> {
|
||||||
|
const mb = this.data.mailboxes.find(m => m.id === mailboxId);
|
||||||
|
if (mb) Object.assign(mb, changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMailbox(mailboxId: string): Promise<void> {
|
||||||
|
this.data.mailboxes = this.data.mailboxes.filter(m => m.id !== mailboxId);
|
||||||
|
// Also remove emails in this mailbox
|
||||||
|
this.data.emails = this.data.emails.filter(e => !e.mailboxIds[mailboxId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Emails ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getEmails(mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
|
||||||
|
let filtered = this.data.emails;
|
||||||
|
if (mailboxId) {
|
||||||
|
filtered = filtered.filter(e => e.mailboxIds[mailboxId]);
|
||||||
|
}
|
||||||
|
filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
|
||||||
|
const total = filtered.length;
|
||||||
|
const emails = filtered.slice(position, position + limit);
|
||||||
|
return { emails, hasMore: position + limit < total, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEmailsInMailbox(mailboxId: string): Promise<Email[]> {
|
||||||
|
return this.data.emails.filter(e => e.mailboxIds[mailboxId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEmail(emailId: string): Promise<Email | null> {
|
||||||
|
return this.data.emails.find(e => e.id === emailId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>> {
|
||||||
|
const result: Record<string, { total: number; unread: number }> = {};
|
||||||
|
for (const tagId of tagIds) {
|
||||||
|
const tagged = this.data.emails.filter(e => e.keywords[tagId]);
|
||||||
|
result[tagId] = {
|
||||||
|
total: tagged.length,
|
||||||
|
unread: tagged.filter(e => !e.keywords.$seen).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchEmails(query: string, mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
let filtered = this.data.emails.filter(e => {
|
||||||
|
const text = [e.subject, e.preview, e.from?.[0]?.name, e.from?.[0]?.email].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
return text.includes(q);
|
||||||
|
});
|
||||||
|
if (mailboxId) filtered = filtered.filter(e => e.mailboxIds[mailboxId]);
|
||||||
|
filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
|
||||||
|
const total = filtered.length;
|
||||||
|
const emails = filtered.slice(position, position + limit);
|
||||||
|
return { emails, hasMore: position + limit < total, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async advancedSearchEmails(filter: Record<string, unknown>, _accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
|
||||||
|
// Simplified: just return all emails for any advanced filter
|
||||||
|
let filtered = [...this.data.emails];
|
||||||
|
if (filter.inMailbox) filtered = filtered.filter(e => e.mailboxIds[filter.inMailbox as string]);
|
||||||
|
if (filter.text) {
|
||||||
|
const q = (filter.text as string).toLowerCase();
|
||||||
|
filtered = filtered.filter(e => [e.subject, e.preview].filter(Boolean).join(' ').toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
|
||||||
|
const total = filtered.length;
|
||||||
|
const emails = filtered.slice(position, position + limit);
|
||||||
|
return { emails, hasMore: position + limit < total, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Email mutations ───────────────────────────────────────────
|
||||||
|
|
||||||
|
async markAsRead(emailId: string, read: boolean = true): Promise<void> {
|
||||||
|
const email = this.data.emails.find(e => e.id === emailId);
|
||||||
|
if (!email) return;
|
||||||
|
if (read) {
|
||||||
|
email.keywords.$seen = true;
|
||||||
|
} else {
|
||||||
|
delete email.keywords.$seen;
|
||||||
|
}
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchMarkAsRead(emailIds: string[], read: boolean = true): Promise<void> {
|
||||||
|
for (const id of emailIds) {
|
||||||
|
const email = this.data.emails.find(e => e.id === id);
|
||||||
|
if (email) {
|
||||||
|
if (read) email.keywords.$seen = true;
|
||||||
|
else delete email.keywords.$seen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleStar(emailId: string, starred: boolean): Promise<void> {
|
||||||
|
const email = this.data.emails.find(e => e.id === emailId);
|
||||||
|
if (!email) return;
|
||||||
|
if (starred) email.keywords.$flagged = true;
|
||||||
|
else delete email.keywords.$flagged;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void> {
|
||||||
|
const email = this.data.emails.find(e => e.id === emailId);
|
||||||
|
if (email) email.keywords = { ...email.keywords, ...keywords };
|
||||||
|
}
|
||||||
|
|
||||||
|
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number> {
|
||||||
|
let count = 0;
|
||||||
|
for (const email of this.data.emails) {
|
||||||
|
if (email.keywords[oldKeyword]) {
|
||||||
|
delete email.keywords[oldKeyword];
|
||||||
|
email.keywords[newKeyword] = true;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEmail(emailId: string): Promise<void> {
|
||||||
|
this.data.emails = this.data.emails.filter(e => e.id !== emailId);
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveToTrash(emailId: string, trashMailboxId: string): Promise<void> {
|
||||||
|
const email = this.data.emails.find(e => e.id === emailId);
|
||||||
|
if (!email) return;
|
||||||
|
email.mailboxIds = { [trashMailboxId]: true };
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchDeleteEmails(emailIds: string[]): Promise<void> {
|
||||||
|
const idSet = new Set(emailIds);
|
||||||
|
this.data.emails = this.data.emails.filter(e => !idSet.has(e.id));
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchMoveEmails(emailIds: string[], toMailboxId: string): Promise<void> {
|
||||||
|
for (const id of emailIds) {
|
||||||
|
const email = this.data.emails.find(e => e.id === id);
|
||||||
|
if (email) email.mailboxIds = { [toMailboxId]: true };
|
||||||
|
}
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveEmail(emailId: string, toMailboxId: string): Promise<void> {
|
||||||
|
const email = this.data.emails.find(e => e.id === emailId);
|
||||||
|
if (email) email.mailboxIds = { [toMailboxId]: true };
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async emptyMailbox(mailboxId: string): Promise<number> {
|
||||||
|
const before = this.data.emails.length;
|
||||||
|
this.data.emails = this.data.emails.filter(e => !e.mailboxIds[mailboxId]);
|
||||||
|
const removed = before - this.data.emails.length;
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAsSpam(emailId: string): Promise<void> {
|
||||||
|
const email = this.data.emails.find(e => e.id === emailId);
|
||||||
|
const junkMb = this.data.mailboxes.find(m => m.role === 'junk');
|
||||||
|
if (email && junkMb) email.mailboxIds = { [junkMb.id]: true };
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async undoSpam(emailId: string, originalMailboxId: string): Promise<void> {
|
||||||
|
const email = this.data.emails.find(e => e.id === emailId);
|
||||||
|
if (email) email.mailboxIds = { [originalMailboxId]: true };
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Threads ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getThread(threadId: string): Promise<Thread | null> {
|
||||||
|
const emails = this.data.emails.filter(e => e.threadId === threadId);
|
||||||
|
if (emails.length === 0) return null;
|
||||||
|
return { id: threadId, emailIds: emails.map(e => e.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getThreadEmails(threadId: string): Promise<Email[]> {
|
||||||
|
return this.data.emails
|
||||||
|
.filter(e => e.threadId === threadId)
|
||||||
|
.sort((a, b) => new Date(a.receivedAt).getTime() - new Date(b.receivedAt).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Compose / Send ────────────────────────────────────────────
|
||||||
|
|
||||||
|
async createDraft(
|
||||||
|
to: string[],
|
||||||
|
subject: string,
|
||||||
|
body: string,
|
||||||
|
cc?: string[],
|
||||||
|
bcc?: string[],
|
||||||
|
_identityId?: string,
|
||||||
|
_fromEmail?: string,
|
||||||
|
draftId?: string,
|
||||||
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
|
||||||
|
_fromName?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const draftsMb = this.data.mailboxes.find(m => m.role === 'drafts');
|
||||||
|
const id = draftId || generateDemoId('email');
|
||||||
|
const existing = draftId ? this.data.emails.findIndex(e => e.id === draftId) : -1;
|
||||||
|
|
||||||
|
const email: Email = {
|
||||||
|
id, threadId: generateDemoId('thread'),
|
||||||
|
mailboxIds: { [draftsMb?.id || 'demo-mailbox-drafts']: true },
|
||||||
|
keywords: { $seen: true, $draft: true },
|
||||||
|
size: body.length,
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
to: to.map(e => ({ email: e })),
|
||||||
|
cc: cc?.map(e => ({ email: e })),
|
||||||
|
bcc: bcc?.map(e => ({ email: e })),
|
||||||
|
subject,
|
||||||
|
sentAt: new Date().toISOString(),
|
||||||
|
preview: body.substring(0, 200),
|
||||||
|
hasAttachment: !!attachments?.length,
|
||||||
|
textBody: [{ partId: '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }],
|
||||||
|
htmlBody: [],
|
||||||
|
bodyValues: { '1': { value: body } },
|
||||||
|
attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })),
|
||||||
|
messageId: `<${id}@demo.example.com>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing >= 0) {
|
||||||
|
this.data.emails[existing] = email;
|
||||||
|
} else {
|
||||||
|
this.data.emails.push(email);
|
||||||
|
}
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendEmail(
|
||||||
|
to: string[],
|
||||||
|
subject: string,
|
||||||
|
body: string,
|
||||||
|
cc?: string[],
|
||||||
|
bcc?: string[],
|
||||||
|
_identityId?: string,
|
||||||
|
_fromEmail?: string,
|
||||||
|
draftId?: string,
|
||||||
|
_fromName?: string,
|
||||||
|
htmlBody?: string,
|
||||||
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
|
||||||
|
): Promise<void> {
|
||||||
|
// Remove draft if updating
|
||||||
|
if (draftId) {
|
||||||
|
this.data.emails = this.data.emails.filter(e => e.id !== draftId);
|
||||||
|
}
|
||||||
|
const sentMb = this.data.mailboxes.find(m => m.role === 'sent');
|
||||||
|
const email: Email = {
|
||||||
|
id: generateDemoId('email'), threadId: generateDemoId('thread'),
|
||||||
|
mailboxIds: { [sentMb?.id || 'demo-mailbox-sent']: true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: body.length + (htmlBody?.length || 0),
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
to: to.map(e => ({ email: e })),
|
||||||
|
cc: cc?.map(e => ({ email: e })),
|
||||||
|
bcc: bcc?.map(e => ({ email: e })),
|
||||||
|
subject,
|
||||||
|
sentAt: new Date().toISOString(),
|
||||||
|
preview: body.substring(0, 200),
|
||||||
|
hasAttachment: !!attachments?.length,
|
||||||
|
textBody: [{ partId: '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }],
|
||||||
|
htmlBody: htmlBody ? [{ partId: '2', blobId: generateDemoId('blob'), size: htmlBody.length, type: 'text/html' }] : [],
|
||||||
|
bodyValues: htmlBody ? { '1': { value: body }, '2': { value: htmlBody } } : { '1': { value: body } },
|
||||||
|
attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })),
|
||||||
|
messageId: `<${generateDemoId('msg')}@demo.example.com>`,
|
||||||
|
};
|
||||||
|
this.data.emails.push(email);
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendImipReply(): Promise<void> { /* no-op in demo */ }
|
||||||
|
async sendImipInvitation(): Promise<void> { /* no-op in demo */ }
|
||||||
|
async sendImipCancellation(): Promise<void> { /* no-op in demo */ }
|
||||||
|
|
||||||
|
// ── Blobs ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> {
|
||||||
|
const blobId = generateDemoId('blob');
|
||||||
|
this.blobStore.set(blobId, file);
|
||||||
|
return { blobId, size: file.size, type: file.type };
|
||||||
|
}
|
||||||
|
|
||||||
|
getBlobDownloadUrl(blobId: string): string {
|
||||||
|
return `data:application/octet-stream;demo-blob=${blobId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchBlob(blobId: string): Promise<Blob> {
|
||||||
|
return this.blobStore.get(blobId) ?? new Blob(['[Demo placeholder content]'], { type: 'text/plain' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchBlobAsObjectUrl(blobId: string): Promise<string> {
|
||||||
|
const blob = await this.fetchBlob(blobId);
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchBlobArrayBuffer(blobId: string): Promise<ArrayBuffer> {
|
||||||
|
const blob = await this.fetchBlob(blobId);
|
||||||
|
return blob.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadBlob(blobId: string, name?: string): Promise<void> {
|
||||||
|
const blob = await this.fetchBlob(blobId);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = name || 'download';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Identities ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getIdentities(): Promise<Identity[]> { return [...this.data.identities]; }
|
||||||
|
|
||||||
|
async createIdentity(
|
||||||
|
name: string, email: string,
|
||||||
|
replyTo?: EmailAddress[] | null, bcc?: EmailAddress[] | null,
|
||||||
|
htmlSignature?: string, textSignature?: string,
|
||||||
|
): Promise<Identity> {
|
||||||
|
const identity: Identity = {
|
||||||
|
id: generateDemoId('identity'), name, email,
|
||||||
|
replyTo: replyTo ?? undefined, bcc: bcc ?? undefined,
|
||||||
|
htmlSignature: htmlSignature ?? '', textSignature: textSignature ?? '',
|
||||||
|
mayDelete: true,
|
||||||
|
};
|
||||||
|
this.data.identities.push(identity);
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateIdentity(identityId: string, updates: { name?: string; replyTo?: EmailAddress[] | null; bcc?: EmailAddress[] | null; htmlSignature?: string; textSignature?: string }): Promise<void> {
|
||||||
|
const identity = this.data.identities.find(i => i.id === identityId);
|
||||||
|
if (identity) Object.assign(identity, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIdentity(identityId: string): Promise<void> {
|
||||||
|
this.data.identities = this.data.identities.filter(i => i.id !== identityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vacation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getVacationResponse(): Promise<VacationResponse> { return { ...this.data.vacationResponse }; }
|
||||||
|
|
||||||
|
async setVacationResponse(updates: Partial<VacationResponse>): Promise<void> {
|
||||||
|
Object.assign(this.data.vacationResponse, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contacts ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
getContactsAccountId(): string { return 'demo-account'; }
|
||||||
|
|
||||||
|
async getAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
|
||||||
|
async getAllAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
|
||||||
|
|
||||||
|
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
||||||
|
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
|
||||||
|
return [...this.data.contacts];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllContacts(): Promise<ContactCard[]> { return [...this.data.contacts]; }
|
||||||
|
|
||||||
|
async getContact(contactId: string): Promise<ContactCard | null> {
|
||||||
|
return this.data.contacts.find(c => c.id === contactId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContact(contact: Partial<ContactCard>): Promise<ContactCard> {
|
||||||
|
const full: ContactCard = {
|
||||||
|
id: generateDemoId('contact'),
|
||||||
|
addressBookIds: contact.addressBookIds ?? { 'demo-addressbook-personal': true },
|
||||||
|
...contact,
|
||||||
|
} as ContactCard;
|
||||||
|
this.data.contacts.push(full);
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateContact(contactId: string, updates: Partial<ContactCard>): Promise<void> {
|
||||||
|
const contact = this.data.contacts.find(c => c.id === contactId);
|
||||||
|
if (contact) Object.assign(contact, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteContact(contactId: string): Promise<void> {
|
||||||
|
this.data.contacts = this.data.contacts.filter(c => c.id !== contactId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchContacts(query: string): Promise<ContactCard[]> {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return this.data.contacts.filter(c => {
|
||||||
|
const nameStr = c.name?.components?.map(nc => nc.value).join(' ').toLowerCase() ?? '';
|
||||||
|
const emailStr = Object.values(c.emails ?? {}).map(e => e.address).join(' ').toLowerCase();
|
||||||
|
return nameStr.includes(q) || emailStr.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Calendars ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
getCalendarsAccountId(): string { return 'demo-account'; }
|
||||||
|
|
||||||
|
async getCalendars(): Promise<Calendar[]> { return [...this.data.calendars]; }
|
||||||
|
async getAllCalendars(): Promise<Calendar[]> { return [...this.data.calendars]; }
|
||||||
|
|
||||||
|
async createCalendar(calendar: Partial<Calendar>): Promise<Calendar> {
|
||||||
|
const full: Calendar = {
|
||||||
|
id: generateDemoId('calendar'),
|
||||||
|
name: calendar.name ?? 'New Calendar',
|
||||||
|
description: calendar.description ?? null,
|
||||||
|
color: calendar.color ?? '#6366f1',
|
||||||
|
sortOrder: calendar.sortOrder ?? 99,
|
||||||
|
isSubscribed: true, isVisible: true, isDefault: false,
|
||||||
|
includeInAvailability: 'all',
|
||||||
|
defaultAlertsWithTime: null, defaultAlertsWithoutTime: null,
|
||||||
|
timeZone: null, shareWith: null,
|
||||||
|
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
||||||
|
...calendar,
|
||||||
|
} as Calendar;
|
||||||
|
this.data.calendars.push(full);
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCalendar(calendarId: string, updates: Partial<Calendar>): Promise<void> {
|
||||||
|
const cal = this.data.calendars.find(c => c.id === calendarId);
|
||||||
|
if (cal) Object.assign(cal, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCalendar(calendarId: string): Promise<void> {
|
||||||
|
this.data.calendars = this.data.calendars.filter(c => c.id !== calendarId);
|
||||||
|
this.data.calendarEvents = this.data.calendarEvents.filter(e => !e.calendarIds[calendarId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCalendarEvents(calendarIds?: string[]): Promise<CalendarEvent[]> {
|
||||||
|
let events = [...this.data.calendarEvents];
|
||||||
|
if (calendarIds?.length) {
|
||||||
|
events = events.filter(e => calendarIds.some(cid => e.calendarIds[cid]));
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCalendarEvent(id: string): Promise<CalendarEvent | null> {
|
||||||
|
return this.data.calendarEvents.find(e => e.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCalendarEvent(event: Partial<CalendarEvent>): Promise<CalendarEvent> {
|
||||||
|
const full: CalendarEvent = {
|
||||||
|
id: generateDemoId('event'),
|
||||||
|
calendarIds: event.calendarIds ?? { 'demo-calendar-personal': true },
|
||||||
|
'@type': 'Event',
|
||||||
|
uid: generateDemoId('uid'),
|
||||||
|
title: event.title ?? 'New Event',
|
||||||
|
description: event.description ?? '',
|
||||||
|
descriptionContentType: 'text/plain',
|
||||||
|
isDraft: false, isOrigin: true,
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
updated: new Date().toISOString(),
|
||||||
|
sequence: 0,
|
||||||
|
start: event.start ?? new Date().toISOString(),
|
||||||
|
duration: event.duration ?? 'PT1H',
|
||||||
|
timeZone: event.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
utcStart: event.utcStart ?? null,
|
||||||
|
utcEnd: event.utcEnd ?? null,
|
||||||
|
showWithoutTime: event.showWithoutTime ?? false,
|
||||||
|
status: 'confirmed', freeBusyStatus: 'busy', 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: true, alerts: null, locations: null,
|
||||||
|
virtualLocations: null, links: null, relatedTo: null,
|
||||||
|
...event,
|
||||||
|
} as CalendarEvent;
|
||||||
|
this.data.calendarEvents.push(full);
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCalendarEvent(eventId: string, updates: Partial<CalendarEvent>): Promise<void> {
|
||||||
|
const event = this.data.calendarEvents.find(e => e.id === eventId);
|
||||||
|
if (!event) throw new Error('Event not found');
|
||||||
|
Object.assign(event, updates, { updated: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCalendarEvent(eventId: string): Promise<void> {
|
||||||
|
this.data.calendarEvents = this.data.calendarEvents.filter(e => e.id !== eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchDeleteCalendarEvents(eventIds: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
||||||
|
const idSet = new Set(eventIds);
|
||||||
|
this.data.calendarEvents = this.data.calendarEvents.filter(e => !idSet.has(e.id));
|
||||||
|
return { destroyed: eventIds, notDestroyed: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryCalendarEvents(filter: CalendarEventFilter): Promise<CalendarEvent[]> {
|
||||||
|
return this.data.calendarEvents.filter(e => {
|
||||||
|
if (filter.after && e.start < filter.after) return false;
|
||||||
|
if (filter.before && e.start > filter.before) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryAllCalendarEvents(filter: CalendarEventFilter): Promise<CalendarEvent[]> {
|
||||||
|
return this.queryCalendarEvents(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseCalendarEvents(): Promise<Partial<CalendarEvent>[]> {
|
||||||
|
return []; // no-op in demo
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sieve / Filters ──────────────────────────────────────────
|
||||||
|
|
||||||
|
getSieveAccountId(): string { return 'demo-account'; }
|
||||||
|
|
||||||
|
getSieveCapabilities(): SieveCapabilities | null {
|
||||||
|
return { ...this.data.sieveCapabilities };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSieveScripts(): Promise<SieveScript[]> { return [...this.data.sieveScripts]; }
|
||||||
|
|
||||||
|
async getSieveScriptContent(blobId: string): Promise<string> {
|
||||||
|
return this.data.sieveContent[blobId] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript> {
|
||||||
|
const blobId = generateDemoId('sieve-blob');
|
||||||
|
const script: SieveScript = { id: generateDemoId('sieve'), name, blobId, isActive: activate ?? false };
|
||||||
|
this.data.sieveScripts.push(script);
|
||||||
|
this.data.sieveContent[blobId] = content;
|
||||||
|
if (activate) {
|
||||||
|
for (const s of this.data.sieveScripts) {
|
||||||
|
if (s.id !== script.id) s.isActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return script;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void> {
|
||||||
|
const script = this.data.sieveScripts.find(s => s.id === scriptId);
|
||||||
|
if (!script) return;
|
||||||
|
const blobId = generateDemoId('sieve-blob');
|
||||||
|
this.data.sieveContent[blobId] = content;
|
||||||
|
script.blobId = blobId;
|
||||||
|
if (activate !== undefined) {
|
||||||
|
script.isActive = activate;
|
||||||
|
if (activate) {
|
||||||
|
for (const s of this.data.sieveScripts) {
|
||||||
|
if (s.id !== scriptId) s.isActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSieveScript(scriptId: string): Promise<void> {
|
||||||
|
this.data.sieveScripts = this.data.sieveScripts.filter(s => s.id !== scriptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateSieveScript(): Promise<{ isValid: boolean; errors?: string[] }> {
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Files (FileNode) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
getFilesAccountId(): string { return 'demo-account'; }
|
||||||
|
|
||||||
|
async probeFileNodeSupport(): Promise<boolean> { return true; }
|
||||||
|
|
||||||
|
async listFileNodes(parentId: string | null): Promise<FileNode[]> {
|
||||||
|
return this.data.fileNodes.filter(n => n.parentId === parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFileNodes(ids: string[] | null): Promise<FileNode[]> {
|
||||||
|
if (ids === null) return [...this.data.fileNodes];
|
||||||
|
return this.data.fileNodes.filter(n => ids.includes(n.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> {
|
||||||
|
const node: FileNode = {
|
||||||
|
id: generateDemoId('file'),
|
||||||
|
parentId, name, type: 'd', blobId: null, size: 0,
|
||||||
|
created: new Date().toISOString(), updated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.data.fileNodes.push(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode> {
|
||||||
|
const node: FileNode = {
|
||||||
|
id: generateDemoId('file'),
|
||||||
|
parentId, name, type, blobId, size,
|
||||||
|
created: new Date().toISOString(), updated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.data.fileNodes.push(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void> {
|
||||||
|
const node = this.data.fileNodes.find(n => n.id === id);
|
||||||
|
if (node) Object.assign(node, updates, { updated: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
||||||
|
const idSet = new Set(ids);
|
||||||
|
this.data.fileNodes = this.data.fileNodes.filter(n => !idSet.has(n.id));
|
||||||
|
return { destroyed: ids, notDestroyed: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode> {
|
||||||
|
const original = this.data.fileNodes.find(n => n.id === id);
|
||||||
|
if (!original) throw new Error('File node not found');
|
||||||
|
return this.createFileNode(newName, original.blobId ?? '', original.type, original.size, parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── S/MIME raw-email helpers ──────────────────────────────────
|
||||||
|
|
||||||
|
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
|
||||||
|
async submitEmail(): Promise<void> { /* no-op */ }
|
||||||
|
async sendRawEmail(): Promise<void> { /* no-op */ }
|
||||||
|
|
||||||
|
// ── Internal helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
private recalcMailboxCounts(): void {
|
||||||
|
for (const mb of this.data.mailboxes) {
|
||||||
|
const inMb = this.data.emails.filter(e => e.mailboxIds[mb.id]);
|
||||||
|
mb.totalEmails = inMb.length;
|
||||||
|
mb.unreadEmails = inMb.filter(e => !e.keywords.$seen).length;
|
||||||
|
mb.totalThreads = new Set(inMb.map(e => e.threadId)).size;
|
||||||
|
mb.unreadThreads = new Set(inMb.filter(e => !e.keywords.$seen).map(e => e.threadId)).size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startIncomingEmailTimer(): void {
|
||||||
|
this.stopIncomingEmailTimer();
|
||||||
|
|
||||||
|
const scheduleNext = () => {
|
||||||
|
const delay = 60_000 + Math.random() * 60_000; // 60-120 seconds
|
||||||
|
this.incomingTimer = setTimeout(() => {
|
||||||
|
this.simulateIncomingEmail();
|
||||||
|
scheduleNext();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
scheduleNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopIncomingEmailTimer(): void {
|
||||||
|
if (this.incomingTimer) {
|
||||||
|
clearTimeout(this.incomingTimer);
|
||||||
|
this.incomingTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private simulateIncomingEmail(): void {
|
||||||
|
const senders = [
|
||||||
|
{ name: 'Alice Johnson', email: 'alice.johnson@example.com' },
|
||||||
|
{ name: 'Bob Chen', email: 'bob.chen@example.com' },
|
||||||
|
{ name: 'Sarah Kim', email: 'sarah.kim@example.com' },
|
||||||
|
{ name: 'Carlos Rivera', email: 'carlos.rivera@example.com' },
|
||||||
|
];
|
||||||
|
const subjects = [
|
||||||
|
'Quick question about the project',
|
||||||
|
'Meeting rescheduled to tomorrow',
|
||||||
|
'FYI: Updated documentation',
|
||||||
|
'Can you review this PR?',
|
||||||
|
'Lunch today?',
|
||||||
|
'Important: deadline reminder',
|
||||||
|
];
|
||||||
|
|
||||||
|
const sender = senders[Math.floor(Math.random() * senders.length)];
|
||||||
|
const subject = subjects[Math.floor(Math.random() * subjects.length)];
|
||||||
|
const id = generateDemoId('email');
|
||||||
|
|
||||||
|
const email: Email = {
|
||||||
|
id, threadId: generateDemoId('thread'),
|
||||||
|
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||||
|
keywords: {},
|
||||||
|
size: 1800,
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
from: [sender],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject, sentAt: new Date().toISOString(),
|
||||||
|
preview: `Hi, ${subject.toLowerCase()}. Let me know what you think.`,
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: generateDemoId('blob'), size: 120, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: `Hi,\n\n${subject}. Let me know what you think.\n\nBest,\n${sender.name}` },
|
||||||
|
},
|
||||||
|
messageId: `<${id}@demo.example.com>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.data.emails.unshift(email);
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
|
||||||
|
// Notify state change to trigger UI refresh
|
||||||
|
this.stateChangeCallback?.({
|
||||||
|
'@type': 'StateChange',
|
||||||
|
changed: { 'demo-account': { Email: generateDemoId('state'), Mailbox: generateDemoId('state') } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { cloneFixtures } from './demo-utils';
|
||||||
|
import { createDemoMailboxes } from './fixtures/mailboxes';
|
||||||
|
import { createDemoEmails } from './fixtures/emails';
|
||||||
|
import { createDemoContacts, createDemoAddressBooks } from './fixtures/contacts';
|
||||||
|
import { createDemoCalendars, createDemoCalendarEvents } from './fixtures/calendars';
|
||||||
|
import { createDemoIdentities } from './fixtures/identities';
|
||||||
|
import { createDemoSieveScripts, createDemoSieveCapabilities, createDemoSieveContent } from './fixtures/filters';
|
||||||
|
import { createDemoFileNodes } from './fixtures/files';
|
||||||
|
import { createDemoVacationResponse } from './fixtures/vacation';
|
||||||
|
|
||||||
|
import type { Email, Mailbox, ContactCard, AddressBook, Calendar, CalendarEvent, Identity, VacationResponse, FileNode } from '@/lib/jmap/types';
|
||||||
|
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||||
|
|
||||||
|
export interface DemoData {
|
||||||
|
mailboxes: Mailbox[];
|
||||||
|
emails: Email[];
|
||||||
|
contacts: ContactCard[];
|
||||||
|
addressBooks: AddressBook[];
|
||||||
|
calendars: Calendar[];
|
||||||
|
calendarEvents: CalendarEvent[];
|
||||||
|
identities: Identity[];
|
||||||
|
sieveScripts: SieveScript[];
|
||||||
|
sieveCapabilities: SieveCapabilities;
|
||||||
|
sieveContent: Record<string, string>;
|
||||||
|
fileNodes: FileNode[];
|
||||||
|
vacationResponse: VacationResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a fresh deep-cloned copy of all demo data. */
|
||||||
|
export function getDemoData(): DemoData {
|
||||||
|
return cloneFixtures({
|
||||||
|
mailboxes: createDemoMailboxes(),
|
||||||
|
emails: createDemoEmails(),
|
||||||
|
contacts: createDemoContacts(),
|
||||||
|
addressBooks: createDemoAddressBooks(),
|
||||||
|
calendars: createDemoCalendars(),
|
||||||
|
calendarEvents: createDemoCalendarEvents(),
|
||||||
|
identities: createDemoIdentities(),
|
||||||
|
sieveScripts: createDemoSieveScripts(),
|
||||||
|
sieveCapabilities: createDemoSieveCapabilities(),
|
||||||
|
sieveContent: createDemoSieveContent(),
|
||||||
|
fileNodes: createDemoFileNodes(),
|
||||||
|
vacationResponse: createDemoVacationResponse(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
let demoIdCounter = 0;
|
||||||
|
|
||||||
|
/** Generate a unique demo ID with the given prefix. */
|
||||||
|
export function generateDemoId(prefix: string = 'demo'): string {
|
||||||
|
return `${prefix}-${Date.now()}-${++demoIdCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an ISO date string relative to "now".
|
||||||
|
* @param daysOffset — whole days from today
|
||||||
|
* @param hoursOffset — additional hours offset (default 0)
|
||||||
|
* @param minutesOffset — additional minutes offset (default 0)
|
||||||
|
*/
|
||||||
|
export function demoDate(daysOffset: number, hoursOffset: number = 0, minutesOffset: number = 0): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + daysOffset);
|
||||||
|
d.setHours(d.getHours() + hoursOffset, d.getMinutes() + minutesOffset, 0, 0);
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a local date-time string (YYYY-MM-DDTHH:mm:ss) for JSCalendar "start" fields.
|
||||||
|
*/
|
||||||
|
export function demoISODate(daysOffset: number, hours: number = 0, minutes: number = 0): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + daysOffset);
|
||||||
|
d.setHours(hours, minutes, 0, 0);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deep clone fixture data so in-memory mutations don't corrupt originals. */
|
||||||
|
export function cloneFixtures<T>(data: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(data));
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||||
|
import { demoDate, demoISODate } from '../demo-utils';
|
||||||
|
|
||||||
|
export function createDemoCalendars(): Calendar[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'demo-calendar-personal',
|
||||||
|
name: 'Personal',
|
||||||
|
description: null,
|
||||||
|
color: '#3b82f6',
|
||||||
|
sortOrder: 1,
|
||||||
|
isSubscribed: true,
|
||||||
|
isVisible: true,
|
||||||
|
isDefault: true,
|
||||||
|
includeInAvailability: 'all',
|
||||||
|
defaultAlertsWithTime: null,
|
||||||
|
defaultAlertsWithoutTime: null,
|
||||||
|
timeZone: null,
|
||||||
|
shareWith: null,
|
||||||
|
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-calendar-work',
|
||||||
|
name: 'Work',
|
||||||
|
description: null,
|
||||||
|
color: '#22c55e',
|
||||||
|
sortOrder: 2,
|
||||||
|
isSubscribed: true,
|
||||||
|
isVisible: true,
|
||||||
|
isDefault: false,
|
||||||
|
includeInAvailability: 'all',
|
||||||
|
defaultAlertsWithTime: null,
|
||||||
|
defaultAlertsWithoutTime: null,
|
||||||
|
timeZone: null,
|
||||||
|
shareWith: null,
|
||||||
|
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-calendar-birthdays',
|
||||||
|
name: 'Birthdays',
|
||||||
|
description: null,
|
||||||
|
color: '#eab308',
|
||||||
|
sortOrder: 3,
|
||||||
|
isSubscribed: true,
|
||||||
|
isVisible: true,
|
||||||
|
isDefault: false,
|
||||||
|
includeInAvailability: 'none',
|
||||||
|
defaultAlertsWithTime: null,
|
||||||
|
defaultAlertsWithoutTime: null,
|
||||||
|
timeZone: null,
|
||||||
|
shareWith: null,
|
||||||
|
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDemoCalendarEvents(): CalendarEvent[] {
|
||||||
|
const baseEvent = {
|
||||||
|
'@type': 'Event' as const,
|
||||||
|
descriptionContentType: 'text/plain',
|
||||||
|
isDraft: false,
|
||||||
|
isOrigin: true,
|
||||||
|
sequence: 0,
|
||||||
|
status: 'confirmed' as const,
|
||||||
|
freeBusyStatus: 'busy' as const,
|
||||||
|
privacy: 'public' as const,
|
||||||
|
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: true,
|
||||||
|
alerts: null,
|
||||||
|
locations: null,
|
||||||
|
virtualLocations: null,
|
||||||
|
links: null,
|
||||||
|
relatedTo: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
// ── Personal calendar ──────────────────────────────────────
|
||||||
|
{
|
||||||
|
...baseEvent,
|
||||||
|
id: 'demo-event-1',
|
||||||
|
calendarIds: { 'demo-calendar-personal': true },
|
||||||
|
uid: 'demo-event-1@example.com',
|
||||||
|
title: 'Dentist Appointment',
|
||||||
|
description: 'Regular checkup at Dr. Smith\'s office',
|
||||||
|
created: demoDate(-7),
|
||||||
|
updated: demoDate(-7),
|
||||||
|
start: demoISODate(2, 10, 0),
|
||||||
|
utcStart: demoDate(2, 10),
|
||||||
|
utcEnd: demoDate(2, 11),
|
||||||
|
duration: 'PT1H',
|
||||||
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
showWithoutTime: false,
|
||||||
|
locations: { loc1: { '@type': 'Location', name: 'Dr. Smith Dental Clinic', description: '123 Medical Plaza', locationTypes: null, coordinates: null, timeZone: null, links: null, relativeTo: null } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...baseEvent,
|
||||||
|
id: 'demo-event-2',
|
||||||
|
calendarIds: { 'demo-calendar-personal': true },
|
||||||
|
uid: 'demo-event-2@example.com',
|
||||||
|
title: 'Birthday Party',
|
||||||
|
description: 'Emma\'s birthday celebration',
|
||||||
|
created: demoDate(-10),
|
||||||
|
updated: demoDate(-10),
|
||||||
|
start: demoISODate(5),
|
||||||
|
utcStart: demoDate(5),
|
||||||
|
utcEnd: demoDate(6),
|
||||||
|
duration: 'P1D',
|
||||||
|
timeZone: null,
|
||||||
|
showWithoutTime: true,
|
||||||
|
freeBusyStatus: 'free' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...baseEvent,
|
||||||
|
id: 'demo-event-3',
|
||||||
|
calendarIds: { 'demo-calendar-personal': true },
|
||||||
|
uid: 'demo-event-3@example.com',
|
||||||
|
title: 'Weekend Trip',
|
||||||
|
description: 'Road trip to the mountains',
|
||||||
|
created: demoDate(-5),
|
||||||
|
updated: demoDate(-5),
|
||||||
|
start: demoISODate(8),
|
||||||
|
utcStart: demoDate(8),
|
||||||
|
utcEnd: demoDate(10),
|
||||||
|
duration: 'P2D',
|
||||||
|
timeZone: null,
|
||||||
|
showWithoutTime: true,
|
||||||
|
freeBusyStatus: 'busy' as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Work calendar ──────────────────────────────────────────
|
||||||
|
{
|
||||||
|
...baseEvent,
|
||||||
|
id: 'demo-event-4',
|
||||||
|
calendarIds: { 'demo-calendar-work': true },
|
||||||
|
uid: 'demo-event-4@example.com',
|
||||||
|
title: 'Weekly Standup',
|
||||||
|
description: 'Team sync-up meeting',
|
||||||
|
created: demoDate(-30),
|
||||||
|
updated: demoDate(-1),
|
||||||
|
start: demoISODate(1, 9, 30),
|
||||||
|
utcStart: demoDate(1, 9, 30),
|
||||||
|
utcEnd: demoDate(1, 10, 0),
|
||||||
|
duration: 'PT30M',
|
||||||
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
showWithoutTime: false,
|
||||||
|
recurrenceRules: [{
|
||||||
|
'@type': 'RecurrenceRule',
|
||||||
|
frequency: 'weekly',
|
||||||
|
interval: 1,
|
||||||
|
rscale: 'gregorian',
|
||||||
|
skip: 'omit',
|
||||||
|
firstDayOfWeek: 'mo',
|
||||||
|
byDay: [{ day: 'mo' }],
|
||||||
|
byMonthDay: null,
|
||||||
|
byMonth: null,
|
||||||
|
byYearDay: null,
|
||||||
|
byWeekNo: null,
|
||||||
|
byHour: null,
|
||||||
|
byMinute: null,
|
||||||
|
bySecond: null,
|
||||||
|
bySetPosition: null,
|
||||||
|
count: null,
|
||||||
|
until: null,
|
||||||
|
}],
|
||||||
|
virtualLocations: { vl1: { '@type': 'VirtualLocation', name: 'Zoom', uri: 'https://zoom.example/123456', description: 'Weekly standup room', features: null } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...baseEvent,
|
||||||
|
id: 'demo-event-5',
|
||||||
|
calendarIds: { 'demo-calendar-work': true },
|
||||||
|
uid: 'demo-event-5@example.com',
|
||||||
|
title: 'Quarterly Review',
|
||||||
|
description: 'Q4 performance review and planning session',
|
||||||
|
created: demoDate(-14),
|
||||||
|
updated: demoDate(-3),
|
||||||
|
start: demoISODate(4, 14, 0),
|
||||||
|
utcStart: demoDate(4, 14),
|
||||||
|
utcEnd: demoDate(4, 16),
|
||||||
|
duration: 'PT2H',
|
||||||
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
showWithoutTime: false,
|
||||||
|
participants: {
|
||||||
|
p1: {
|
||||||
|
'@type': 'Participant', name: 'Demo User', email: 'demo@example.com', calendarAddress: null, description: null, sendTo: null,
|
||||||
|
kind: 'individual', roles: { attendee: true }, participationStatus: 'accepted', participationComment: null,
|
||||||
|
expectReply: false, scheduleAgent: 'server', scheduleForceSend: false, scheduleId: null, scheduleSequence: 0,
|
||||||
|
scheduleStatus: null, scheduleUpdated: null, invitedBy: null, delegatedTo: null, delegatedFrom: null, memberOf: null,
|
||||||
|
locationId: null, language: null, links: null,
|
||||||
|
},
|
||||||
|
p2: {
|
||||||
|
'@type': 'Participant', name: 'Alice Johnson', email: 'alice.johnson@example.com', calendarAddress: null, description: null, sendTo: null,
|
||||||
|
kind: 'individual', roles: { owner: true }, participationStatus: 'accepted', participationComment: null,
|
||||||
|
expectReply: false, scheduleAgent: 'server', scheduleForceSend: false, scheduleId: null, scheduleSequence: 0,
|
||||||
|
scheduleStatus: null, scheduleUpdated: null, invitedBy: null, delegatedTo: null, delegatedFrom: null, memberOf: null,
|
||||||
|
locationId: null, language: null, links: null,
|
||||||
|
},
|
||||||
|
p3: {
|
||||||
|
'@type': 'Participant', name: 'Bob Chen', email: 'bob.chen@example.com', calendarAddress: null, description: null, sendTo: null,
|
||||||
|
kind: 'individual', roles: { attendee: true }, participationStatus: 'tentative', participationComment: null,
|
||||||
|
expectReply: true, scheduleAgent: 'server', scheduleForceSend: false, scheduleId: null, scheduleSequence: 0,
|
||||||
|
scheduleStatus: null, scheduleUpdated: null, invitedBy: null, delegatedTo: null, delegatedFrom: null, memberOf: null,
|
||||||
|
locationId: null, language: null, links: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...baseEvent,
|
||||||
|
id: 'demo-event-6',
|
||||||
|
calendarIds: { 'demo-calendar-work': true },
|
||||||
|
uid: 'demo-event-6@example.com',
|
||||||
|
title: 'Lunch Meeting with Sarah',
|
||||||
|
description: 'Design review over lunch',
|
||||||
|
created: demoDate(-3),
|
||||||
|
updated: demoDate(-3),
|
||||||
|
start: demoISODate(3, 12, 0),
|
||||||
|
utcStart: demoDate(3, 12),
|
||||||
|
utcEnd: demoDate(3, 13),
|
||||||
|
duration: 'PT1H',
|
||||||
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
showWithoutTime: false,
|
||||||
|
locations: { loc1: { '@type': 'Location', name: 'The Garden Bistro', description: '123 Oak Street', locationTypes: null, coordinates: null, timeZone: null, links: null, relativeTo: null } },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Birthdays calendar ─────────────────────────────────────
|
||||||
|
{
|
||||||
|
...baseEvent,
|
||||||
|
id: 'demo-event-7',
|
||||||
|
calendarIds: { 'demo-calendar-birthdays': true },
|
||||||
|
uid: 'demo-event-7@example.com',
|
||||||
|
title: 'Alice Johnson\'s Birthday',
|
||||||
|
description: '',
|
||||||
|
created: demoDate(-30),
|
||||||
|
updated: demoDate(-30),
|
||||||
|
start: demoISODate(12),
|
||||||
|
utcStart: demoDate(12),
|
||||||
|
utcEnd: demoDate(13),
|
||||||
|
duration: 'P1D',
|
||||||
|
timeZone: null,
|
||||||
|
showWithoutTime: true,
|
||||||
|
freeBusyStatus: 'free' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...baseEvent,
|
||||||
|
id: 'demo-event-8',
|
||||||
|
calendarIds: { 'demo-calendar-birthdays': true },
|
||||||
|
uid: 'demo-event-8@example.com',
|
||||||
|
title: 'Carlos Rivera\'s Birthday',
|
||||||
|
description: '',
|
||||||
|
created: demoDate(-30),
|
||||||
|
updated: demoDate(-30),
|
||||||
|
start: demoISODate(-3),
|
||||||
|
utcStart: demoDate(-3),
|
||||||
|
utcEnd: demoDate(-2),
|
||||||
|
duration: 'P1D',
|
||||||
|
timeZone: null,
|
||||||
|
showWithoutTime: true,
|
||||||
|
freeBusyStatus: 'free' as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import type { ContactCard, AddressBook } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
export function createDemoAddressBooks(): AddressBook[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'demo-addressbook-personal',
|
||||||
|
name: 'Personal',
|
||||||
|
isDefault: true,
|
||||||
|
isSubscribed: true,
|
||||||
|
sortOrder: 1,
|
||||||
|
myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-addressbook-work',
|
||||||
|
name: 'Work',
|
||||||
|
isDefault: false,
|
||||||
|
isSubscribed: true,
|
||||||
|
sortOrder: 2,
|
||||||
|
myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDemoContacts(): ContactCard[] {
|
||||||
|
return [
|
||||||
|
// ── Personal address book ──────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'demo-contact-1',
|
||||||
|
addressBookIds: { 'demo-addressbook-personal': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Johnson' }] },
|
||||||
|
emails: { e1: { address: 'alice.johnson@example.com', contexts: { work: true }, pref: 1 } },
|
||||||
|
phones: { p1: { number: '+1-555-0101', features: { voice: true }, contexts: { work: true } } },
|
||||||
|
organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Engineering' }] } },
|
||||||
|
titles: { t1: { name: 'Senior Engineer', kind: 'title' } },
|
||||||
|
anniversaries: { a1: { kind: 'birth', date: { year: 1990, month: 3, day: 15 } } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-2',
|
||||||
|
addressBookIds: { 'demo-addressbook-personal': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Chen' }] },
|
||||||
|
emails: {
|
||||||
|
e1: { address: 'bob.chen@example.com', contexts: { work: true }, pref: 1 },
|
||||||
|
e2: { address: 'bob.personal@email.example', contexts: { private: true } },
|
||||||
|
},
|
||||||
|
phones: {
|
||||||
|
p1: { number: '+1-555-0102', features: { voice: true }, contexts: { work: true } },
|
||||||
|
p2: { number: '+1-555-0103', features: { cell: true }, contexts: { private: true } },
|
||||||
|
},
|
||||||
|
organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Backend Team' }] } },
|
||||||
|
titles: { t1: { name: 'Staff Engineer', kind: 'title' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-3',
|
||||||
|
addressBookIds: { 'demo-addressbook-personal': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Sarah' }, { kind: 'surname', value: 'Kim' }] },
|
||||||
|
emails: { e1: { address: 'sarah.kim@example.com', pref: 1 } },
|
||||||
|
phones: { p1: { number: '+1-555-0104', features: { voice: true } } },
|
||||||
|
organizations: { o1: { name: 'DesignCo' } },
|
||||||
|
titles: { t1: { name: 'UX Designer', kind: 'title' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-4',
|
||||||
|
addressBookIds: { 'demo-addressbook-personal': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Carlos' }, { kind: 'surname', value: 'Rivera' }] },
|
||||||
|
emails: { e1: { address: 'carlos.rivera@example.com', pref: 1 } },
|
||||||
|
phones: { p1: { number: '+1-555-0105', features: { cell: true } } },
|
||||||
|
notes: { n1: { note: 'Met at the DevConf 2024 conference' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-5',
|
||||||
|
addressBookIds: { 'demo-addressbook-personal': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Emma' }, { kind: 'surname', value: 'Wilson' }] },
|
||||||
|
emails: { e1: { address: 'emma.wilson@example.com', pref: 1 } },
|
||||||
|
addresses: {
|
||||||
|
a1: {
|
||||||
|
components: [
|
||||||
|
{ kind: 'number', value: '456' },
|
||||||
|
{ kind: 'name', value: 'Elm Street' },
|
||||||
|
{ kind: 'locality', value: 'Springfield' },
|
||||||
|
{ kind: 'region', value: 'IL' },
|
||||||
|
{ kind: 'postcode', value: '62701' },
|
||||||
|
],
|
||||||
|
contexts: { private: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
anniversaries: { a1: { kind: 'birth', date: { month: 7, day: 22 } } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-6',
|
||||||
|
addressBookIds: { 'demo-addressbook-personal': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'David' }, { kind: 'surname', value: 'Park' }] },
|
||||||
|
emails: { e1: { address: 'david.park@example.com', pref: 1 } },
|
||||||
|
phones: { p1: { number: '+82-10-1234-5678', features: { cell: true } } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-7',
|
||||||
|
addressBookIds: { 'demo-addressbook-personal': true },
|
||||||
|
kind: 'org',
|
||||||
|
name: { components: [{ kind: 'surname', value: 'Local Coffee Shop' }] },
|
||||||
|
emails: { e1: { address: 'hello@localcoffee.example', pref: 1 } },
|
||||||
|
phones: { p1: { number: '+1-555-0200', features: { voice: true } } },
|
||||||
|
addresses: {
|
||||||
|
a1: {
|
||||||
|
components: [
|
||||||
|
{ kind: 'number', value: '789' },
|
||||||
|
{ kind: 'name', value: 'Main Street' },
|
||||||
|
{ kind: 'locality', value: 'Anytown' },
|
||||||
|
{ kind: 'region', value: 'CA' },
|
||||||
|
{ kind: 'postcode', value: '90210' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-8',
|
||||||
|
addressBookIds: { 'demo-addressbook-personal': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Lisa' }, { kind: 'surname', value: 'Tanaka' }] },
|
||||||
|
emails: { e1: { address: 'lisa.tanaka@example.com', pref: 1 } },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Work address book ──────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'demo-contact-9',
|
||||||
|
addressBookIds: { 'demo-addressbook-work': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Michael' }, { kind: 'surname', value: 'Torres' }] },
|
||||||
|
emails: { e1: { address: 'michael.torres@company.example', contexts: { work: true }, pref: 1 } },
|
||||||
|
phones: { p1: { number: '+1-555-0301', features: { voice: true }, contexts: { work: true } } },
|
||||||
|
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Product' }] } },
|
||||||
|
titles: { t1: { name: 'Product Manager', kind: 'title' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-10',
|
||||||
|
addressBookIds: { 'demo-addressbook-work': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Rachel' }, { kind: 'surname', value: 'Green' }] },
|
||||||
|
emails: { e1: { address: 'rachel.green@company.example', contexts: { work: true }, pref: 1 } },
|
||||||
|
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Marketing' }] } },
|
||||||
|
titles: { t1: { name: 'Marketing Lead', kind: 'title' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-11',
|
||||||
|
addressBookIds: { 'demo-addressbook-work': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'James' }, { kind: 'surname', value: 'Miller' }] },
|
||||||
|
emails: { e1: { address: 'james.miller@company.example', contexts: { work: true }, pref: 1 } },
|
||||||
|
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Engineering' }] } },
|
||||||
|
titles: { t1: { name: 'CTO', kind: 'title' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-12',
|
||||||
|
addressBookIds: { 'demo-addressbook-work': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Priya' }, { kind: 'surname', value: 'Sharma' }] },
|
||||||
|
emails: { e1: { address: 'priya.sharma@company.example', contexts: { work: true }, pref: 1 } },
|
||||||
|
organizations: { o1: { name: 'Company Inc', units: [{ name: 'QA' }] } },
|
||||||
|
titles: { t1: { name: 'QA Engineer', kind: 'title' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-13',
|
||||||
|
addressBookIds: { 'demo-addressbook-work': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Ahmed' }, { kind: 'surname', value: 'Hassan' }] },
|
||||||
|
emails: { e1: { address: 'ahmed.hassan@company.example', contexts: { work: true }, pref: 1 } },
|
||||||
|
organizations: { o1: { name: 'Company Inc', units: [{ name: 'DevOps' }] } },
|
||||||
|
titles: { t1: { name: 'DevOps Engineer', kind: 'title' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-14',
|
||||||
|
addressBookIds: { 'demo-addressbook-work': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Maria' }, { kind: 'surname', value: 'Lopez' }] },
|
||||||
|
emails: { e1: { address: 'maria.lopez@company.example', contexts: { work: true }, pref: 1 } },
|
||||||
|
organizations: { o1: { name: 'Company Inc', units: [{ name: 'HR' }] } },
|
||||||
|
titles: { t1: { name: 'HR Business Partner', kind: 'title' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-15',
|
||||||
|
addressBookIds: { 'demo-addressbook-work': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Wei' }, { kind: 'surname', value: 'Zhang' }] },
|
||||||
|
emails: { e1: { address: 'wei.zhang@company.example', contexts: { work: true }, pref: 1 } },
|
||||||
|
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Data Science' }] } },
|
||||||
|
titles: { t1: { name: 'Data Scientist', kind: 'title' } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import type { Email } from '@/lib/jmap/types';
|
||||||
|
import { demoDate } from '../demo-utils';
|
||||||
|
|
||||||
|
export function createDemoEmails(): Email[] {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
return [
|
||||||
|
// ── Inbox ───────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'demo-email-1',
|
||||||
|
threadId: 'demo-thread-1',
|
||||||
|
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||||
|
keywords: {},
|
||||||
|
size: 4200,
|
||||||
|
receivedAt: demoDate(0, -2),
|
||||||
|
from: [{ name: 'Bulwark Team', email: 'welcome@bulwark.email' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: 'Welcome to Bulwark Mail!',
|
||||||
|
sentAt: demoDate(0, -2),
|
||||||
|
preview: 'Thanks for trying out Bulwark Mail. This is a demo environment where you can explore all features...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-1', size: 350, type: 'text/plain' }],
|
||||||
|
htmlBody: [{ partId: '2', blobId: 'blob-2', size: 800, type: 'text/html' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Thanks for trying out Bulwark Mail!\n\nThis is a demo environment where you can explore all features without connecting to a real server. All data stays on your device.\n\nFeel free to:\n- Read, compose, and organize emails\n- Manage contacts and calendars\n- Configure filters and settings\n- Try keyboard shortcuts (press ? to see them)\n\nEnjoy exploring!' },
|
||||||
|
'2': { value: '<div><h2>Welcome to Bulwark Mail!</h2><p>Thanks for trying out Bulwark Mail!</p><p>This is a demo environment where you can explore all features without connecting to a real server. <strong>All data stays on your device.</strong></p><p>Feel free to:</p><ul><li>Read, compose, and organize emails</li><li>Manage contacts and calendars</li><li>Configure filters and settings</li><li>Try keyboard shortcuts (press <kbd>?</kbd> to see them)</li></ul><p>Enjoy exploring!</p></div>' },
|
||||||
|
},
|
||||||
|
messageId: '<welcome@demo.bulwark.email>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-email-2',
|
||||||
|
threadId: 'demo-thread-2',
|
||||||
|
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: 18500,
|
||||||
|
receivedAt: demoDate(-1, -5),
|
||||||
|
from: [{ name: 'TechDigest Weekly', email: 'newsletter@techdigest.example' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: 'This Week in Tech: AI Developments & Open Source Updates',
|
||||||
|
sentAt: demoDate(-1, -5),
|
||||||
|
preview: 'Your weekly roundup of the most important technology news and open source developments...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-3', size: 2400, type: 'text/plain' }],
|
||||||
|
htmlBody: [{ partId: '2', blobId: 'blob-4', size: 5200, type: 'text/html' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'This Week in Tech\n\n1. AI-Powered Code Review Tools\nNew tools are making code reviews faster and more thorough...\n\n2. Open Source Licensing Update\nThe OSI has published new guidelines for AI-generated code...\n\n3. WebAssembly 2.0 Draft\nThe W3C has released the first draft of WebAssembly 2.0...\n\nRead more at techdigest.example' },
|
||||||
|
'2': { value: '<div style="max-width:600px;margin:0 auto;"><h1>This Week in Tech</h1><h3>1. AI-Powered Code Review Tools</h3><p>New tools are making code reviews faster and more thorough, with several open-source options gaining traction.</p><h3>2. Open Source Licensing Update</h3><p>The OSI has published new guidelines for AI-generated code contributions to open source projects.</p><h3>3. WebAssembly 2.0 Draft</h3><p>The W3C has released the first draft of WebAssembly 2.0, promising improved memory management.</p></div>' },
|
||||||
|
},
|
||||||
|
messageId: '<weekly-42@techdigest.example>',
|
||||||
|
},
|
||||||
|
// Thread: Project discussion (3 emails in same thread)
|
||||||
|
{
|
||||||
|
id: 'demo-email-3a',
|
||||||
|
threadId: 'demo-thread-3',
|
||||||
|
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: 3100,
|
||||||
|
receivedAt: demoDate(-3, -10),
|
||||||
|
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }, { name: 'Bob Chen', email: 'bob.chen@example.com' }],
|
||||||
|
subject: 'Q4 Project Timeline',
|
||||||
|
sentAt: demoDate(-3, -10),
|
||||||
|
preview: 'Hi team, I wanted to share the updated timeline for our Q4 deliverables...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-5', size: 450, type: 'text/plain' }],
|
||||||
|
htmlBody: [{ partId: '2', blobId: 'blob-6', size: 650, type: 'text/html' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Hi team,\n\nI wanted to share the updated timeline for our Q4 deliverables:\n\n- Phase 1: Design review — Oct 15\n- Phase 2: Development — Nov 1-30\n- Phase 3: Testing — Dec 1-15\n- Phase 4: Launch — Dec 20\n\nPlease review and let me know if you see any conflicts.\n\nBest,\nAlice' },
|
||||||
|
'2': { value: '<p>Hi team,</p><p>I wanted to share the updated timeline for our Q4 deliverables:</p><ul><li>Phase 1: Design review — Oct 15</li><li>Phase 2: Development — Nov 1-30</li><li>Phase 3: Testing — Dec 1-15</li><li>Phase 4: Launch — Dec 20</li></ul><p>Please review and let me know if you see any conflicts.</p><p>Best,<br>Alice</p>' },
|
||||||
|
},
|
||||||
|
messageId: '<q4-timeline-1@example.com>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-email-3b',
|
||||||
|
threadId: 'demo-thread-3',
|
||||||
|
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: 3500,
|
||||||
|
receivedAt: demoDate(-2, -8),
|
||||||
|
from: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
|
||||||
|
to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }, { name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: 'Re: Q4 Project Timeline',
|
||||||
|
sentAt: demoDate(-2, -8),
|
||||||
|
preview: 'Looks good to me! One concern: the testing window might be tight given the holidays...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-7', size: 520, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Looks good to me! One concern: the testing window might be tight given the holidays. Could we start testing a few days earlier?\n\nAlso, should we set up a shared doc for tracking blockers?\n\n— Bob' },
|
||||||
|
},
|
||||||
|
messageId: '<q4-timeline-2@example.com>',
|
||||||
|
inReplyTo: ['<q4-timeline-1@example.com>'],
|
||||||
|
references: ['<q4-timeline-1@example.com>'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-email-3c',
|
||||||
|
threadId: 'demo-thread-3',
|
||||||
|
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||||
|
keywords: {},
|
||||||
|
size: 3800,
|
||||||
|
receivedAt: demoDate(-1, -3),
|
||||||
|
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
|
||||||
|
to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }, { name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: 'Re: Q4 Project Timeline',
|
||||||
|
sentAt: demoDate(-1, -3),
|
||||||
|
preview: 'Great point Bob. Let\'s move testing to Nov 28. I\'ll create the shared doc today...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-8', size: 400, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Great point Bob. Let\'s move testing to Nov 28. I\'ll create the shared doc today and share the link.\n\nUpdated timeline:\n- Design review: Oct 15\n- Development: Nov 1-27\n- Testing: Nov 28 - Dec 15\n- Launch: Dec 20\n\n— Alice' },
|
||||||
|
},
|
||||||
|
messageId: '<q4-timeline-3@example.com>',
|
||||||
|
inReplyTo: ['<q4-timeline-2@example.com>'],
|
||||||
|
references: ['<q4-timeline-1@example.com>', '<q4-timeline-2@example.com>'],
|
||||||
|
},
|
||||||
|
// Email with attachments
|
||||||
|
{
|
||||||
|
id: 'demo-email-4',
|
||||||
|
threadId: 'demo-thread-4',
|
||||||
|
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||||
|
keywords: {},
|
||||||
|
size: 245000,
|
||||||
|
receivedAt: demoDate(0, -6),
|
||||||
|
from: [{ name: 'Sarah Kim', email: 'sarah.kim@example.com' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: 'Invoice #2024-089 & Project Screenshot',
|
||||||
|
sentAt: demoDate(0, -6),
|
||||||
|
preview: 'Hi, please find attached the invoice for October and a screenshot of the latest prototype...',
|
||||||
|
hasAttachment: true,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-9', size: 280, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Hi,\n\nPlease find attached the invoice for October and a screenshot of the latest prototype.\n\nLet me know if you have any questions.\n\nBest regards,\nSarah' },
|
||||||
|
},
|
||||||
|
attachments: [
|
||||||
|
{ partId: 'att-1', blobId: 'demo-blob-att-1', size: 145000, name: 'Invoice-2024-089.pdf', type: 'application/pdf' },
|
||||||
|
{ partId: 'att-2', blobId: 'demo-blob-att-2', size: 89000, name: 'prototype-v3.png', type: 'image/png' },
|
||||||
|
],
|
||||||
|
messageId: '<invoice-089@example.com>',
|
||||||
|
},
|
||||||
|
// Starred email
|
||||||
|
{
|
||||||
|
id: 'demo-email-5',
|
||||||
|
threadId: 'demo-thread-5',
|
||||||
|
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||||
|
keywords: { $seen: true, $flagged: true },
|
||||||
|
size: 2800,
|
||||||
|
receivedAt: demoDate(-2, -1),
|
||||||
|
from: [{ name: 'Carlos Rivera', email: 'carlos.rivera@example.com' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: 'Reminder: Team Dinner Friday',
|
||||||
|
sentAt: demoDate(-2, -1),
|
||||||
|
preview: 'Hey! Just a reminder about our team dinner this Friday at 7 PM at The Garden Bistro...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-10', size: 320, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Hey!\n\nJust a reminder about our team dinner this Friday at 7 PM at The Garden Bistro. I\'ve made a reservation for 8 people.\n\nAddress: 123 Oak Street\n\nLet me know if you can make it!\n\nCheers,\nCarlos' },
|
||||||
|
},
|
||||||
|
messageId: '<dinner-reminder@example.com>',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Sent ────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'demo-email-6',
|
||||||
|
threadId: 'demo-thread-6',
|
||||||
|
mailboxIds: { 'demo-mailbox-sent': true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: 2100,
|
||||||
|
receivedAt: demoDate(-1, -4),
|
||||||
|
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
|
||||||
|
subject: 'Updated Requirements Document',
|
||||||
|
sentAt: demoDate(-1, -4),
|
||||||
|
preview: 'Hi Alice, I\'ve updated the requirements document with the changes we discussed...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-11', size: 290, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Hi Alice,\n\nI\'ve updated the requirements document with the changes we discussed in yesterday\'s meeting. The main updates are in sections 3 and 5.\n\nLet me know if you have any questions.\n\nBest,\nDemo User' },
|
||||||
|
},
|
||||||
|
messageId: '<sent-1@example.com>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-email-7',
|
||||||
|
threadId: 'demo-thread-7',
|
||||||
|
mailboxIds: { 'demo-mailbox-sent': true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: 1800,
|
||||||
|
receivedAt: demoDate(-4, -2),
|
||||||
|
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
to: [{ name: 'Sarah Kim', email: 'sarah.kim@example.com' }],
|
||||||
|
subject: 'Re: Design Feedback',
|
||||||
|
sentAt: demoDate(-4, -2),
|
||||||
|
preview: 'Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-12', size: 250, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements for accessibility.\n\nLet\'s go with Option B for the navigation.\n\nBest,\nDemo User' },
|
||||||
|
},
|
||||||
|
messageId: '<sent-2@example.com>',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Drafts ──────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'demo-email-8',
|
||||||
|
threadId: 'demo-thread-8',
|
||||||
|
mailboxIds: { 'demo-mailbox-drafts': true },
|
||||||
|
keywords: { $seen: true, $draft: true },
|
||||||
|
size: 900,
|
||||||
|
receivedAt: demoDate(0, -1),
|
||||||
|
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
|
||||||
|
subject: 'Meeting Notes - Draft',
|
||||||
|
sentAt: demoDate(0, -1),
|
||||||
|
preview: 'Here are the notes from today\'s standup...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-13', size: 180, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Here are the notes from today\'s standup:\n\n- API integration on track\n- Need to resolve the caching issue\n- ' },
|
||||||
|
},
|
||||||
|
messageId: '<draft-1@example.com>',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Trash ───────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'demo-email-9',
|
||||||
|
threadId: 'demo-thread-9',
|
||||||
|
mailboxIds: { 'demo-mailbox-trash': true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: 15200,
|
||||||
|
receivedAt: demoDate(-5, -3),
|
||||||
|
from: [{ name: 'Promo Store', email: 'deals@promostore.example' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: '🎉 Flash Sale: 50% Off Everything!',
|
||||||
|
sentAt: demoDate(-5, -3),
|
||||||
|
preview: 'Limited time offer! Get 50% off all items in our store...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-14', size: 400, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Limited time offer! Get 50% off all items in our store. Use code FLASH50 at checkout.' },
|
||||||
|
},
|
||||||
|
messageId: '<promo-1@promostore.example>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-email-10',
|
||||||
|
threadId: 'demo-thread-10',
|
||||||
|
mailboxIds: { 'demo-mailbox-trash': true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: 2300,
|
||||||
|
receivedAt: demoDate(-7, 0),
|
||||||
|
from: [{ name: 'System Notification', email: 'noreply@service.example' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: 'Your password was changed',
|
||||||
|
sentAt: demoDate(-7, 0),
|
||||||
|
preview: 'Your account password was successfully changed on...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-15', size: 200, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Your account password was successfully changed. If you did not make this change, please contact support immediately.' },
|
||||||
|
},
|
||||||
|
messageId: '<notification-1@service.example>',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Projects ────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'demo-email-11',
|
||||||
|
threadId: 'demo-thread-11',
|
||||||
|
mailboxIds: { 'demo-mailbox-projects': true },
|
||||||
|
keywords: { $seen: true, $flagged: true },
|
||||||
|
size: 4500,
|
||||||
|
receivedAt: demoDate(-2, -7),
|
||||||
|
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: '[Project] Sprint Planning Agenda',
|
||||||
|
sentAt: demoDate(-2, -7),
|
||||||
|
preview: 'Here\'s the agenda for next week\'s sprint planning session...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-16', size: 600, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Hi team,\n\nHere\'s the agenda for next week\'s sprint planning:\n\n1. Review previous sprint velocity\n2. Discuss tech debt items\n3. Prioritize backlog\n4. Assign story points\n5. Capacity planning\n\nPlease come prepared with your updates.\n\nThanks,\nAlice' },
|
||||||
|
},
|
||||||
|
messageId: '<project-1@example.com>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-email-12',
|
||||||
|
threadId: 'demo-thread-12',
|
||||||
|
mailboxIds: { 'demo-mailbox-projects': true },
|
||||||
|
keywords: {},
|
||||||
|
size: 3200,
|
||||||
|
receivedAt: demoDate(0, -8),
|
||||||
|
from: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: '[Project] API Rate Limiting Discussion',
|
||||||
|
sentAt: demoDate(0, -8),
|
||||||
|
preview: 'I\'ve been thinking about our rate limiting approach and wanted to propose a few changes...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-17', size: 480, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Hey,\n\nI\'ve been thinking about our rate limiting approach and wanted to propose:\n\n1. Token bucket algorithm instead of fixed window\n2. Per-endpoint limits rather than global\n3. Graduated response (warn → throttle → block)\n\nThoughts? I can put together a more detailed RFC if we agree on the direction.\n\n— Bob' },
|
||||||
|
},
|
||||||
|
messageId: '<project-2@example.com>',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Archive ─────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'demo-email-13',
|
||||||
|
threadId: 'demo-thread-13',
|
||||||
|
mailboxIds: { 'demo-mailbox-archive': true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: 2600,
|
||||||
|
receivedAt: demoDate(-14, -6),
|
||||||
|
from: [{ name: 'HR Department', email: 'hr@company.example' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: 'Updated PTO Policy - Effective January 1',
|
||||||
|
sentAt: demoDate(-14, -6),
|
||||||
|
preview: 'Please review the updated PTO policy that takes effect January 1st...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-18', size: 380, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Dear team,\n\nPlease review the updated PTO policy effective January 1st. Key changes include:\n\n- Increased annual allowance from 20 to 25 days\n- Flexible half-day options\n- Rollover limit increased to 10 days\n\nPlease acknowledge receipt.\n\nBest,\nHR Department' },
|
||||||
|
},
|
||||||
|
messageId: '<hr-policy-1@company.example>',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Receipts ────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'demo-email-14',
|
||||||
|
threadId: 'demo-thread-14',
|
||||||
|
mailboxIds: { 'demo-mailbox-receipts': true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: 5200,
|
||||||
|
receivedAt: demoDate(-3, -12),
|
||||||
|
from: [{ name: 'Cloud Services', email: 'billing@cloudprovider.example' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: 'Payment Receipt - Invoice #INV-2024-1042',
|
||||||
|
sentAt: demoDate(-3, -12),
|
||||||
|
preview: 'Your payment of $49.99 has been processed successfully...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-19', size: 350, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Payment Confirmation\n\nAmount: $49.99\nDate: Processing date\nInvoice: INV-2024-1042\nService: Cloud Hosting (Standard Plan)\n\nThank you for your payment.' },
|
||||||
|
},
|
||||||
|
messageId: '<receipt-1@cloudprovider.example>',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Spam ────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'demo-email-15',
|
||||||
|
threadId: 'demo-thread-15',
|
||||||
|
mailboxIds: { 'demo-mailbox-junk': true },
|
||||||
|
keywords: {},
|
||||||
|
size: 8900,
|
||||||
|
receivedAt: demoDate(-1, -9),
|
||||||
|
from: [{ name: 'Prize Center', email: 'winner@totallylegit.example' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: 'Congratulations! You Won $1,000,000!!!',
|
||||||
|
sentAt: demoDate(-1, -9),
|
||||||
|
preview: 'Dear lucky winner, you have been selected to receive one million dollars...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-20', size: 500, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Dear lucky winner,\n\nYou have been selected to receive ONE MILLION DOLLARS! Click below to claim your prize immediately.\n\n[This is a demo spam email]' },
|
||||||
|
},
|
||||||
|
messageId: '<spam-1@totallylegit.example>',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { FileNode } from '@/lib/jmap/types';
|
||||||
|
import { demoDate } from '../demo-utils';
|
||||||
|
|
||||||
|
export function createDemoFileNodes(): FileNode[] {
|
||||||
|
return [
|
||||||
|
// Root-level directories
|
||||||
|
{
|
||||||
|
id: 'demo-file-documents',
|
||||||
|
parentId: null,
|
||||||
|
name: 'Documents',
|
||||||
|
type: 'd',
|
||||||
|
blobId: null,
|
||||||
|
size: 0,
|
||||||
|
created: demoDate(-30),
|
||||||
|
updated: demoDate(-2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-file-photos',
|
||||||
|
parentId: null,
|
||||||
|
name: 'Photos',
|
||||||
|
type: 'd',
|
||||||
|
blobId: null,
|
||||||
|
size: 0,
|
||||||
|
created: demoDate(-30),
|
||||||
|
updated: demoDate(-5),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Documents contents
|
||||||
|
{
|
||||||
|
id: 'demo-file-meeting-notes',
|
||||||
|
parentId: 'demo-file-documents',
|
||||||
|
name: 'meeting-notes.md',
|
||||||
|
type: 'text/markdown',
|
||||||
|
blobId: 'demo-blob-file-1',
|
||||||
|
size: 2150,
|
||||||
|
created: demoDate(-7),
|
||||||
|
updated: demoDate(-2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-file-quarterly-report',
|
||||||
|
parentId: 'demo-file-documents',
|
||||||
|
name: 'quarterly-report.pdf',
|
||||||
|
type: 'application/pdf',
|
||||||
|
blobId: 'demo-blob-file-2',
|
||||||
|
size: 148480,
|
||||||
|
created: demoDate(-14),
|
||||||
|
updated: demoDate(-14),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-file-todo',
|
||||||
|
parentId: 'demo-file-documents',
|
||||||
|
name: 'todo.txt',
|
||||||
|
type: 'text/plain',
|
||||||
|
blobId: 'demo-blob-file-3',
|
||||||
|
size: 410,
|
||||||
|
created: demoDate(-3),
|
||||||
|
updated: demoDate(-1),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Photos contents
|
||||||
|
{
|
||||||
|
id: 'demo-file-vacation',
|
||||||
|
parentId: 'demo-file-photos',
|
||||||
|
name: 'vacation.jpg',
|
||||||
|
type: 'image/jpeg',
|
||||||
|
blobId: 'demo-blob-file-4',
|
||||||
|
size: 1258291,
|
||||||
|
created: demoDate(-10),
|
||||||
|
updated: demoDate(-10),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-file-team-photo',
|
||||||
|
parentId: 'demo-file-photos',
|
||||||
|
name: 'team-photo.png',
|
||||||
|
type: 'image/png',
|
||||||
|
blobId: 'demo-blob-file-5',
|
||||||
|
size: 911360,
|
||||||
|
created: demoDate(-21),
|
||||||
|
updated: demoDate(-21),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Root-level file
|
||||||
|
{
|
||||||
|
id: 'demo-file-budget',
|
||||||
|
parentId: null,
|
||||||
|
name: 'budget.xlsx',
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
blobId: 'demo-blob-file-6',
|
||||||
|
size: 68608,
|
||||||
|
created: demoDate(-5),
|
||||||
|
updated: demoDate(-1),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||||
|
|
||||||
|
export function createDemoSieveCapabilities(): SieveCapabilities {
|
||||||
|
return {
|
||||||
|
implementation: 'Demo Sieve Engine',
|
||||||
|
maxSizeScript: 65536,
|
||||||
|
sieveExtensions: ['fileinto', 'reject', 'vacation', 'imap4flags', 'comparator-i;ascii-casemap', 'body', 'envelope'],
|
||||||
|
notificationMethods: [],
|
||||||
|
externalLists: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDemoSieveScripts(): SieveScript[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'demo-sieve-1',
|
||||||
|
name: 'Default Filters',
|
||||||
|
blobId: 'demo-sieve-blob-1',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sieve script content keyed by blobId
|
||||||
|
export function createDemoSieveContent(): Record<string, string> {
|
||||||
|
return {
|
||||||
|
'demo-sieve-blob-1': [
|
||||||
|
'require ["fileinto", "imap4flags"];',
|
||||||
|
'',
|
||||||
|
'# Newsletters to Receipts',
|
||||||
|
'if address :contains "from" "newsletter@" {',
|
||||||
|
' fileinto "Receipts";',
|
||||||
|
' stop;',
|
||||||
|
'}',
|
||||||
|
'',
|
||||||
|
'# Flag emails from boss',
|
||||||
|
'if address :is "from" "alice.johnson@example.com" {',
|
||||||
|
' addflag "\\\\Flagged";',
|
||||||
|
'}',
|
||||||
|
'',
|
||||||
|
'# Move project updates',
|
||||||
|
'if header :contains "subject" "[Project]" {',
|
||||||
|
' fileinto "Projects";',
|
||||||
|
' stop;',
|
||||||
|
'}',
|
||||||
|
].join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Identity } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
export function createDemoIdentities(): Identity[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'demo-identity-primary',
|
||||||
|
name: 'Demo User',
|
||||||
|
email: 'demo@example.com',
|
||||||
|
textSignature: 'Best regards,\nDemo User\nBulwark Mail Demo',
|
||||||
|
htmlSignature: '<p>Best regards,<br><b>Demo User</b><br>Bulwark Mail Demo</p>',
|
||||||
|
mayDelete: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-identity-alias',
|
||||||
|
name: 'Demo User',
|
||||||
|
email: 'demo+newsletter@example.com',
|
||||||
|
textSignature: '',
|
||||||
|
htmlSignature: '',
|
||||||
|
mayDelete: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Mailbox } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
const RIGHTS_SYSTEM = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: false, mayDelete: false, maySubmit: true };
|
||||||
|
const RIGHTS_CUSTOM = { ...RIGHTS_SYSTEM, mayRename: true, mayDelete: true };
|
||||||
|
|
||||||
|
export function createDemoMailboxes(): Mailbox[] {
|
||||||
|
return [
|
||||||
|
{ id: 'demo-mailbox-inbox', name: 'Inbox', role: 'inbox', sortOrder: 1, totalEmails: 12, unreadEmails: 5, totalThreads: 10, unreadThreads: 4, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||||
|
{ id: 'demo-mailbox-sent', name: 'Sent', role: 'sent', sortOrder: 2, totalEmails: 8, unreadEmails: 0, totalThreads: 8, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||||
|
{ id: 'demo-mailbox-drafts', name: 'Drafts', role: 'drafts', sortOrder: 3, totalEmails: 1, unreadEmails: 0, totalThreads: 1, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||||
|
{ id: 'demo-mailbox-trash', name: 'Trash', role: 'trash', sortOrder: 5, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||||
|
{ id: 'demo-mailbox-archive', name: 'Archive', role: 'archive', sortOrder: 4, totalEmails: 4, unreadEmails: 0, totalThreads: 4, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||||
|
{ id: 'demo-mailbox-junk', name: 'Spam', role: 'junk', sortOrder: 6, totalEmails: 3, unreadEmails: 1, totalThreads: 3, unreadThreads: 1, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||||
|
{ id: 'demo-mailbox-projects', name: 'Projects', sortOrder: 10, totalEmails: 5, unreadEmails: 2, totalThreads: 5, unreadThreads: 2, myRights: RIGHTS_CUSTOM, isSubscribed: true },
|
||||||
|
{ id: 'demo-mailbox-receipts', name: 'Receipts', sortOrder: 11, totalEmails: 3, unreadEmails: 0, totalThreads: 3, unreadThreads: 0, myRights: RIGHTS_CUSTOM, isSubscribed: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { VacationResponse } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
export function createDemoVacationResponse(): VacationResponse {
|
||||||
|
return {
|
||||||
|
id: 'singleton',
|
||||||
|
isEnabled: false,
|
||||||
|
fromDate: null,
|
||||||
|
toDate: null,
|
||||||
|
subject: 'Out of Office',
|
||||||
|
textBody: 'Thank you for your email. I am currently out of the office and will return on Monday. For urgent matters, please contact support@example.com.',
|
||||||
|
htmlBody: '<p>Thank you for your email. I am currently out of the office and will return on Monday.</p><p>For urgent matters, please contact <a href="mailto:support@example.com">support@example.com</a>.</p>',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode } from "./types";
|
||||||
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface defining the public JMAP client contract.
|
||||||
|
*
|
||||||
|
* Both the real `JMAPClient` (network-backed) and `DemoJMAPClient`
|
||||||
|
* (in-memory/browser-only) implement this interface so that stores
|
||||||
|
* and UI code never need to know which one is active.
|
||||||
|
*/
|
||||||
|
export interface IJMAPClient {
|
||||||
|
// ── Connection lifecycle ──────────────────────────────────────
|
||||||
|
connect(): Promise<void>;
|
||||||
|
disconnect(): void;
|
||||||
|
reconnect(): Promise<void>;
|
||||||
|
ping(): Promise<void>;
|
||||||
|
|
||||||
|
// ── Session / auth accessors ──────────────────────────────────
|
||||||
|
getServerUrl(): string;
|
||||||
|
getAuthHeader(): string;
|
||||||
|
updateAccessToken(token: string): void;
|
||||||
|
getAccountId(): string;
|
||||||
|
getUsername(): string;
|
||||||
|
|
||||||
|
// ── Capabilities ──────────────────────────────────────────────
|
||||||
|
getCapabilities(): Record<string, unknown>;
|
||||||
|
getMaxSizeUpload(): number;
|
||||||
|
getMaxCallsInRequest(): number;
|
||||||
|
getMaxObjectsInGet(): number;
|
||||||
|
getEventSourceUrl(): string | null;
|
||||||
|
supportsEmailSubmission(): boolean;
|
||||||
|
supportsQuota(): boolean;
|
||||||
|
supportsVacationResponse(): boolean;
|
||||||
|
supportsContacts(): boolean;
|
||||||
|
supportsCalendars(): boolean;
|
||||||
|
supportsSieve(): boolean;
|
||||||
|
supportsFiles(): boolean;
|
||||||
|
|
||||||
|
// ── Push / state ──────────────────────────────────────────────
|
||||||
|
setupPushNotifications(): boolean;
|
||||||
|
closePushNotifications(): void;
|
||||||
|
onConnectionChange(callback: (connected: boolean) => void): void;
|
||||||
|
onStateChange(callback: (change: StateChange) => void): void;
|
||||||
|
getLastStates(): AccountStates;
|
||||||
|
setLastStates(states: AccountStates): void;
|
||||||
|
|
||||||
|
// ── Quota ─────────────────────────────────────────────────────
|
||||||
|
getQuota(): Promise<{ used: number; total: number } | null>;
|
||||||
|
|
||||||
|
// ── Mailboxes ─────────────────────────────────────────────────
|
||||||
|
getMailboxes(): Promise<Mailbox[]>;
|
||||||
|
getAllMailboxes(): Promise<Mailbox[]>;
|
||||||
|
createMailbox(name: string, parentId?: string): Promise<Mailbox>;
|
||||||
|
updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void>;
|
||||||
|
deleteMailbox(mailboxId: string): Promise<void>;
|
||||||
|
|
||||||
|
// ── Emails ────────────────────────────────────────────────────
|
||||||
|
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
||||||
|
getEmailsInMailbox(mailboxId: string): Promise<Email[]>;
|
||||||
|
getEmail(emailId: string, accountId?: string): Promise<Email | null>;
|
||||||
|
getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>>;
|
||||||
|
searchEmails(query: string, mailboxId?: string, accountId?: string, limit?: number, position?: number): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
||||||
|
advancedSearchEmails(
|
||||||
|
filter: Record<string, unknown>,
|
||||||
|
accountId?: string,
|
||||||
|
limit?: number,
|
||||||
|
position?: number,
|
||||||
|
): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
||||||
|
|
||||||
|
// ── Email mutations ───────────────────────────────────────────
|
||||||
|
markAsRead(emailId: string, read?: boolean, accountId?: string): Promise<void>;
|
||||||
|
batchMarkAsRead(emailIds: string[], read?: boolean): Promise<void>;
|
||||||
|
toggleStar(emailId: string, starred: boolean): Promise<void>;
|
||||||
|
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>;
|
||||||
|
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
|
||||||
|
deleteEmail(emailId: string): Promise<void>;
|
||||||
|
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
|
||||||
|
batchDeleteEmails(emailIds: string[]): Promise<void>;
|
||||||
|
batchMoveEmails(emailIds: string[], toMailboxId: string): Promise<void>;
|
||||||
|
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
|
||||||
|
emptyMailbox(mailboxId: string): Promise<number>;
|
||||||
|
markAsSpam(emailId: string, accountId?: string): Promise<void>;
|
||||||
|
undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>;
|
||||||
|
|
||||||
|
// ── Threads ───────────────────────────────────────────────────
|
||||||
|
getThread(threadId: string, accountId?: string): Promise<Thread | null>;
|
||||||
|
getThreadEmails(threadId: string, accountId?: string): Promise<Email[]>;
|
||||||
|
|
||||||
|
// ── Compose / Send ────────────────────────────────────────────
|
||||||
|
createDraft(
|
||||||
|
to: string[],
|
||||||
|
subject: string,
|
||||||
|
body: string,
|
||||||
|
cc?: string[],
|
||||||
|
bcc?: string[],
|
||||||
|
identityId?: string,
|
||||||
|
fromEmail?: string,
|
||||||
|
draftId?: string,
|
||||||
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
|
||||||
|
fromName?: string,
|
||||||
|
): Promise<string>;
|
||||||
|
|
||||||
|
sendEmail(
|
||||||
|
to: string[],
|
||||||
|
subject: string,
|
||||||
|
body: string,
|
||||||
|
cc?: string[],
|
||||||
|
bcc?: string[],
|
||||||
|
identityId?: string,
|
||||||
|
fromEmail?: string,
|
||||||
|
draftId?: string,
|
||||||
|
fromName?: string,
|
||||||
|
htmlBody?: string,
|
||||||
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
|
sendImipReply(opts: {
|
||||||
|
organizerEmail: string;
|
||||||
|
organizerName?: string;
|
||||||
|
attendeeEmail: string;
|
||||||
|
attendeeName?: string;
|
||||||
|
uid: string;
|
||||||
|
summary?: string;
|
||||||
|
dtStart?: string;
|
||||||
|
dtEnd?: string;
|
||||||
|
timeZone?: string;
|
||||||
|
isAllDay?: boolean;
|
||||||
|
sequence?: number;
|
||||||
|
status: 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
|
||||||
|
identityId?: string;
|
||||||
|
}): Promise<void>;
|
||||||
|
|
||||||
|
sendImipInvitation(event: CalendarEvent): Promise<void>;
|
||||||
|
sendImipCancellation(event: CalendarEvent): Promise<void>;
|
||||||
|
|
||||||
|
// ── Blobs ─────────────────────────────────────────────────────
|
||||||
|
uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }>;
|
||||||
|
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string;
|
||||||
|
fetchBlob(blobId: string, name?: string, type?: string): Promise<Blob>;
|
||||||
|
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string>;
|
||||||
|
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer>;
|
||||||
|
downloadBlob(blobId: string, name?: string, type?: string): Promise<void>;
|
||||||
|
|
||||||
|
// ── Identities ────────────────────────────────────────────────
|
||||||
|
getIdentities(): Promise<Identity[]>;
|
||||||
|
createIdentity(
|
||||||
|
name: string,
|
||||||
|
email: string,
|
||||||
|
replyTo?: EmailAddress[] | null,
|
||||||
|
bcc?: EmailAddress[] | null,
|
||||||
|
htmlSignature?: string,
|
||||||
|
textSignature?: string,
|
||||||
|
): Promise<Identity>;
|
||||||
|
updateIdentity(
|
||||||
|
identityId: string,
|
||||||
|
updates: {
|
||||||
|
name?: string;
|
||||||
|
replyTo?: EmailAddress[] | null;
|
||||||
|
bcc?: EmailAddress[] | null;
|
||||||
|
htmlSignature?: string;
|
||||||
|
textSignature?: string;
|
||||||
|
},
|
||||||
|
): Promise<void>;
|
||||||
|
deleteIdentity(identityId: string): Promise<void>;
|
||||||
|
|
||||||
|
// ── Vacation ──────────────────────────────────────────────────
|
||||||
|
getVacationResponse(): Promise<VacationResponse>;
|
||||||
|
setVacationResponse(updates: Partial<VacationResponse>): Promise<void>;
|
||||||
|
|
||||||
|
// ── Contacts ──────────────────────────────────────────────────
|
||||||
|
getContactsAccountId(): string;
|
||||||
|
getAddressBooks(): Promise<AddressBook[]>;
|
||||||
|
getAllAddressBooks(): Promise<AddressBook[]>;
|
||||||
|
getContacts(addressBookId?: string): Promise<ContactCard[]>;
|
||||||
|
getAllContacts(): Promise<ContactCard[]>;
|
||||||
|
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
|
||||||
|
createContact(contact: Partial<ContactCard>, targetAccountId?: string): Promise<ContactCard>;
|
||||||
|
updateContact(contactId: string, updates: Partial<ContactCard>, targetAccountId?: string): Promise<void>;
|
||||||
|
deleteContact(contactId: string, targetAccountId?: string): Promise<void>;
|
||||||
|
searchContacts(query: string): Promise<ContactCard[]>;
|
||||||
|
|
||||||
|
// ── Calendars ─────────────────────────────────────────────────
|
||||||
|
getCalendarsAccountId(): string;
|
||||||
|
getCalendars(): Promise<Calendar[]>;
|
||||||
|
getAllCalendars(): Promise<Calendar[]>;
|
||||||
|
createCalendar(calendar: Partial<Calendar>, targetAccountId?: string): Promise<Calendar>;
|
||||||
|
updateCalendar(calendarId: string, updates: Partial<Calendar>, targetAccountId?: string): Promise<void>;
|
||||||
|
deleteCalendar(calendarId: string, targetAccountId?: string): Promise<void>;
|
||||||
|
getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]>;
|
||||||
|
getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null>;
|
||||||
|
createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent>;
|
||||||
|
updateCalendarEvent(
|
||||||
|
eventId: string,
|
||||||
|
updates: Partial<CalendarEvent>,
|
||||||
|
sendSchedulingMessages?: boolean,
|
||||||
|
targetAccountId?: string,
|
||||||
|
): Promise<void>;
|
||||||
|
deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<void>;
|
||||||
|
batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
|
||||||
|
queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, targetAccountId?: string): Promise<CalendarEvent[]>;
|
||||||
|
queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise<CalendarEvent[]>;
|
||||||
|
parseCalendarEvents(accountId: string, blobId: string): Promise<Partial<CalendarEvent>[]>;
|
||||||
|
|
||||||
|
// ── Sieve / Filters ──────────────────────────────────────────
|
||||||
|
getSieveAccountId(): string;
|
||||||
|
getSieveCapabilities(): SieveCapabilities | null;
|
||||||
|
getSieveScripts(): Promise<SieveScript[]>;
|
||||||
|
getSieveScriptContent(blobId: string): Promise<string>;
|
||||||
|
createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript>;
|
||||||
|
updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void>;
|
||||||
|
deleteSieveScript(scriptId: string): Promise<void>;
|
||||||
|
validateSieveScript(content: string): Promise<{ isValid: boolean; errors?: string[] }>;
|
||||||
|
|
||||||
|
// ── Files (WebDAV / FileNode) ─────────────────────────────────
|
||||||
|
getFilesAccountId(): string;
|
||||||
|
probeFileNodeSupport(): Promise<boolean>;
|
||||||
|
listFileNodes(parentId: string | null): Promise<FileNode[]>;
|
||||||
|
getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]>;
|
||||||
|
createFileDirectory(name: string, parentId: string | null): Promise<FileNode>;
|
||||||
|
createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode>;
|
||||||
|
updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void>;
|
||||||
|
destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
|
||||||
|
copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>;
|
||||||
|
|
||||||
|
// ── S/MIME raw-email helpers ──────────────────────────────────
|
||||||
|
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>): Promise<string>;
|
||||||
|
submitEmail(emailId: string, identityId: string): Promise<void>;
|
||||||
|
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string): Promise<void>;
|
||||||
|
}
|
||||||
+144
-54
@@ -1,5 +1,6 @@
|
|||||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode, FileNodeFilter } from "./types";
|
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode, FileNodeFilter } from "./types";
|
||||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
|
import type { IJMAPClient } from "./client-interface";
|
||||||
import { toWildcardQuery } from "./search-utils";
|
import { toWildcardQuery } from "./search-utils";
|
||||||
|
|
||||||
// JMAP protocol types - these are intentionally flexible due to server variations
|
// JMAP protocol types - these are intentionally flexible due to server variations
|
||||||
@@ -99,7 +100,7 @@ function computeHasMore(position: number, emailCount: number, total: number, lim
|
|||||||
return emailCount === limit;
|
return emailCount === limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class JMAPClient {
|
export class JMAPClient implements IJMAPClient {
|
||||||
private serverUrl: string;
|
private serverUrl: string;
|
||||||
private username: string;
|
private username: string;
|
||||||
private password: string;
|
private password: string;
|
||||||
@@ -762,6 +763,56 @@ export class JMAPClient {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number> {
|
||||||
|
// Query all email IDs that have the old keyword
|
||||||
|
const allIds: string[] = [];
|
||||||
|
let position = 0;
|
||||||
|
const batchSize = 100;
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-constant-condition
|
||||||
|
while (true) {
|
||||||
|
const response = await this.request([
|
||||||
|
["Email/query", {
|
||||||
|
accountId: this.accountId,
|
||||||
|
filter: { hasKeyword: oldKeyword },
|
||||||
|
limit: batchSize,
|
||||||
|
position,
|
||||||
|
}, "0"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const queryResult = response.methodResponses?.[0]?.[1];
|
||||||
|
const ids: string[] = queryResult?.ids || [];
|
||||||
|
allIds.push(...ids);
|
||||||
|
|
||||||
|
if (ids.length < batchSize) break;
|
||||||
|
position += ids.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allIds.length === 0) return 0;
|
||||||
|
|
||||||
|
// Batch update: remove old keyword, add new keyword using per-property patches
|
||||||
|
const updateBatchSize = 50;
|
||||||
|
for (let i = 0; i < allIds.length; i += updateBatchSize) {
|
||||||
|
const batch = allIds.slice(i, i + updateBatchSize);
|
||||||
|
const update: Record<string, Record<string, boolean | null>> = {};
|
||||||
|
for (const id of batch) {
|
||||||
|
update[id] = {
|
||||||
|
[`keywords/${oldKeyword}`]: null,
|
||||||
|
[`keywords/${newKeyword}`]: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.request([
|
||||||
|
["Email/set", {
|
||||||
|
accountId: this.accountId,
|
||||||
|
update,
|
||||||
|
}, "0"],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
async deleteEmail(emailId: string): Promise<void> {
|
async deleteEmail(emailId: string): Promise<void> {
|
||||||
await this.request([
|
await this.request([
|
||||||
["Email/set", {
|
["Email/set", {
|
||||||
@@ -1358,34 +1409,35 @@ export class JMAPClient {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Destroy old draft before creating replacement to avoid duplicates
|
// Use a single Email/set call with both destroy and create for atomicity
|
||||||
const methodCalls: JMAPMethodCall[] = [];
|
const setArgs: Record<string, unknown> = {
|
||||||
|
accountId: this.accountId,
|
||||||
|
create: { [emailId]: emailData },
|
||||||
|
};
|
||||||
if (draftId) {
|
if (draftId) {
|
||||||
methodCalls.push(["Email/set", {
|
setArgs.destroy = [draftId];
|
||||||
accountId: this.accountId, destroy: [draftId],
|
|
||||||
}, "0"]);
|
|
||||||
methodCalls.push(["Email/set", {
|
|
||||||
accountId: this.accountId, create: { [emailId]: emailData },
|
|
||||||
}, "1"]);
|
|
||||||
} else {
|
|
||||||
methodCalls.push(["Email/set", {
|
|
||||||
accountId: this.accountId, create: { [emailId]: emailData },
|
|
||||||
}, "0"]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const methodCalls: JMAPMethodCall[] = [
|
||||||
|
["Email/set", setArgs, "0"],
|
||||||
|
];
|
||||||
|
|
||||||
const response = await this.request(methodCalls);
|
const response = await this.request(methodCalls);
|
||||||
const responseIndex = draftId ? 1 : 0;
|
|
||||||
|
|
||||||
if (response.methodResponses?.[responseIndex]?.[0] === "Email/set") {
|
if (response.methodResponses?.[0]?.[0] === "Email/set") {
|
||||||
const result = response.methodResponses[responseIndex][1];
|
const result = response.methodResponses[0][1];
|
||||||
|
|
||||||
if (result.notCreated || result.notUpdated) {
|
if (result.notCreated) {
|
||||||
const errors = result.notCreated || result.notUpdated;
|
const errors = result.notCreated;
|
||||||
const firstError = Object.values(errors)[0] as { description?: string; type?: string };
|
const firstError = Object.values(errors)[0] as { description?: string; type?: string };
|
||||||
console.error('Draft save error:', firstError);
|
console.error('Draft save error:', firstError);
|
||||||
throw new Error(firstError?.description || firstError?.type || 'Failed to save draft');
|
throw new Error(firstError?.description || firstError?.type || 'Failed to save draft');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (draftId && result.notDestroyed) {
|
||||||
|
console.warn('Failed to destroy old draft:', result.notDestroyed);
|
||||||
|
}
|
||||||
|
|
||||||
if (result.created?.[emailId]) {
|
if (result.created?.[emailId]) {
|
||||||
return result.created[emailId].id;
|
return result.created[emailId].id;
|
||||||
}
|
}
|
||||||
@@ -2066,6 +2118,11 @@ export class JMAPClient {
|
|||||||
return coreCapability?.maxCallsInRequest || 50;
|
return coreCapability?.maxCallsInRequest || 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMaxObjectsInGet(): number {
|
||||||
|
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxObjectsInGet?: number } | undefined;
|
||||||
|
return coreCapability?.maxObjectsInGet || 500;
|
||||||
|
}
|
||||||
|
|
||||||
getEventSourceUrl(): string | null {
|
getEventSourceUrl(): string | null {
|
||||||
if (!this.session) return null;
|
if (!this.session) return null;
|
||||||
|
|
||||||
@@ -2428,26 +2485,62 @@ export class JMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
private async fetchPaginatedContacts(
|
||||||
try {
|
accountId: string,
|
||||||
const accountId = this.getContactsAccountId();
|
filter?: Record<string, unknown>,
|
||||||
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
|
): Promise<ContactCard[]> {
|
||||||
if (addressBookId) {
|
const batchSize = this.getMaxObjectsInGet();
|
||||||
queryArgs.filter = { inAddressBook: addressBookId };
|
const allIds: string[] = [];
|
||||||
|
let position = 0;
|
||||||
|
|
||||||
|
// Paginate ContactCard/query to collect all IDs
|
||||||
|
for (;;) {
|
||||||
|
const queryArgs: Record<string, unknown> = { accountId, position, limit: batchSize };
|
||||||
|
if (filter) {
|
||||||
|
queryArgs.filter = filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["ContactCard/query", queryArgs, "0"],
|
["ContactCard/query", queryArgs, "q"],
|
||||||
["ContactCard/get", {
|
|
||||||
accountId,
|
|
||||||
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
|
|
||||||
}, "1"],
|
|
||||||
], this.contactUsing());
|
], this.contactUsing());
|
||||||
|
|
||||||
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
|
const queryResult = response.methodResponses?.[0];
|
||||||
return (response.methodResponses[1][1].list || []) as ContactCard[];
|
if (queryResult?.[0] !== "ContactCard/query") break;
|
||||||
|
|
||||||
|
const ids: string[] = queryResult[1].ids || [];
|
||||||
|
allIds.push(...ids);
|
||||||
|
|
||||||
|
const total: number = queryResult[1].total ?? -1;
|
||||||
|
if (ids.length < batchSize || (total > 0 && allIds.length >= total)) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return [];
|
position += ids.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allIds.length === 0) return [];
|
||||||
|
|
||||||
|
// Batch ContactCard/get to respect maxObjectsInGet
|
||||||
|
const allContacts: ContactCard[] = [];
|
||||||
|
for (let i = 0; i < allIds.length; i += batchSize) {
|
||||||
|
const chunk = allIds.slice(i, i + batchSize);
|
||||||
|
const response = await this.request([
|
||||||
|
["ContactCard/get", { accountId, ids: chunk }, "g"],
|
||||||
|
], this.contactUsing());
|
||||||
|
|
||||||
|
if (response.methodResponses?.[0]?.[0] === "ContactCard/get") {
|
||||||
|
const list = (response.methodResponses[0][1].list || []) as ContactCard[];
|
||||||
|
allContacts.push(...list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allContacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
||||||
|
try {
|
||||||
|
const accountId = this.getContactsAccountId();
|
||||||
|
const filter = addressBookId ? { inAddressBook: addressBookId } : undefined;
|
||||||
|
return await this.fetchPaginatedContacts(accountId, filter);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get contacts:', error);
|
console.error('Failed to get contacts:', error);
|
||||||
return [];
|
return [];
|
||||||
@@ -2465,26 +2558,19 @@ export class JMAPClient {
|
|||||||
const account = this.accounts[accountId];
|
const account = this.accounts[accountId];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.request([
|
const rawContacts = await this.fetchPaginatedContacts(accountId);
|
||||||
["ContactCard/query", { accountId, limit: 1000 }, "0"],
|
const contacts = rawContacts.map((contact) => ({
|
||||||
["ContactCard/get", {
|
...contact,
|
||||||
accountId,
|
id: isPrimary ? contact.id : `${accountId}:${contact.id}`,
|
||||||
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
|
originalId: contact.id,
|
||||||
}, "1"],
|
addressBookIds: isPrimary ? contact.addressBookIds : (contact.addressBookIds ? Object.fromEntries(
|
||||||
], this.contactUsing());
|
Object.entries(contact.addressBookIds).map(([bookId, v]) => [`${accountId}:${bookId}`, v])
|
||||||
|
) : contact.addressBookIds),
|
||||||
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
|
accountId,
|
||||||
const rawContacts = (response.methodResponses[1][1].list || []) as ContactCard[];
|
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||||||
const contacts = rawContacts.map((contact) => ({
|
isShared: !isPrimary,
|
||||||
...contact,
|
}));
|
||||||
id: isPrimary ? contact.id : `${accountId}:${contact.id}`,
|
allContacts.push(...contacts);
|
||||||
originalId: contact.id,
|
|
||||||
accountId,
|
|
||||||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
|
||||||
isShared: !isPrimary,
|
|
||||||
}));
|
|
||||||
allContacts.push(...contacts);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to fetch contacts for account ${accountId}:`, error);
|
console.error(`Failed to fetch contacts for account ${accountId}:`, error);
|
||||||
}
|
}
|
||||||
@@ -2851,8 +2937,8 @@ export class JMAPClient {
|
|||||||
id: isPrimary ? event.id : `${accountId}:${event.id}`,
|
id: isPrimary ? event.id : `${accountId}:${event.id}`,
|
||||||
originalId: event.id,
|
originalId: event.id,
|
||||||
originalCalendarIds: event.calendarIds,
|
originalCalendarIds: event.calendarIds,
|
||||||
calendarIds: isPrimary ? event.calendarIds : Object.fromEntries(
|
calendarIds: isPrimary ? (event.calendarIds || {}) : Object.fromEntries(
|
||||||
Object.entries(event.calendarIds).map(([calId, v]) => [`${accountId}:${calId}`, v])
|
Object.entries(event.calendarIds || {}).map(([calId, v]) => [`${accountId}:${calId}`, v])
|
||||||
),
|
),
|
||||||
accountId,
|
accountId,
|
||||||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||||||
@@ -2885,6 +2971,10 @@ export class JMAPClient {
|
|||||||
filter,
|
filter,
|
||||||
limit: limit || 1000,
|
limit: limit || 1000,
|
||||||
};
|
};
|
||||||
|
// Expand recurring events into individual occurrences when a date range is provided
|
||||||
|
if (filter.after || filter.before) {
|
||||||
|
queryArgs.expandRecurrences = true;
|
||||||
|
}
|
||||||
if (sort) {
|
if (sort) {
|
||||||
queryArgs.sort = sort;
|
queryArgs.sort = sort;
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-1
@@ -252,7 +252,20 @@ export interface ContactTitle {
|
|||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RFC 9553 AddressComponent
|
||||||
|
export interface AddressComponent {
|
||||||
|
kind: 'room' | 'apartment' | 'floor' | 'building' | 'number' | 'name' | 'block' | 'subDistrict' | 'district' | 'locality' | 'region' | 'postcode' | 'country' | 'direction' | 'landmark' | 'postOfficeBox' | 'separator' | string;
|
||||||
|
value: string;
|
||||||
|
phonetic?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContactAddress {
|
export interface ContactAddress {
|
||||||
|
// RFC 9553 format
|
||||||
|
components?: AddressComponent[];
|
||||||
|
full?: string;
|
||||||
|
isOrdered?: boolean;
|
||||||
|
defaultSeparator?: string;
|
||||||
|
// Legacy flat fields (from vCard import)
|
||||||
street?: string;
|
street?: string;
|
||||||
locality?: string;
|
locality?: string;
|
||||||
region?: string;
|
region?: string;
|
||||||
@@ -284,9 +297,27 @@ export interface ContactMedia {
|
|||||||
mediaType?: string;
|
mediaType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RFC 9553 PartialDate
|
||||||
|
export interface PartialDate {
|
||||||
|
'@type'?: 'PartialDate';
|
||||||
|
year?: number;
|
||||||
|
month?: number;
|
||||||
|
day?: number;
|
||||||
|
calendarScale?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 9553 Timestamp
|
||||||
|
export interface Timestamp {
|
||||||
|
'@type': 'Timestamp';
|
||||||
|
utc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnniversaryDate = string | PartialDate | Timestamp;
|
||||||
|
|
||||||
export interface ContactAnniversary {
|
export interface ContactAnniversary {
|
||||||
|
'@type'?: 'Anniversary';
|
||||||
kind: 'birth' | 'death' | 'wedding' | 'other';
|
kind: 'birth' | 'death' | 'wedding' | 'other';
|
||||||
date: string;
|
date: AnniversaryDate;
|
||||||
place?: ContactAddress;
|
place?: ContactAddress;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,7 @@
|
|||||||
export const OAUTH_SCOPES = 'openid email profile';
|
export const OAUTH_SCOPES = 'openid email profile';
|
||||||
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. */
|
||||||
|
export function refreshTokenCookieName(slot: number): string {
|
||||||
|
return slot === 0 ? REFRESH_TOKEN_COOKIE : `${REFRESH_TOKEN_COOKIE}_${slot}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createHash, createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
import { createHash, createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||||
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
|
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';
|
||||||
@@ -45,7 +45,10 @@ export async function saveUserSettings(username: string, serverUrl: string, sett
|
|||||||
const tag = cipher.getAuthTag();
|
const tag = cipher.getAuthTag();
|
||||||
|
|
||||||
const data = Buffer.concat([iv, tag, encrypted]);
|
const data = Buffer.concat([iv, tag, encrypted]);
|
||||||
await writeFile(getSettingsPath(username, serverUrl), data);
|
const targetPath = getSettingsPath(username, serverUrl);
|
||||||
|
const tmpPath = targetPath + '.tmp';
|
||||||
|
await writeFile(tmpPath, data);
|
||||||
|
await rename(tmpPath, targetPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadUserSettings(username: string, serverUrl: string): Promise<Record<string, unknown> | null> {
|
export async function loadUserSettings(username: string, serverUrl: string): Promise<Record<string, unknown> | null> {
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { parseHtmlSafely, sanitizeSignatureHtml } from '@/lib/email-sanitization';
|
||||||
|
|
||||||
|
type SignatureSource = {
|
||||||
|
textSignature?: string;
|
||||||
|
htmlSignature?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BLOCK_TAGS = new Set([
|
||||||
|
'address',
|
||||||
|
'article',
|
||||||
|
'aside',
|
||||||
|
'blockquote',
|
||||||
|
'div',
|
||||||
|
'footer',
|
||||||
|
'header',
|
||||||
|
'li',
|
||||||
|
'nav',
|
||||||
|
'p',
|
||||||
|
'section',
|
||||||
|
'tr',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function normalizeSignatureLineBreaks(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/\r\n?/g, '\n')
|
||||||
|
.replace(/\u00a0/g, ' ')
|
||||||
|
.replace(/[ \t]+\n/g, '\n')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlToPlainText(html: string): string {
|
||||||
|
const document = parseHtmlSafely(html);
|
||||||
|
const chunks: string[] = [];
|
||||||
|
|
||||||
|
const appendText = (value: string) => {
|
||||||
|
if (!value) return;
|
||||||
|
const normalized = value.replace(/\s+/g, ' ');
|
||||||
|
if (!normalized.trim()) return;
|
||||||
|
const previous = chunks[chunks.length - 1];
|
||||||
|
if (previous && !previous.endsWith('\n') && !previous.endsWith(' ')) {
|
||||||
|
chunks.push(' ');
|
||||||
|
}
|
||||||
|
chunks.push(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendNewline = () => {
|
||||||
|
const previous = chunks[chunks.length - 1];
|
||||||
|
if (previous === '\n') return;
|
||||||
|
if (previous?.endsWith('\n')) return;
|
||||||
|
chunks.push('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const walk = (node: Node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
appendText(node.textContent || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = node as HTMLElement;
|
||||||
|
const tagName = element.tagName.toLowerCase();
|
||||||
|
|
||||||
|
if (tagName === 'br') {
|
||||||
|
appendNewline();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagName === 'a') {
|
||||||
|
const text = element.textContent?.replace(/\s+/g, ' ').trim() || '';
|
||||||
|
const href = element.getAttribute('href')?.trim() || '';
|
||||||
|
const normalizedHref = href.replace(/^mailto:/i, '');
|
||||||
|
if (text && normalizedHref && text === normalizedHref) {
|
||||||
|
appendText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (text && href && text !== href) {
|
||||||
|
appendText(`${text} <${href}>`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BLOCK_TAGS.has(tagName) && chunks.length > 0) {
|
||||||
|
appendNewline();
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.from(element.childNodes).forEach(walk);
|
||||||
|
|
||||||
|
if (BLOCK_TAGS.has(tagName)) {
|
||||||
|
appendNewline();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Array.from(document.body.childNodes).forEach(walk);
|
||||||
|
return normalizeSignatureLineBreaks(chunks.join(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlainTextSignature(signature?: SignatureSource | null): string {
|
||||||
|
if (signature?.textSignature?.trim()) {
|
||||||
|
return normalizeSignatureLineBreaks(signature.textSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signature?.htmlSignature?.trim()) {
|
||||||
|
return htmlToPlainText(sanitizeSignatureHtml(signature.htmlSignature));
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendPlainTextSignature(body: string, signature?: SignatureSource | null): string {
|
||||||
|
const plainTextSignature = getPlainTextSignature(signature);
|
||||||
|
if (!plainTextSignature) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${body}\n\n-- \n${plainTextSignature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasMeaningfulHtmlBody(html: string): boolean {
|
||||||
|
if (!html.trim()) return false;
|
||||||
|
|
||||||
|
const document = parseHtmlSafely(html);
|
||||||
|
const richSelector = [
|
||||||
|
'table',
|
||||||
|
'img',
|
||||||
|
'style',
|
||||||
|
'b',
|
||||||
|
'strong',
|
||||||
|
'i',
|
||||||
|
'em',
|
||||||
|
'u',
|
||||||
|
'font',
|
||||||
|
'a[href]',
|
||||||
|
'div[style]',
|
||||||
|
'span[style]',
|
||||||
|
'p[style]',
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'h6',
|
||||||
|
'ul',
|
||||||
|
'ol',
|
||||||
|
'blockquote',
|
||||||
|
'br',
|
||||||
|
].join(', ');
|
||||||
|
|
||||||
|
if (document.querySelector(richSelector)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockElements = document.body.querySelectorAll('p, div, blockquote, li');
|
||||||
|
return blockElements.length > 1;
|
||||||
|
}
|
||||||
@@ -171,13 +171,33 @@ function extractEmailAddresses(cert: pkijs.Certificate): string[] {
|
|||||||
|
|
||||||
// From SubjectAlternativeName
|
// From SubjectAlternativeName
|
||||||
const sanExt = cert.extensions?.find((e) => e.extnID === OID_SAN);
|
const sanExt = cert.extensions?.find((e) => e.extnID === OID_SAN);
|
||||||
if (sanExt?.parsedValue) {
|
if (sanExt) {
|
||||||
const san = sanExt.parsedValue as pkijs.GeneralNames;
|
let names: pkijs.GeneralName[] | undefined;
|
||||||
for (const name of san.names) {
|
|
||||||
// type 1 = rfc822Name
|
// parsedValue may be a GeneralNames with .names, or a raw ASN.1 object
|
||||||
if (name.type === 1 && typeof name.value === 'string') {
|
const pv = sanExt.parsedValue as pkijs.GeneralNames | undefined;
|
||||||
if (!emails.includes(name.value)) {
|
if (pv?.names) {
|
||||||
emails.push(name.value);
|
names = pv.names;
|
||||||
|
} else if (sanExt.extnValue) {
|
||||||
|
// Manually parse the extension value as a SEQUENCE OF GeneralName
|
||||||
|
try {
|
||||||
|
const sanAsn1 = asn1js.fromBER(sanExt.extnValue.valueBlock.valueHexView);
|
||||||
|
if (sanAsn1.offset !== -1) {
|
||||||
|
const gn = new pkijs.GeneralNames({ schema: sanAsn1.result });
|
||||||
|
names = gn.names;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Malformed SAN — skip gracefully
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (names) {
|
||||||
|
for (const name of names) {
|
||||||
|
// type 1 = rfc822Name
|
||||||
|
if (name.type === 1 && typeof name.value === 'string') {
|
||||||
|
if (!emails.includes(name.value)) {
|
||||||
|
emails.push(name.value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+182
-3
@@ -7,21 +7,200 @@
|
|||||||
*
|
*
|
||||||
* Native Web Crypto calls are passed through to the real implementation;
|
* Native Web Crypto calls are passed through to the real implementation;
|
||||||
* liner only intercepts algorithms that the browser doesn't natively support.
|
* liner only intercepts algorithms that the browser doesn't natively support.
|
||||||
|
*
|
||||||
|
* Additionally, pkijs's CryptoEngine.decryptEncryptedContentInfo only
|
||||||
|
* handles PBES2 (OID 1.2.840.113549.1.5.13). Many PKCS#12 files use
|
||||||
|
* legacy PBE algorithms (e.g. pbeWithSHAAnd3-KeyTripleDES-CBC). We
|
||||||
|
* extend CryptoEngine to handle those via RFC 7292 Appendix B key
|
||||||
|
* derivation + webcrypto-liner's DES-EDE3-CBC support.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as asn1js from 'asn1js';
|
||||||
import * as pkijs from 'pkijs';
|
import * as pkijs from 'pkijs';
|
||||||
|
|
||||||
// webcrypto-liner exports a Crypto constructor at runtime that extends native
|
// webcrypto-liner exports a Crypto constructor at runtime that extends native
|
||||||
// Web Crypto with legacy algorithms (3DES, etc.). Its type declarations only
|
// Web Crypto with legacy algorithms (3DES, etc.). Its type declarations only
|
||||||
// expose the type alias, so we import the module dynamically and cast.
|
// expose the type alias, so we import the module dynamically and cast.
|
||||||
|
// Import the ES module build directly — the package's "browser" field points
|
||||||
|
// to a shim-only build that has no named exports (no setCrypto, Crypto, etc.).
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const liner = require('webcrypto-liner') as {
|
const liner = require('webcrypto-liner/build/index.es.js') as {
|
||||||
Crypto: { new (): Crypto };
|
Crypto: { new (): Crypto };
|
||||||
setCrypto: (subtle: SubtleCrypto) => void;
|
setCrypto: (subtle: SubtleCrypto) => void;
|
||||||
nativeCrypto: Crypto | Record<string, never>;
|
nativeCrypto: Crypto | Record<string, never>;
|
||||||
};
|
};
|
||||||
|
|
||||||
let linerEngine: pkijs.CryptoEngine | null = null;
|
// ── PKCS#12 legacy PBE OIDs ──────────────────────────────────────────
|
||||||
|
const PBE_SHA1_3DES_3KEY = '1.2.840.113549.1.12.1.3'; // pbeWithSHAAnd3-KeyTripleDES-CBC
|
||||||
|
const PBE_SHA1_3DES_2KEY = '1.2.840.113549.1.12.1.4'; // pbeWithSHAAnd2-KeyTripleDES-CBC
|
||||||
|
const PBE_SHA1_RC2_128 = '1.2.840.113549.1.12.1.5'; // pbeWithSHAAnd128BitRC2-CBC
|
||||||
|
const PBE_SHA1_RC2_40 = '1.2.840.113549.1.12.1.6'; // pbeWithSHAAnd40BitRC2-CBC
|
||||||
|
|
||||||
|
const LEGACY_PBE_OIDS = new Set([
|
||||||
|
PBE_SHA1_3DES_3KEY,
|
||||||
|
PBE_SHA1_3DES_2KEY,
|
||||||
|
PBE_SHA1_RC2_128,
|
||||||
|
PBE_SHA1_RC2_40,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Algorithm config for each legacy PBE OID. */
|
||||||
|
function pbeConfig(oid: string): { keyLen: number; ivLen: number; algName: string } {
|
||||||
|
switch (oid) {
|
||||||
|
case PBE_SHA1_3DES_3KEY: return { keyLen: 24, ivLen: 8, algName: 'DES-EDE3-CBC' };
|
||||||
|
case PBE_SHA1_3DES_2KEY: return { keyLen: 16, ivLen: 8, algName: 'DES-EDE3-CBC' };
|
||||||
|
case PBE_SHA1_RC2_128: return { keyLen: 16, ivLen: 8, algName: 'RC2-CBC' };
|
||||||
|
case PBE_SHA1_RC2_40: return { keyLen: 5, ivLen: 8, algName: 'RC2-CBC' };
|
||||||
|
default: throw new Error(`Unsupported legacy PBE OID: ${oid}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PKCS#12 key derivation — RFC 7292, Appendix B.
|
||||||
|
*
|
||||||
|
* @param password BMP-encoded password (with trailing 0x00 0x00)
|
||||||
|
* @param salt raw salt bytes
|
||||||
|
* @param iterations PBKDF iteration count
|
||||||
|
* @param id 1 = key material, 2 = IV, 3 = MAC key
|
||||||
|
* @param needed number of bytes to derive
|
||||||
|
*/
|
||||||
|
async function pkcs12KDF(
|
||||||
|
password: Uint8Array,
|
||||||
|
salt: Uint8Array,
|
||||||
|
iterations: number,
|
||||||
|
id: number,
|
||||||
|
needed: number,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const v = 64; // SHA-1 block size
|
||||||
|
const u = 20; // SHA-1 output size
|
||||||
|
|
||||||
|
// Step 1: diversifier D = v bytes of 'id'
|
||||||
|
const D = new Uint8Array(v);
|
||||||
|
D.fill(id);
|
||||||
|
|
||||||
|
// Step 2: fill S from salt, padded/repeated to v-byte boundary
|
||||||
|
const sLen = salt.length === 0 ? 0 : v * Math.ceil(salt.length / v);
|
||||||
|
const S = new Uint8Array(sLen);
|
||||||
|
for (let i = 0; i < sLen; i++) S[i] = salt[i % salt.length];
|
||||||
|
|
||||||
|
// Step 3: fill P from password, padded/repeated to v-byte boundary
|
||||||
|
const pLen = password.length === 0 ? 0 : v * Math.ceil(password.length / v);
|
||||||
|
const P = new Uint8Array(pLen);
|
||||||
|
for (let i = 0; i < pLen; i++) P[i] = password[i % password.length];
|
||||||
|
|
||||||
|
// I = S || P
|
||||||
|
const I = new Uint8Array(sLen + pLen);
|
||||||
|
I.set(S, 0);
|
||||||
|
I.set(P, sLen);
|
||||||
|
|
||||||
|
const c = Math.ceil(needed / u);
|
||||||
|
const result = new Uint8Array(c * u);
|
||||||
|
|
||||||
|
for (let i = 0; i < c; i++) {
|
||||||
|
// Aj = Hash^iterations(D || I)
|
||||||
|
const buf = new Uint8Array(v + I.length);
|
||||||
|
buf.set(D, 0);
|
||||||
|
buf.set(I, v);
|
||||||
|
|
||||||
|
let A = new Uint8Array(await crypto.subtle.digest('SHA-1', buf));
|
||||||
|
for (let j = 1; j < iterations; j++) {
|
||||||
|
A = new Uint8Array(await crypto.subtle.digest('SHA-1', A));
|
||||||
|
}
|
||||||
|
|
||||||
|
result.set(A, i * u);
|
||||||
|
|
||||||
|
if (i + 1 < c) {
|
||||||
|
// Build B by repeating A to fill v bytes
|
||||||
|
const B = new Uint8Array(v);
|
||||||
|
for (let j = 0; j < v; j++) B[j] = A[j % u];
|
||||||
|
|
||||||
|
// I[j] = (I[j] + B + 1) mod 2^v for each v-byte block
|
||||||
|
for (let j = 0; j < I.length; j += v) {
|
||||||
|
let carry = 1;
|
||||||
|
for (let k = v - 1; k >= 0; k--) {
|
||||||
|
const sum = I[j + k] + B[k] + carry;
|
||||||
|
I[j + k] = sum & 0xff;
|
||||||
|
carry = sum >> 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.slice(0, needed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encode a password as BMP string with trailing NUL pair (RFC 7292 §B.1). */
|
||||||
|
function passwordToBMP(password: ArrayBuffer): Uint8Array {
|
||||||
|
const passView = new Uint8Array(password);
|
||||||
|
// If already BMP-encoded (even length, every odd byte is 0x00 for ASCII),
|
||||||
|
// or empty, use as-is. Otherwise convert char codes to big-endian UCS-2.
|
||||||
|
// pkijs passes the password as a raw ArrayBuffer of char codes.
|
||||||
|
const bmp = new Uint8Array(passView.length * 2 + 2);
|
||||||
|
for (let i = 0; i < passView.length; i++) {
|
||||||
|
bmp[i * 2] = 0;
|
||||||
|
bmp[i * 2 + 1] = passView[i];
|
||||||
|
}
|
||||||
|
// trailing 0x00 0x00
|
||||||
|
bmp[bmp.length - 2] = 0;
|
||||||
|
bmp[bmp.length - 1] = 0;
|
||||||
|
return bmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended CryptoEngine that handles legacy PKCS#12 PBE algorithms.
|
||||||
|
* Falls through to the base CryptoEngine for everything else.
|
||||||
|
*/
|
||||||
|
class Pkcs12CryptoEngine extends pkijs.CryptoEngine {
|
||||||
|
async decryptEncryptedContentInfo(
|
||||||
|
parameters: Parameters<pkijs.CryptoEngine['decryptEncryptedContentInfo']>[0],
|
||||||
|
): Promise<ArrayBuffer> {
|
||||||
|
const oid = parameters.encryptedContentInfo.contentEncryptionAlgorithm.algorithmId;
|
||||||
|
|
||||||
|
if (!LEGACY_PBE_OIDS.has(oid)) {
|
||||||
|
// Delegate to base CryptoEngine (handles PBES2)
|
||||||
|
return super.decryptEncryptedContentInfo(parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
const algParams = parameters.encryptedContentInfo.contentEncryptionAlgorithm.algorithmParams;
|
||||||
|
if (!algParams) {
|
||||||
|
throw new Error('Missing PBE algorithm parameters');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse PBEParameter ::= SEQUENCE { salt OCTET STRING, iterationCount INTEGER }
|
||||||
|
const paramAsn1 = asn1js.fromBER(algParams.toBER(false));
|
||||||
|
if (paramAsn1.offset === -1) {
|
||||||
|
throw new Error('Invalid PBE parameters ASN.1');
|
||||||
|
}
|
||||||
|
const seq = paramAsn1.result as asn1js.Sequence;
|
||||||
|
const salt = new Uint8Array((seq.valueBlock.value[0] as asn1js.OctetString).valueBlock.valueHexView);
|
||||||
|
const iterations = (seq.valueBlock.value[1] as asn1js.Integer).valueBlock.valueDec;
|
||||||
|
|
||||||
|
const { keyLen, ivLen, algName } = pbeConfig(oid);
|
||||||
|
const bmpPassword = passwordToBMP(parameters.password);
|
||||||
|
|
||||||
|
// Derive key (id=1) and IV (id=2) using PKCS#12 KDF
|
||||||
|
const keyBytes = await pkcs12KDF(bmpPassword, salt, iterations, 1, keyLen);
|
||||||
|
const ivBytes = await pkcs12KDF(bmpPassword, salt, iterations, 2, ivLen);
|
||||||
|
|
||||||
|
// Import key via webcrypto-liner (supports DES-EDE3-CBC)
|
||||||
|
const cryptoKey = await this.importKey(
|
||||||
|
'raw',
|
||||||
|
new Uint8Array(keyBytes.buffer as ArrayBuffer, keyBytes.byteOffset, keyBytes.byteLength) as unknown as BufferSource,
|
||||||
|
{ name: algName, length: keyLen * 8 } as Algorithm,
|
||||||
|
false,
|
||||||
|
['decrypt'],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
const ciphertext = parameters.encryptedContentInfo.getEncryptedContent();
|
||||||
|
return this.decrypt(
|
||||||
|
{ name: algName, iv: ivBytes } as Algorithm,
|
||||||
|
cryptoKey,
|
||||||
|
ciphertext,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let linerEngine: Pkcs12CryptoEngine | null = null;
|
||||||
let linerCryptoInstance: Crypto | null = null;
|
let linerCryptoInstance: Crypto | null = null;
|
||||||
|
|
||||||
function ensureLiner() {
|
function ensureLiner() {
|
||||||
@@ -39,7 +218,7 @@ function ensureLiner() {
|
|||||||
linerCryptoInstance = new liner.Crypto();
|
linerCryptoInstance = new liner.Crypto();
|
||||||
}
|
}
|
||||||
if (!linerEngine) {
|
if (!linerEngine) {
|
||||||
linerEngine = new pkijs.CryptoEngine({
|
linerEngine = new Pkcs12CryptoEngine({
|
||||||
crypto: linerCryptoInstance,
|
crypto: linerCryptoInstance,
|
||||||
subtle: linerCryptoInstance.subtle,
|
subtle: linerCryptoInstance.subtle,
|
||||||
name: 'webcrypto-liner',
|
name: 'webcrypto-liner',
|
||||||
|
|||||||
+10
-6
@@ -97,17 +97,19 @@ const ROLE_PRIORITY: Record<string, number> = {
|
|||||||
|
|
||||||
// Deduplicate mailboxes (e.g., "Sent" vs "Sent Mail")
|
// Deduplicate mailboxes (e.g., "Sent" vs "Sent Mail")
|
||||||
function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
||||||
const roleMap = new Map<string, Mailbox>();
|
|
||||||
const result: Mailbox[] = [];
|
const result: Mailbox[] = [];
|
||||||
|
|
||||||
// First pass: collect mailboxes with roles
|
// Group role mailboxes by account so deduplication is scoped per-account
|
||||||
|
const rolesByAccount = new Map<string, Mailbox[]>();
|
||||||
mailboxes.forEach(mb => {
|
mailboxes.forEach(mb => {
|
||||||
if (mb.role) {
|
if (mb.role) {
|
||||||
roleMap.set(mb.role, mb);
|
const key = mb.accountId || '';
|
||||||
|
if (!rolesByAccount.has(key)) rolesByAccount.set(key, []);
|
||||||
|
rolesByAccount.get(key)!.push(mb);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Second pass: filter out duplicates
|
// Filter out duplicates scoped to the same account
|
||||||
mailboxes.forEach(mb => {
|
mailboxes.forEach(mb => {
|
||||||
// If this mailbox has a role, always keep it
|
// If this mailbox has a role, always keep it
|
||||||
if (mb.role) {
|
if (mb.role) {
|
||||||
@@ -115,9 +117,11 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a duplicate of a role-based mailbox
|
// Check if this is a duplicate of a role-based mailbox in the SAME account
|
||||||
|
const accountKey = mb.accountId || '';
|
||||||
|
const accountRoles = rolesByAccount.get(accountKey) || [];
|
||||||
const lowerName = mb.name.toLowerCase();
|
const lowerName = mb.name.toLowerCase();
|
||||||
const isDuplicate = Array.from(roleMap.values()).some(roleMb => {
|
const isDuplicate = accountRoles.some(roleMb => {
|
||||||
const roleLowerName = roleMb.name.toLowerCase();
|
const roleLowerName = roleMb.name.toLowerCase();
|
||||||
// Check for common duplicates: "Sent Mail" vs "Sent", etc.
|
// Check for common duplicates: "Sent Mail" vs "Sent", etc.
|
||||||
return lowerName.includes(roleLowerName) || roleLowerName.includes(lowerName);
|
return lowerName.includes(roleLowerName) || roleLowerName.includes(lowerName);
|
||||||
|
|||||||
+48
-9
@@ -1,4 +1,26 @@
|
|||||||
import type { ContactCard, NameComponent, ContactMedia, ContactOnlineService } from "@/lib/jmap/types";
|
import type { ContactCard, NameComponent, ContactMedia, ContactOnlineService, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
||||||
|
|
||||||
|
// Convert RFC 9553 AnniversaryDate (PartialDate|Timestamp|string) to vCard date string
|
||||||
|
function anniversaryDateToVcardString(date: AnniversaryDate): string {
|
||||||
|
if (typeof date === 'string') return date;
|
||||||
|
if (date && typeof date === 'object') {
|
||||||
|
if ('@type' in date && date['@type'] === 'Timestamp' && 'utc' in date) {
|
||||||
|
return (date as { utc: string }).utc.split('T')[0];
|
||||||
|
}
|
||||||
|
const pd = date as PartialDate;
|
||||||
|
if (pd.year && pd.month && pd.day) {
|
||||||
|
return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
if (pd.month && pd.day) {
|
||||||
|
return `--${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
if (pd.year && pd.month) {
|
||||||
|
return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
if (pd.year) return String(pd.year);
|
||||||
|
}
|
||||||
|
return String(date);
|
||||||
|
}
|
||||||
|
|
||||||
const VCARD_SEX_TO_GENDER: Record<string, string> = {
|
const VCARD_SEX_TO_GENDER: Record<string, string> = {
|
||||||
M: "masculine",
|
M: "masculine",
|
||||||
@@ -598,14 +620,30 @@ function generateSingleVCard(contact: ContactCard): string {
|
|||||||
for (const addr of Object.values(contact.addresses)) {
|
for (const addr of Object.values(contact.addresses)) {
|
||||||
const type = contextToType(addr.contexts);
|
const type = contextToType(addr.contexts);
|
||||||
const typeParam = type ? `;TYPE=${type}` : "";
|
const typeParam = type ? `;TYPE=${type}` : "";
|
||||||
|
let street = addr.street || "";
|
||||||
|
let locality = addr.locality || "";
|
||||||
|
let region = addr.region || "";
|
||||||
|
let postcode = addr.postcode || "";
|
||||||
|
let country = addr.country || "";
|
||||||
|
// RFC 9553 components-based address: extract flat fields for vCard ADR
|
||||||
|
if (addr.components && addr.components.length > 0) {
|
||||||
|
const findComp = (kind: string) => addr.components!.filter(c => c.kind === kind).map(c => c.value).join(' ');
|
||||||
|
const number = findComp('number');
|
||||||
|
const name = findComp('name');
|
||||||
|
street = street || [number, name].filter(Boolean).join(' ');
|
||||||
|
locality = locality || findComp('locality');
|
||||||
|
region = region || findComp('region');
|
||||||
|
postcode = postcode || findComp('postcode');
|
||||||
|
country = country || findComp('country');
|
||||||
|
}
|
||||||
const parts = [
|
const parts = [
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
addr.street || "",
|
street,
|
||||||
addr.locality || "",
|
locality,
|
||||||
addr.region || "",
|
region,
|
||||||
addr.postcode || "",
|
postcode,
|
||||||
addr.country || "",
|
country,
|
||||||
];
|
];
|
||||||
lines.push(`ADR${typeParam}:${parts.map(encodeValue).join(";")}`);
|
lines.push(`ADR${typeParam}:${parts.map(encodeValue).join(";")}`);
|
||||||
}
|
}
|
||||||
@@ -613,12 +651,13 @@ function generateSingleVCard(contact: ContactCard): string {
|
|||||||
|
|
||||||
if (contact.anniversaries) {
|
if (contact.anniversaries) {
|
||||||
for (const ann of Object.values(contact.anniversaries)) {
|
for (const ann of Object.values(contact.anniversaries)) {
|
||||||
|
const dateStr = anniversaryDateToVcardString(ann.date);
|
||||||
if (ann.kind === "birth") {
|
if (ann.kind === "birth") {
|
||||||
lines.push(`BDAY:${ann.date}`);
|
lines.push(`BDAY:${dateStr}`);
|
||||||
} else if (ann.kind === "wedding") {
|
} else if (ann.kind === "wedding") {
|
||||||
lines.push(`ANNIVERSARY:${ann.date}`);
|
lines.push(`ANNIVERSARY:${dateStr}`);
|
||||||
} else if (ann.kind === "death") {
|
} else if (ann.kind === "death") {
|
||||||
lines.push(`DEATHDATE:${ann.date}`);
|
lines.push(`DEATHDATE:${dateStr}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+97
-5
@@ -34,9 +34,18 @@
|
|||||||
"dismiss": "Schließen",
|
"dismiss": "Schließen",
|
||||||
"or": "oder",
|
"or": "oder",
|
||||||
"sign_in_sso": "Mit SSO anmelden",
|
"sign_in_sso": "Mit SSO anmelden",
|
||||||
|
"add_account_title": "Konto hinzufügen",
|
||||||
|
"add_account_subtitle": "Mit einem anderen Konto anmelden",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
"website": "Webseite",
|
"website": "Webseite",
|
||||||
"imprint": "Impressum",
|
"imprint": "Impressum",
|
||||||
"privacy_policy": "Datenschutz",
|
"privacy_policy": "Datenschutz",
|
||||||
|
"try_demo": "Demo testen",
|
||||||
|
"demo_description": "Erkunden Sie mit Beispieldaten — kein Konto nötig",
|
||||||
|
"demo_launching": "Demo wird gestartet...",
|
||||||
|
"demo_login_button": "Demo starten",
|
||||||
|
"demo_tagline": "Erleben Sie einen voll ausgestatteten E-Mail-Client. Kein Konto erforderlich.",
|
||||||
|
"demo_no_signup": "Keine Registrierung nötig — erkunden Sie frei mit Beispieldaten",
|
||||||
"oauth_completing": "Anmeldung wird abgeschlossen...",
|
"oauth_completing": "Anmeldung wird abgeschlossen...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Authentifizierung fehlgeschlagen",
|
"title": "Authentifizierung fehlgeschlagen",
|
||||||
@@ -58,6 +67,11 @@
|
|||||||
"storage_free": "Frei",
|
"storage_free": "Frei",
|
||||||
"storage_total": "Gesamt",
|
"storage_total": "Gesamt",
|
||||||
"sign_out": "Abmelden",
|
"sign_out": "Abmelden",
|
||||||
|
"sign_out_of": "Von {account} abmelden",
|
||||||
|
"sign_out_all": "Von allen Konten abmelden",
|
||||||
|
"add_account": "Konto hinzufügen",
|
||||||
|
"set_as_default": "Als Standard festlegen",
|
||||||
|
"switch_account": "Konto wechseln",
|
||||||
"contacts": "Kontakte",
|
"contacts": "Kontakte",
|
||||||
"calendar": "Kalender",
|
"calendar": "Kalender",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
@@ -96,6 +110,9 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Suche löschen",
|
"clear_search": "Suche löschen",
|
||||||
"vacation_active": "Abwesenheitsnotiz ist aktiv",
|
"vacation_active": "Abwesenheitsnotiz ist aktiv",
|
||||||
|
"demo_banner": "Demo-Modus",
|
||||||
|
"demo_reset": "Zurücksetzen",
|
||||||
|
"demo_tour": "Tour",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"mail": "E-Mail",
|
"mail": "E-Mail",
|
||||||
"nav_label": "Navigation",
|
"nav_label": "Navigation",
|
||||||
@@ -202,6 +219,7 @@
|
|||||||
"attachments": "Anhänge",
|
"attachments": "Anhänge",
|
||||||
"important": "Wichtig",
|
"important": "Wichtig",
|
||||||
"download": "Herunterladen",
|
"download": "Herunterladen",
|
||||||
|
"download_all": "Alle herunterladen",
|
||||||
"from": "Von",
|
"from": "Von",
|
||||||
"to": "An",
|
"to": "An",
|
||||||
"cc": "CC",
|
"cc": "CC",
|
||||||
@@ -393,7 +411,8 @@
|
|||||||
},
|
},
|
||||||
"previous": "Zurück",
|
"previous": "Zurück",
|
||||||
"next": "Weiter",
|
"next": "Weiter",
|
||||||
"send": "Senden"
|
"send": "Senden",
|
||||||
|
"more": "mehr"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "Neue Nachricht",
|
"new_message": "Neue Nachricht",
|
||||||
@@ -745,6 +764,12 @@
|
|||||||
"preview": "Wenn möglich in Vorschau öffnen",
|
"preview": "Wenn möglich in Vorschau öffnen",
|
||||||
"download": "Sofort herunterladen"
|
"download": "Sofort herunterladen"
|
||||||
},
|
},
|
||||||
|
"attachment_position": {
|
||||||
|
"label": "Anhangsposition",
|
||||||
|
"description": "Wo Anhänge im E-Mail-Header angezeigt werden",
|
||||||
|
"beside-sender": "Neben dem Absender",
|
||||||
|
"below-header": "Unter dem Header"
|
||||||
|
},
|
||||||
"emails_per_page": {
|
"emails_per_page": {
|
||||||
"25": "25 E-Mails",
|
"25": "25 E-Mails",
|
||||||
"50": "50 E-Mails",
|
"50": "50 E-Mails",
|
||||||
@@ -831,6 +856,9 @@
|
|||||||
"account": {
|
"account": {
|
||||||
"title": "Konto",
|
"title": "Konto",
|
||||||
"description": "Zeigen Sie Ihre Kontoinformationen an",
|
"description": "Zeigen Sie Ihre Kontoinformationen an",
|
||||||
|
"name_label": "Anzeigename",
|
||||||
|
"account_type_label": "Kontotyp",
|
||||||
|
"demo_account": "Demokonto",
|
||||||
"email": {
|
"email": {
|
||||||
"label": "E-Mail-Adresse",
|
"label": "E-Mail-Adresse",
|
||||||
"value": "{email}"
|
"value": "{email}"
|
||||||
@@ -1454,6 +1482,9 @@
|
|||||||
"title": "Kontakte",
|
"title": "Kontakte",
|
||||||
"search_placeholder": "Kontakte suchen...",
|
"search_placeholder": "Kontakte suchen...",
|
||||||
"create_new": "Neuer Kontakt",
|
"create_new": "Neuer Kontakt",
|
||||||
|
"no_category": "Ohne Kategorie",
|
||||||
|
"category_added": "Kontakt zu {name} hinzugefügt",
|
||||||
|
"category_added_plural": "{count} Kontakte zu {name} hinzugefügt",
|
||||||
"empty_state": "Keine Kontakte",
|
"empty_state": "Keine Kontakte",
|
||||||
"empty_state_title": "Keine Kontakte",
|
"empty_state_title": "Keine Kontakte",
|
||||||
"empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei",
|
"empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei",
|
||||||
@@ -1473,11 +1504,12 @@
|
|||||||
"title": "Geteilt"
|
"title": "Geteilt"
|
||||||
},
|
},
|
||||||
"address_books": {
|
"address_books": {
|
||||||
"title": "Verzeichnisse",
|
"title": "Meine Adressbücher",
|
||||||
|
"shared_prefix": "Geteilt: {name}",
|
||||||
"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": "Verzeichnis"
|
"address_book": "Adressbuch"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "E-Mail-Adressen",
|
"emails": "E-Mail-Adressen",
|
||||||
@@ -1596,7 +1628,8 @@
|
|||||||
"level_low": "Niedrig",
|
"level_low": "Niedrig",
|
||||||
"categories": "Kategorien",
|
"categories": "Kategorien",
|
||||||
"categories_placeholder": "z. B. Familie, Freunde, Kollegen",
|
"categories_placeholder": "z. B. Familie, Freunde, Kollegen",
|
||||||
"categories_hint": "Mit Kommas trennen",
|
"categories_hint": "Tippen zum Suchen oder Hinzufügen",
|
||||||
|
"category_add": "Hinzufügen",
|
||||||
"note": "Notizen",
|
"note": "Notizen",
|
||||||
"note_placeholder": "Notiz hinzufügen...",
|
"note_placeholder": "Notiz hinzufügen...",
|
||||||
"gender": "Geschlecht",
|
"gender": "Geschlecht",
|
||||||
@@ -1938,6 +1971,12 @@
|
|||||||
"deleted": "Abonnement entfernt",
|
"deleted": "Abonnement entfernt",
|
||||||
"delete_error": "Abonnement konnte nicht entfernt werden",
|
"delete_error": "Abonnement konnte nicht entfernt werden",
|
||||||
"last_refreshed": "Zuletzt aktualisiert: {time}"
|
"last_refreshed": "Zuletzt aktualisiert: {time}"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"no_tasks": "Keine Aufgaben",
|
||||||
|
"no_title": "(Kein Titel)",
|
||||||
|
"mark_complete": "Als erledigt markieren",
|
||||||
|
"mark_incomplete": "Als unerledigt markieren"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"advanced_search": {
|
"advanced_search": {
|
||||||
@@ -1978,7 +2017,17 @@
|
|||||||
"tip_settings": "Passen Sie Ihre Erfahrung in den Einstellungen an",
|
"tip_settings": "Passen Sie Ihre Erfahrung in den Einstellungen an",
|
||||||
"got_it": "Verstanden",
|
"got_it": "Verstanden",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"dismiss": "Schließen"
|
"dismiss": "Schließen",
|
||||||
|
"start_tour": "Tour starten"
|
||||||
|
},
|
||||||
|
"demo_welcome": {
|
||||||
|
"title": "Willkommen bei Bulwark Mail",
|
||||||
|
"description": "Entdecken Sie einen voll ausgestatteten Webmail-Client — direkt in Ihrem Browser. Alle Daten bleiben auf Ihrem Gerät, also testen Sie alles.",
|
||||||
|
"feature_email": "E-Mails lesen & verfassen",
|
||||||
|
"feature_organize": "Tags, Sterne & Ordner",
|
||||||
|
"feature_shortcuts": "Tastenkürzel",
|
||||||
|
"feature_privacy": "100 % private Demo",
|
||||||
|
"hint": "Klicken Sie links auf eine E-Mail, um loszulegen, oder starten Sie die Tour."
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Dateien",
|
"title": "Dateien",
|
||||||
@@ -2158,5 +2207,48 @@
|
|||||||
"export_passphrase_desc": "Wählen Sie eine Passphrase zum Schutz der exportierten PKCS#12-Datei",
|
"export_passphrase_desc": "Wählen Sie eine Passphrase zum Schutz der exportierten PKCS#12-Datei",
|
||||||
"export_storage_desc": "Geben Sie die Speicher-Passphrase ein, um den Schlüssel für den Export zu entschlüsseln",
|
"export_storage_desc": "Geben Sie die Speicher-Passphrase ein, um den Schlüssel für den Export zu entschlüsseln",
|
||||||
"incorrect_passphrase": "Falsche Passphrase"
|
"incorrect_passphrase": "Falsche Passphrase"
|
||||||
|
},
|
||||||
|
"tour": {
|
||||||
|
"step_counter": "Schritt {current} von {total}",
|
||||||
|
"skip": "Tour überspringen",
|
||||||
|
"back": "Zurück",
|
||||||
|
"next": "Weiter",
|
||||||
|
"finish": "Fertig",
|
||||||
|
"take_a_tour": "Machen Sie eine Tour durch die Oberfläche",
|
||||||
|
"restart_title": "Einführungstour",
|
||||||
|
"restart_desc": "Geführte Tour durch die Oberfläche erneut abspielen",
|
||||||
|
"restart_button": "Tour neu starten",
|
||||||
|
"sidebar_title": "Ihre Postfächer",
|
||||||
|
"sidebar_desc": "Dies ist Ihre Ordner-Seitenleiste. Klicken Sie auf ein Postfach, um seine E-Mails anzuzeigen. Sie können Ordner erstellen, E-Mails zwischen ihnen verschieben und ungelesene Zähler auf einen Blick sehen.",
|
||||||
|
"compose_title": "E-Mail verfassen",
|
||||||
|
"compose_desc": "Klicken Sie hier, um eine neue E-Mail zu schreiben. Sie können Empfänger, Anhänge und Textformatierung hinzufügen.",
|
||||||
|
"search_title": "E-Mails durchsuchen",
|
||||||
|
"search_desc": "Suchen Sie nach Absender, Betreff oder Inhalt. Klicken Sie auf das Filtersymbol für erweiterte Optionen wie Datumsbereich, Anhänge und markierte Nachrichten.",
|
||||||
|
"email_list_title": "Ihre E-Mail-Liste",
|
||||||
|
"email_list_desc": "E-Mails erscheinen hier. Klicken Sie auf eine, um sie rechts zu lesen. Verwenden Sie die Checkbox, um mehrere auszuwählen und sie dann zu verschieben, löschen oder taggen.",
|
||||||
|
"email_viewer_title": "Lesebereich",
|
||||||
|
"email_viewer_desc": "Die ausgewählte E-Mail wird hier geöffnet. Antworten, weiterleiten, archivieren oder löschen Sie mit den Schaltflächen. Sie können auch E-Mails markieren oder Farbtags hinzufügen.",
|
||||||
|
"keywords_title": "Farbtags",
|
||||||
|
"keywords_desc": "Organisieren Sie Ihre E-Mails mit farbcodierten Tags. Ziehen Sie eine E-Mail auf ein Tag oder klicken Sie mit der rechten Maustaste.",
|
||||||
|
"calendar_title": "Kalender",
|
||||||
|
"calendar_desc": "Wechseln Sie zum Kalender, um Ihre Termine zu verwalten. Erstellen Sie Ereignisse, setzen Sie Erinnerungen und wählen Sie verschiedene Ansichten.",
|
||||||
|
"contacts_title": "Kontakte",
|
||||||
|
"contacts_desc": "Hier finden Sie Ihr Adressbuch. Importieren Sie Kontakte, erstellen Sie Gruppen und sehen Sie Details.",
|
||||||
|
"settings_title": "Einstellungen",
|
||||||
|
"settings_desc": "Passen Sie alles an: Design, Dichte, Signaturen, Filter, Tastaturkürzel, Kalender-Standards und mehr.",
|
||||||
|
"shortcuts_title": "Tastaturkürzel",
|
||||||
|
"shortcuts_desc": "Für Power-User. Drücken Sie jederzeit ?, um alle verfügbaren Kürzel anzuzeigen.",
|
||||||
|
"calendar_view_title": "Ihr Kalender",
|
||||||
|
"calendar_view_desc": "Hier ist Ihr Kalender mit Beispielterminen. Wechseln Sie zwischen Tag-, Wochen-, Monats- und Agendaansicht.",
|
||||||
|
"contacts_list_title": "Ihre Kontakte",
|
||||||
|
"contacts_list_desc": "Hier sind Ihre Kontakte. Klicken Sie auf einen Kontakt, um Details zu sehen. Sie können neue Kontakte erstellen oder vCards importieren.",
|
||||||
|
"files_title": "Dateispeicher",
|
||||||
|
"settings_tabs_title": "Einstellungsmenü",
|
||||||
|
"settings_tabs_desc": "Hier finden Sie alle Einstellungskategorien. Passen Sie das Erscheinungsbild an, verwalten Sie Identitäten, richten Sie E-Mail-Filter ein, konfigurieren Sie Ihren Kalender und vieles mehr.",
|
||||||
|
"files_desc": "Ihr Dateibrowser zum Hochladen, Organisieren und Teilen von Dateien.",
|
||||||
|
"demo_banner_title": "Demo-Steuerung",
|
||||||
|
"demo_banner_desc": "Sie sind im Demo-Modus — alles bleibt in Ihrem Browser. Klicken Sie jederzeit auf 'Demo zurücksetzen'.",
|
||||||
|
"quota_title": "Speichernutzung",
|
||||||
|
"quota_desc": "Verfolgen Sie Ihre Postfachgröße hier. Der Kreis füllt sich mit zunehmendem Verbrauch."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-6
@@ -34,9 +34,18 @@
|
|||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
"or": "or",
|
"or": "or",
|
||||||
"sign_in_sso": "Sign in with SSO",
|
"sign_in_sso": "Sign in with SSO",
|
||||||
|
"add_account_title": "Add Account",
|
||||||
|
"add_account_subtitle": "Sign in with another account",
|
||||||
|
"cancel": "Cancel",
|
||||||
"website": "Website",
|
"website": "Website",
|
||||||
"imprint": "Imprint",
|
"imprint": "Imprint",
|
||||||
"privacy_policy": "Privacy Policy",
|
"privacy_policy": "Privacy Policy",
|
||||||
|
"try_demo": "Try Demo",
|
||||||
|
"demo_description": "Explore with sample data — no account needed",
|
||||||
|
"demo_launching": "Launching demo...",
|
||||||
|
"demo_login_button": "Launch Demo",
|
||||||
|
"demo_tagline": "Experience a full-featured email client. No account required.",
|
||||||
|
"demo_no_signup": "No signup needed — explore freely with sample data",
|
||||||
"oauth_completing": "Completing sign in...",
|
"oauth_completing": "Completing sign in...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Authentication Failed",
|
"title": "Authentication Failed",
|
||||||
@@ -58,6 +67,11 @@
|
|||||||
"storage_free": "Free",
|
"storage_free": "Free",
|
||||||
"storage_total": "Total",
|
"storage_total": "Total",
|
||||||
"sign_out": "Sign out",
|
"sign_out": "Sign out",
|
||||||
|
"sign_out_of": "Sign out of {account}",
|
||||||
|
"sign_out_all": "Sign out of all accounts",
|
||||||
|
"add_account": "Add account",
|
||||||
|
"set_as_default": "Set as default",
|
||||||
|
"switch_account": "Switch account",
|
||||||
"contacts": "Contacts",
|
"contacts": "Contacts",
|
||||||
"calendar": "Calendar",
|
"calendar": "Calendar",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
@@ -96,6 +110,9 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Clear search",
|
"clear_search": "Clear search",
|
||||||
"vacation_active": "Vacation responder is active",
|
"vacation_active": "Vacation responder is active",
|
||||||
|
"demo_banner": "Demo Mode",
|
||||||
|
"demo_reset": "Reset",
|
||||||
|
"demo_tour": "Tour",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"mail": "Mail",
|
"mail": "Mail",
|
||||||
"nav_label": "Navigation",
|
"nav_label": "Navigation",
|
||||||
@@ -202,6 +219,7 @@
|
|||||||
"attachments": "Attachments",
|
"attachments": "Attachments",
|
||||||
"important": "Important",
|
"important": "Important",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
|
"download_all": "Download all",
|
||||||
"from": "From",
|
"from": "From",
|
||||||
"to": "To",
|
"to": "To",
|
||||||
"cc": "CC",
|
"cc": "CC",
|
||||||
@@ -393,7 +411,8 @@
|
|||||||
"event_status_tentative": "Tentative",
|
"event_status_tentative": "Tentative",
|
||||||
"event_status_cancelled": "Cancelled"
|
"event_status_cancelled": "Cancelled"
|
||||||
},
|
},
|
||||||
"send": "Send"
|
"send": "Send",
|
||||||
|
"more": "more"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "New Message",
|
"new_message": "New Message",
|
||||||
@@ -672,7 +691,9 @@
|
|||||||
"delete": "Delete keyword",
|
"delete": "Delete keyword",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel",
|
||||||
|
"migrating": "Updating keyword on existing emails…",
|
||||||
|
"migration_error": "Failed to update keyword on existing emails"
|
||||||
},
|
},
|
||||||
"language_region": {
|
"language_region": {
|
||||||
"title": "Language & Region",
|
"title": "Language & Region",
|
||||||
@@ -745,6 +766,12 @@
|
|||||||
"preview": "Preview when possible",
|
"preview": "Preview when possible",
|
||||||
"download": "Download immediately"
|
"download": "Download immediately"
|
||||||
},
|
},
|
||||||
|
"attachment_position": {
|
||||||
|
"label": "Attachment Position",
|
||||||
|
"description": "Where to display attachments in the email header",
|
||||||
|
"beside-sender": "Next to sender",
|
||||||
|
"below-header": "Below header"
|
||||||
|
},
|
||||||
"emails_per_page": {
|
"emails_per_page": {
|
||||||
"25": "25 emails",
|
"25": "25 emails",
|
||||||
"50": "50 emails",
|
"50": "50 emails",
|
||||||
@@ -781,6 +808,17 @@
|
|||||||
"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"
|
||||||
|
},
|
||||||
|
"hover_actions": {
|
||||||
|
"label": "Quick Hover Actions",
|
||||||
|
"description": "Choose which quick actions appear when hovering over an email in the list",
|
||||||
|
"delete": "Delete",
|
||||||
|
"star": "Star / Unstar",
|
||||||
|
"mark_read": "Mark Read / Unread",
|
||||||
|
"archive": "Archive",
|
||||||
|
"tag": "Tag",
|
||||||
|
"spam": "Mark as Spam",
|
||||||
|
"none_selected": "No actions selected"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
@@ -831,6 +869,9 @@
|
|||||||
"account": {
|
"account": {
|
||||||
"title": "Account",
|
"title": "Account",
|
||||||
"description": "View your account information",
|
"description": "View your account information",
|
||||||
|
"name_label": "Display Name",
|
||||||
|
"account_type_label": "Account Type",
|
||||||
|
"demo_account": "Demo Account",
|
||||||
"email": {
|
"email": {
|
||||||
"label": "Email Address",
|
"label": "Email Address",
|
||||||
"value": "{email}"
|
"value": "{email}"
|
||||||
@@ -1454,6 +1495,9 @@
|
|||||||
"title": "Contacts",
|
"title": "Contacts",
|
||||||
"search_placeholder": "Search contacts...",
|
"search_placeholder": "Search contacts...",
|
||||||
"create_new": "New Contact",
|
"create_new": "New Contact",
|
||||||
|
"no_category": "No Category",
|
||||||
|
"category_added": "Contact added to {name}",
|
||||||
|
"category_added_plural": "{count} contacts added to {name}",
|
||||||
"empty_state": "No contacts yet",
|
"empty_state": "No contacts yet",
|
||||||
"empty_state_title": "No contacts yet",
|
"empty_state_title": "No contacts yet",
|
||||||
"empty_state_subtitle": "Create your first contact or import from a vCard file",
|
"empty_state_subtitle": "Create your first contact or import from a vCard file",
|
||||||
@@ -1473,11 +1517,12 @@
|
|||||||
"title": "Shared"
|
"title": "Shared"
|
||||||
},
|
},
|
||||||
"address_books": {
|
"address_books": {
|
||||||
"title": "Directories",
|
"title": "My Address Books",
|
||||||
|
"shared_prefix": "Shared: {name}",
|
||||||
"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": "Directory"
|
"address_book": "Address Book"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Email Addresses",
|
"emails": "Email Addresses",
|
||||||
@@ -1596,7 +1641,8 @@
|
|||||||
"level_low": "Low",
|
"level_low": "Low",
|
||||||
"categories": "Categories",
|
"categories": "Categories",
|
||||||
"categories_placeholder": "e.g., Family, Friends, Colleagues",
|
"categories_placeholder": "e.g., Family, Friends, Colleagues",
|
||||||
"categories_hint": "Separate with commas",
|
"categories_hint": "Type to search or add categories",
|
||||||
|
"category_add": "Add",
|
||||||
"note": "Notes",
|
"note": "Notes",
|
||||||
"note_placeholder": "Add a note...",
|
"note_placeholder": "Add a note...",
|
||||||
"gender": "Gender",
|
"gender": "Gender",
|
||||||
@@ -1938,6 +1984,12 @@
|
|||||||
"deleted": "Subscription removed",
|
"deleted": "Subscription removed",
|
||||||
"delete_error": "Failed to remove subscription",
|
"delete_error": "Failed to remove subscription",
|
||||||
"last_refreshed": "Last updated: {time}"
|
"last_refreshed": "Last updated: {time}"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"no_tasks": "No tasks",
|
||||||
|
"no_title": "(No title)",
|
||||||
|
"mark_complete": "Mark as complete",
|
||||||
|
"mark_incomplete": "Mark as incomplete"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"advanced_search": {
|
"advanced_search": {
|
||||||
@@ -1978,7 +2030,17 @@
|
|||||||
"tip_settings": "Customize your experience in Settings",
|
"tip_settings": "Customize your experience in Settings",
|
||||||
"got_it": "Got it",
|
"got_it": "Got it",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"dismiss": "Dismiss"
|
"dismiss": "Dismiss",
|
||||||
|
"start_tour": "Start Tour"
|
||||||
|
},
|
||||||
|
"demo_welcome": {
|
||||||
|
"title": "Welcome to Bulwark Mail",
|
||||||
|
"description": "Explore a fully-featured webmail client — right in your browser. All data stays on your device, so feel free to test everything.",
|
||||||
|
"feature_email": "Read & compose email",
|
||||||
|
"feature_organize": "Tags, stars & folders",
|
||||||
|
"feature_shortcuts": "Keyboard shortcuts",
|
||||||
|
"feature_privacy": "100% private demo",
|
||||||
|
"hint": "Click any email on the left to get started, or take the tour below."
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Files",
|
"title": "Files",
|
||||||
@@ -2158,5 +2220,54 @@
|
|||||||
"export_passphrase_desc": "Choose a passphrase to protect the exported PKCS#12 file",
|
"export_passphrase_desc": "Choose a passphrase to protect the exported PKCS#12 file",
|
||||||
"export_storage_desc": "Enter the storage passphrase to decrypt the key for export",
|
"export_storage_desc": "Enter the storage passphrase to decrypt the key for export",
|
||||||
"incorrect_passphrase": "Incorrect passphrase"
|
"incorrect_passphrase": "Incorrect passphrase"
|
||||||
|
},
|
||||||
|
"tour": {
|
||||||
|
"step_counter": "Step {current} of {total}",
|
||||||
|
"skip": "Skip tour",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"finish": "Finish",
|
||||||
|
"take_a_tour": "Take a tour of the interface",
|
||||||
|
"restart_title": "Introductory tour",
|
||||||
|
"restart_desc": "Replay the guided walkthrough of the interface",
|
||||||
|
"restart_button": "Restart tour",
|
||||||
|
"sidebar_title": "Your mailboxes",
|
||||||
|
"sidebar_desc": "This is your folder sidebar. Click any mailbox to view its emails. You can create folders, drag emails between them, and see unread counts at a glance.",
|
||||||
|
"compose_title": "Compose an email",
|
||||||
|
"compose_desc": "Click here to write a new email. You can add recipients, attachments, and use rich text formatting.",
|
||||||
|
"search_title": "Search your mail",
|
||||||
|
"search_desc": "Search by sender, subject, or content. Click the filter icon for advanced options like date range, attachments, and starred messages.",
|
||||||
|
"email_list_title": "Your email list",
|
||||||
|
"email_list_desc": "Emails appear here. Click one to read it on the right. Use the checkbox to select multiple, then bulk-move, delete, or tag them.",
|
||||||
|
"email_viewer_title": "Reading pane",
|
||||||
|
"email_viewer_desc": "The selected email opens here. Reply, forward, archive, or delete with the toolbar buttons. You can also star emails or add color tags.",
|
||||||
|
"keywords_title": "Color tags",
|
||||||
|
"keywords_desc": "Organize your email with color-coded tags. Drag an email onto a tag to label it, or right-click an email to assign tags.",
|
||||||
|
"calendar_title": "Calendar",
|
||||||
|
"calendar_desc": "Switch to the calendar to manage your events. Create events, set reminders, and view day, week, or month layouts.",
|
||||||
|
"contacts_title": "Contacts",
|
||||||
|
"contacts_desc": "Your address book lives here. Import contacts, create groups, and click any contact to see their full details.",
|
||||||
|
"settings_title": "Settings",
|
||||||
|
"settings_desc": "Customize everything: theme, density, signatures, filters, keyboard shortcuts, calendar defaults, and more.",
|
||||||
|
"shortcuts_title": "Keyboard shortcuts",
|
||||||
|
"shortcuts_desc": "Power users love this. Press ? anytime to see all available shortcuts. You can navigate, compose, and manage emails without touching a mouse.",
|
||||||
|
"compose_open_title": "The composer",
|
||||||
|
"compose_open_desc": "This is the email composer. Add recipients, write your message, attach files, and use rich text formatting. You can also save drafts and use templates.",
|
||||||
|
"calendar_view_title": "Your calendar",
|
||||||
|
"calendar_view_desc": "Here's your calendar with sample events. You can switch between day, week, month, and agenda views using the toolbar.",
|
||||||
|
"create_event_title": "Create an event",
|
||||||
|
"create_event_desc": "Click this button to create a new calendar event. You can set a title, date, time, and add participants.",
|
||||||
|
"event_modal_title": "Event details",
|
||||||
|
"event_modal_desc": "Here's the event form. Fill in the title, pick a date and time, add a location or participants. Hit save when you're done — or close it and move on.",
|
||||||
|
"contacts_list_title": "Your contacts",
|
||||||
|
"contacts_list_desc": "Here are your contacts. Click any contact to see their full details on the right. You can also create new contacts, import vCards, or organize contacts into groups.",
|
||||||
|
"settings_tabs_title": "Settings menu",
|
||||||
|
"settings_tabs_desc": "Here are all the settings categories. Customize your appearance, manage identities, set up email filters, configure your calendar, and much more.",
|
||||||
|
"files_title": "File storage",
|
||||||
|
"files_desc": "Your file browser lets you upload, organize, and share files — like a personal cloud drive built into your mail.",
|
||||||
|
"demo_banner_title": "Demo controls",
|
||||||
|
"demo_banner_desc": "You're in demo mode — everything stays in your browser. Hit 'Reset Demo' anytime to start fresh with clean sample data.",
|
||||||
|
"quota_title": "Storage usage",
|
||||||
|
"quota_desc": "Track your mailbox size here. The circle fills up as you use more space."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+97
-5
@@ -34,9 +34,18 @@
|
|||||||
"dismiss": "Cerrar",
|
"dismiss": "Cerrar",
|
||||||
"or": "o",
|
"or": "o",
|
||||||
"sign_in_sso": "Iniciar sesión con SSO",
|
"sign_in_sso": "Iniciar sesión con SSO",
|
||||||
|
"add_account_title": "Agregar cuenta",
|
||||||
|
"add_account_subtitle": "Iniciar sesión con otra cuenta",
|
||||||
|
"cancel": "Cancelar",
|
||||||
"website": "Sitio web",
|
"website": "Sitio web",
|
||||||
"imprint": "Aviso legal",
|
"imprint": "Aviso legal",
|
||||||
"privacy_policy": "Política de privacidad",
|
"privacy_policy": "Política de privacidad",
|
||||||
|
"try_demo": "Probar demo",
|
||||||
|
"demo_description": "Explora con datos de ejemplo — sin cuenta necesaria",
|
||||||
|
"demo_launching": "Iniciando demo...",
|
||||||
|
"demo_login_button": "Iniciar demo",
|
||||||
|
"demo_tagline": "Experimenta un cliente de correo completo. Sin necesidad de cuenta.",
|
||||||
|
"demo_no_signup": "Sin registro — explora libremente con datos de ejemplo",
|
||||||
"oauth_completing": "Completando inicio de sesión...",
|
"oauth_completing": "Completando inicio de sesión...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Error de autenticación",
|
"title": "Error de autenticación",
|
||||||
@@ -58,6 +67,11 @@
|
|||||||
"storage_free": "Libre",
|
"storage_free": "Libre",
|
||||||
"storage_total": "Total",
|
"storage_total": "Total",
|
||||||
"sign_out": "Cerrar sesión",
|
"sign_out": "Cerrar sesión",
|
||||||
|
"sign_out_of": "Cerrar sesión de {account}",
|
||||||
|
"sign_out_all": "Cerrar sesión de todas las cuentas",
|
||||||
|
"add_account": "Agregar cuenta",
|
||||||
|
"set_as_default": "Establecer como predeterminada",
|
||||||
|
"switch_account": "Cambiar cuenta",
|
||||||
"contacts": "Contactos",
|
"contacts": "Contactos",
|
||||||
"calendar": "Calendario",
|
"calendar": "Calendario",
|
||||||
"settings": "Configuración",
|
"settings": "Configuración",
|
||||||
@@ -96,6 +110,9 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Limpiar búsqueda",
|
"clear_search": "Limpiar búsqueda",
|
||||||
"vacation_active": "Respuesta automática activa",
|
"vacation_active": "Respuesta automática activa",
|
||||||
|
"demo_banner": "Modo demo",
|
||||||
|
"demo_reset": "Restablecer",
|
||||||
|
"demo_tour": "Tour",
|
||||||
"tags": "Etiquetas",
|
"tags": "Etiquetas",
|
||||||
"mail": "Correo",
|
"mail": "Correo",
|
||||||
"nav_label": "Navegación",
|
"nav_label": "Navegación",
|
||||||
@@ -202,6 +219,7 @@
|
|||||||
"attachments": "Archivos adjuntos",
|
"attachments": "Archivos adjuntos",
|
||||||
"important": "Importante",
|
"important": "Importante",
|
||||||
"download": "Descargar",
|
"download": "Descargar",
|
||||||
|
"download_all": "Descargar todo",
|
||||||
"from": "De",
|
"from": "De",
|
||||||
"to": "Para",
|
"to": "Para",
|
||||||
"cc": "CC",
|
"cc": "CC",
|
||||||
@@ -393,7 +411,8 @@
|
|||||||
},
|
},
|
||||||
"previous": "Anterior",
|
"previous": "Anterior",
|
||||||
"next": "Siguiente",
|
"next": "Siguiente",
|
||||||
"send": "Enviar"
|
"send": "Enviar",
|
||||||
|
"more": "más"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "Nuevo Mensaje",
|
"new_message": "Nuevo Mensaje",
|
||||||
@@ -745,6 +764,12 @@
|
|||||||
"preview": "Mostrar vista previa cuando sea posible",
|
"preview": "Mostrar vista previa cuando sea posible",
|
||||||
"download": "Descargar inmediatamente"
|
"download": "Descargar inmediatamente"
|
||||||
},
|
},
|
||||||
|
"attachment_position": {
|
||||||
|
"label": "Posición del adjunto",
|
||||||
|
"description": "Dónde mostrar los adjuntos en el encabezado del correo",
|
||||||
|
"beside-sender": "Junto al remitente",
|
||||||
|
"below-header": "Debajo del encabezado"
|
||||||
|
},
|
||||||
"emails_per_page": {
|
"emails_per_page": {
|
||||||
"25": "25 correos",
|
"25": "25 correos",
|
||||||
"50": "50 correos",
|
"50": "50 correos",
|
||||||
@@ -831,6 +856,9 @@
|
|||||||
"account": {
|
"account": {
|
||||||
"title": "Cuenta",
|
"title": "Cuenta",
|
||||||
"description": "Vea la información de su cuenta",
|
"description": "Vea la información de su cuenta",
|
||||||
|
"name_label": "Nombre para mostrar",
|
||||||
|
"account_type_label": "Tipo de cuenta",
|
||||||
|
"demo_account": "Cuenta de demostración",
|
||||||
"email": {
|
"email": {
|
||||||
"label": "Dirección de Correo",
|
"label": "Dirección de Correo",
|
||||||
"value": "{email}"
|
"value": "{email}"
|
||||||
@@ -1454,6 +1482,9 @@
|
|||||||
"title": "Contactos",
|
"title": "Contactos",
|
||||||
"search_placeholder": "Buscar contactos...",
|
"search_placeholder": "Buscar contactos...",
|
||||||
"create_new": "Nuevo contacto",
|
"create_new": "Nuevo contacto",
|
||||||
|
"no_category": "Sin categoría",
|
||||||
|
"category_added": "Contacto añadido a {name}",
|
||||||
|
"category_added_plural": "{count} contactos añadidos a {name}",
|
||||||
"empty_state": "No hay contactos",
|
"empty_state": "No hay contactos",
|
||||||
"empty_state_title": "Sin contactos",
|
"empty_state_title": "Sin contactos",
|
||||||
"empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard",
|
"empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard",
|
||||||
@@ -1473,11 +1504,12 @@
|
|||||||
"title": "Compartidos"
|
"title": "Compartidos"
|
||||||
},
|
},
|
||||||
"address_books": {
|
"address_books": {
|
||||||
"title": "Directorios",
|
"title": "Mis Libretas de Direcciones",
|
||||||
|
"shared_prefix": "Compartido: {name}",
|
||||||
"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": "Directorio"
|
"address_book": "Libreta de direcciones"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Direcciones de correo",
|
"emails": "Direcciones de correo",
|
||||||
@@ -1596,7 +1628,8 @@
|
|||||||
"level_low": "Bajo",
|
"level_low": "Bajo",
|
||||||
"categories": "Categorías",
|
"categories": "Categorías",
|
||||||
"categories_placeholder": "p. ej., Familia, Amigos, Colegas",
|
"categories_placeholder": "p. ej., Familia, Amigos, Colegas",
|
||||||
"categories_hint": "Separar con comas",
|
"categories_hint": "Escriba para buscar o añadir categorías",
|
||||||
|
"category_add": "Añadir",
|
||||||
"note": "Notas",
|
"note": "Notas",
|
||||||
"note_placeholder": "Agregar una nota...",
|
"note_placeholder": "Agregar una nota...",
|
||||||
"gender": "Género",
|
"gender": "Género",
|
||||||
@@ -1938,6 +1971,12 @@
|
|||||||
"deleted": "Suscripción eliminada",
|
"deleted": "Suscripción eliminada",
|
||||||
"delete_error": "No se pudo eliminar la suscripción",
|
"delete_error": "No se pudo eliminar la suscripción",
|
||||||
"last_refreshed": "Última actualización: {time}"
|
"last_refreshed": "Última actualización: {time}"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"no_tasks": "Sin tareas",
|
||||||
|
"no_title": "(Sin título)",
|
||||||
|
"mark_complete": "Marcar como completada",
|
||||||
|
"mark_incomplete": "Marcar como incompleta"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"advanced_search": {
|
"advanced_search": {
|
||||||
@@ -1978,7 +2017,17 @@
|
|||||||
"tip_settings": "Personaliza tu experiencia en Ajustes",
|
"tip_settings": "Personaliza tu experiencia en Ajustes",
|
||||||
"got_it": "Entendido",
|
"got_it": "Entendido",
|
||||||
"settings": "Ajustes",
|
"settings": "Ajustes",
|
||||||
"dismiss": "Cerrar"
|
"dismiss": "Cerrar",
|
||||||
|
"start_tour": "Iniciar tour"
|
||||||
|
},
|
||||||
|
"demo_welcome": {
|
||||||
|
"title": "Bienvenido a Bulwark Mail",
|
||||||
|
"description": "Explora un cliente de correo web completo — directamente en tu navegador. Todos los datos quedan en tu dispositivo, así que prueba todo.",
|
||||||
|
"feature_email": "Leer y redactar correos",
|
||||||
|
"feature_organize": "Etiquetas, estrellas y carpetas",
|
||||||
|
"feature_shortcuts": "Atajos de teclado",
|
||||||
|
"feature_privacy": "Demo 100 % privada",
|
||||||
|
"hint": "Haz clic en un correo a la izquierda para empezar, o inicia el tour."
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Archivos",
|
"title": "Archivos",
|
||||||
@@ -2158,5 +2207,48 @@
|
|||||||
"export_passphrase_desc": "Elige una contraseña para proteger el archivo PKCS#12 exportado",
|
"export_passphrase_desc": "Elige una contraseña para proteger el archivo PKCS#12 exportado",
|
||||||
"export_storage_desc": "Introduce la contraseña de almacenamiento para descifrar la clave antes de exportarla",
|
"export_storage_desc": "Introduce la contraseña de almacenamiento para descifrar la clave antes de exportarla",
|
||||||
"incorrect_passphrase": "Contraseña incorrecta"
|
"incorrect_passphrase": "Contraseña incorrecta"
|
||||||
|
},
|
||||||
|
"tour": {
|
||||||
|
"step_counter": "Paso {current} de {total}",
|
||||||
|
"skip": "Saltar tour",
|
||||||
|
"back": "Atrás",
|
||||||
|
"next": "Siguiente",
|
||||||
|
"finish": "Finalizar",
|
||||||
|
"take_a_tour": "Haz un recorrido por la interfaz",
|
||||||
|
"restart_title": "Tour introductorio",
|
||||||
|
"restart_desc": "Repetir el recorrido guiado por la interfaz",
|
||||||
|
"restart_button": "Reiniciar tour",
|
||||||
|
"sidebar_title": "Tus buzones",
|
||||||
|
"sidebar_desc": "Esta es tu barra lateral de carpetas. Haz clic en cualquier buzón para ver sus correos. Puedes crear carpetas, arrastrar correos entre ellas y ver los contadores de no leídos.",
|
||||||
|
"compose_title": "Redactar un correo",
|
||||||
|
"compose_desc": "Haz clic aquí para escribir un nuevo correo. Puedes añadir destinatarios, archivos adjuntos y formato de texto enriquecido.",
|
||||||
|
"search_title": "Buscar en tu correo",
|
||||||
|
"search_desc": "Busca por remitente, asunto o contenido. Haz clic en el icono de filtro para opciones avanzadas como rango de fechas, adjuntos y mensajes destacados.",
|
||||||
|
"email_list_title": "Tu lista de correos",
|
||||||
|
"email_list_desc": "Los correos aparecen aquí. Haz clic en uno para leerlo a la derecha. Usa la casilla para seleccionar varios y moverlos, eliminarlos o etiquetarlos.",
|
||||||
|
"email_viewer_title": "Panel de lectura",
|
||||||
|
"email_viewer_desc": "El correo seleccionado se abre aquí. Responde, reenvía, archiva o elimina con los botones de la barra. También puedes destacar correos o añadir etiquetas de color.",
|
||||||
|
"keywords_title": "Etiquetas de color",
|
||||||
|
"keywords_desc": "Organiza tu correo con etiquetas de colores. Arrastra un correo sobre una etiqueta o haz clic derecho para asignarlas.",
|
||||||
|
"calendar_title": "Calendario",
|
||||||
|
"calendar_desc": "Cambia al calendario para gestionar tus eventos. Crea eventos, configura recordatorios y elige diferentes vistas.",
|
||||||
|
"contacts_title": "Contactos",
|
||||||
|
"contacts_desc": "Tu libreta de direcciones está aquí. Importa contactos, crea grupos y consulta los detalles.",
|
||||||
|
"settings_title": "Ajustes",
|
||||||
|
"settings_desc": "Personaliza todo: tema, densidad, firmas, filtros, atajos de teclado, valores predeterminados del calendario y más.",
|
||||||
|
"shortcuts_title": "Atajos de teclado",
|
||||||
|
"shortcuts_desc": "Para usuarios avanzados. Pulsa ? en cualquier momento para ver todos los atajos disponibles.",
|
||||||
|
"calendar_view_title": "Tu calendario",
|
||||||
|
"calendar_view_desc": "Aquí está tu calendario con eventos de ejemplo. Cambia entre vistas de día, semana, mes y agenda.",
|
||||||
|
"contacts_list_title": "Tus contactos",
|
||||||
|
"contacts_list_desc": "Aquí están tus contactos. Haz clic en cualquier contacto para ver sus detalles. Puedes crear contactos nuevos o importar vCards.",
|
||||||
|
"files_title": "Almacenamiento de archivos",
|
||||||
|
"settings_tabs_title": "Menú de ajustes",
|
||||||
|
"settings_tabs_desc": "Aquí están todas las categorías de ajustes. Personaliza la apariencia, gestiona identidades, configura filtros de correo, ajusta tu calendario y mucho más.",
|
||||||
|
"files_desc": "Tu explorador de archivos para subir, organizar y compartir archivos.",
|
||||||
|
"demo_banner_title": "Controles de demo",
|
||||||
|
"demo_banner_desc": "Estás en modo demo — todo permanece en tu navegador. Haz clic en 'Restablecer demo' en cualquier momento.",
|
||||||
|
"quota_title": "Uso de almacenamiento",
|
||||||
|
"quota_desc": "Controla el tamaño de tu buzón aquí. El círculo se llena a medida que usas más espacio."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+97
-5
@@ -34,9 +34,18 @@
|
|||||||
"dismiss": "Fermer",
|
"dismiss": "Fermer",
|
||||||
"or": "ou",
|
"or": "ou",
|
||||||
"sign_in_sso": "Se connecter avec SSO",
|
"sign_in_sso": "Se connecter avec SSO",
|
||||||
|
"add_account_title": "Ajouter un compte",
|
||||||
|
"add_account_subtitle": "Se connecter avec un autre compte",
|
||||||
|
"cancel": "Annuler",
|
||||||
"website": "Site web",
|
"website": "Site web",
|
||||||
"imprint": "Mentions légales",
|
"imprint": "Mentions légales",
|
||||||
"privacy_policy": "Politique de confidentialité",
|
"privacy_policy": "Politique de confidentialité",
|
||||||
|
"try_demo": "Essayer la démo",
|
||||||
|
"demo_description": "Explorez avec des données d'exemple — aucun compte nécessaire",
|
||||||
|
"demo_launching": "Lancement de la démo...",
|
||||||
|
"demo_login_button": "Lancer la démo",
|
||||||
|
"demo_tagline": "Découvrez un client de messagerie complet. Aucun compte requis.",
|
||||||
|
"demo_no_signup": "Aucune inscription — explorez librement avec des données d'exemple",
|
||||||
"oauth_completing": "Connexion en cours...",
|
"oauth_completing": "Connexion en cours...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Échec de l'authentification",
|
"title": "Échec de l'authentification",
|
||||||
@@ -58,6 +67,11 @@
|
|||||||
"storage_free": "Libre",
|
"storage_free": "Libre",
|
||||||
"storage_total": "Total",
|
"storage_total": "Total",
|
||||||
"sign_out": "Se déconnecter",
|
"sign_out": "Se déconnecter",
|
||||||
|
"sign_out_of": "Se déconnecter de {account}",
|
||||||
|
"sign_out_all": "Se déconnecter de tous les comptes",
|
||||||
|
"add_account": "Ajouter un compte",
|
||||||
|
"set_as_default": "Définir par défaut",
|
||||||
|
"switch_account": "Changer de compte",
|
||||||
"contacts": "Contacts",
|
"contacts": "Contacts",
|
||||||
"calendar": "Calendrier",
|
"calendar": "Calendrier",
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
@@ -96,6 +110,9 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Effacer la recherche",
|
"clear_search": "Effacer la recherche",
|
||||||
"vacation_active": "Répondeur d'absence activé",
|
"vacation_active": "Répondeur d'absence activé",
|
||||||
|
"demo_banner": "Mode démo",
|
||||||
|
"demo_reset": "Réinitialiser",
|
||||||
|
"demo_tour": "Visite",
|
||||||
"tags": "Étiquettes",
|
"tags": "Étiquettes",
|
||||||
"mail": "Messagerie",
|
"mail": "Messagerie",
|
||||||
"nav_label": "Navigation",
|
"nav_label": "Navigation",
|
||||||
@@ -202,6 +219,7 @@
|
|||||||
"attachments": "Pièces jointes",
|
"attachments": "Pièces jointes",
|
||||||
"important": "Important",
|
"important": "Important",
|
||||||
"download": "Télécharger",
|
"download": "Télécharger",
|
||||||
|
"download_all": "Tout télécharger",
|
||||||
"from": "De",
|
"from": "De",
|
||||||
"to": "À",
|
"to": "À",
|
||||||
"cc": "CC",
|
"cc": "CC",
|
||||||
@@ -393,7 +411,8 @@
|
|||||||
},
|
},
|
||||||
"previous": "Précédent",
|
"previous": "Précédent",
|
||||||
"next": "Suivant",
|
"next": "Suivant",
|
||||||
"send": "Envoyer"
|
"send": "Envoyer",
|
||||||
|
"more": "plus"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "Nouveau message",
|
"new_message": "Nouveau message",
|
||||||
@@ -745,6 +764,12 @@
|
|||||||
"preview": "Aperçu si possible",
|
"preview": "Aperçu si possible",
|
||||||
"download": "Télécharger immédiatement"
|
"download": "Télécharger immédiatement"
|
||||||
},
|
},
|
||||||
|
"attachment_position": {
|
||||||
|
"label": "Position des pièces jointes",
|
||||||
|
"description": "Où afficher les pièces jointes dans l'en-tête de l'email",
|
||||||
|
"beside-sender": "À côté de l'expéditeur",
|
||||||
|
"below-header": "Sous l'en-tête"
|
||||||
|
},
|
||||||
"emails_per_page": {
|
"emails_per_page": {
|
||||||
"25": "25 emails",
|
"25": "25 emails",
|
||||||
"50": "50 emails",
|
"50": "50 emails",
|
||||||
@@ -831,6 +856,9 @@
|
|||||||
"account": {
|
"account": {
|
||||||
"title": "Compte",
|
"title": "Compte",
|
||||||
"description": "Consultez les informations de votre compte",
|
"description": "Consultez les informations de votre compte",
|
||||||
|
"name_label": "Nom d'affichage",
|
||||||
|
"account_type_label": "Type de compte",
|
||||||
|
"demo_account": "Compte de démonstration",
|
||||||
"email": {
|
"email": {
|
||||||
"label": "Adresse email",
|
"label": "Adresse email",
|
||||||
"value": "{email}"
|
"value": "{email}"
|
||||||
@@ -1454,6 +1482,9 @@
|
|||||||
"title": "Contacts",
|
"title": "Contacts",
|
||||||
"search_placeholder": "Rechercher des contacts...",
|
"search_placeholder": "Rechercher des contacts...",
|
||||||
"create_new": "Nouveau contact",
|
"create_new": "Nouveau contact",
|
||||||
|
"no_category": "Sans catégorie",
|
||||||
|
"category_added": "Contact ajouté à {name}",
|
||||||
|
"category_added_plural": "{count} contacts ajoutés à {name}",
|
||||||
"empty_state": "Aucun contact",
|
"empty_state": "Aucun contact",
|
||||||
"empty_state_title": "Aucun contact",
|
"empty_state_title": "Aucun contact",
|
||||||
"empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard",
|
"empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard",
|
||||||
@@ -1473,11 +1504,12 @@
|
|||||||
"title": "Partagés"
|
"title": "Partagés"
|
||||||
},
|
},
|
||||||
"address_books": {
|
"address_books": {
|
||||||
"title": "Répertoires",
|
"title": "Mes Carnets d'adresses",
|
||||||
|
"shared_prefix": "Partagé : {name}",
|
||||||
"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": "Répertoire"
|
"address_book": "Carnet d'adresses"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Adresses e-mail",
|
"emails": "Adresses e-mail",
|
||||||
@@ -1596,7 +1628,8 @@
|
|||||||
"level_low": "Faible",
|
"level_low": "Faible",
|
||||||
"categories": "Catégories",
|
"categories": "Catégories",
|
||||||
"categories_placeholder": "p. ex., Famille, Amis, Collègues",
|
"categories_placeholder": "p. ex., Famille, Amis, Collègues",
|
||||||
"categories_hint": "Séparer par des virgules",
|
"categories_hint": "Tapez pour rechercher ou ajouter",
|
||||||
|
"category_add": "Ajouter",
|
||||||
"note": "Notes",
|
"note": "Notes",
|
||||||
"note_placeholder": "Ajouter une note...",
|
"note_placeholder": "Ajouter une note...",
|
||||||
"gender": "Genre",
|
"gender": "Genre",
|
||||||
@@ -1938,6 +1971,12 @@
|
|||||||
"deleted": "Abonnement supprimé",
|
"deleted": "Abonnement supprimé",
|
||||||
"delete_error": "Impossible de supprimer l'abonnement",
|
"delete_error": "Impossible de supprimer l'abonnement",
|
||||||
"last_refreshed": "Dernière mise à jour : {time}"
|
"last_refreshed": "Dernière mise à jour : {time}"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"no_tasks": "Aucune tâche",
|
||||||
|
"no_title": "(Sans titre)",
|
||||||
|
"mark_complete": "Marquer comme terminée",
|
||||||
|
"mark_incomplete": "Marquer comme non terminée"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"advanced_search": {
|
"advanced_search": {
|
||||||
@@ -1978,7 +2017,17 @@
|
|||||||
"tip_settings": "Personnalisez votre experience dans les Parametres",
|
"tip_settings": "Personnalisez votre experience dans les Parametres",
|
||||||
"got_it": "Compris",
|
"got_it": "Compris",
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"dismiss": "Fermer"
|
"dismiss": "Fermer",
|
||||||
|
"start_tour": "Démarrer la visite"
|
||||||
|
},
|
||||||
|
"demo_welcome": {
|
||||||
|
"title": "Bienvenue sur Bulwark Mail",
|
||||||
|
"description": "Explorez un client webmail complet — directement dans votre navigateur. Toutes les données restent sur votre appareil, alors testez tout.",
|
||||||
|
"feature_email": "Lire et rédiger des e-mails",
|
||||||
|
"feature_organize": "Tags, étoiles et dossiers",
|
||||||
|
"feature_shortcuts": "Raccourcis clavier",
|
||||||
|
"feature_privacy": "Démo 100 % privée",
|
||||||
|
"hint": "Cliquez sur un e-mail à gauche pour commencer, ou lancez la visite."
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Fichiers",
|
"title": "Fichiers",
|
||||||
@@ -2158,5 +2207,48 @@
|
|||||||
"export_passphrase_desc": "Choisissez une phrase secrète pour protéger le fichier PKCS#12 exporté",
|
"export_passphrase_desc": "Choisissez une phrase secrète pour protéger le fichier PKCS#12 exporté",
|
||||||
"export_storage_desc": "Entrez la phrase secrète de stockage pour déchiffrer la clé avant l'export",
|
"export_storage_desc": "Entrez la phrase secrète de stockage pour déchiffrer la clé avant l'export",
|
||||||
"incorrect_passphrase": "Phrase secrète incorrecte"
|
"incorrect_passphrase": "Phrase secrète incorrecte"
|
||||||
|
},
|
||||||
|
"tour": {
|
||||||
|
"step_counter": "Étape {current} sur {total}",
|
||||||
|
"skip": "Passer la visite",
|
||||||
|
"back": "Retour",
|
||||||
|
"next": "Suivant",
|
||||||
|
"finish": "Terminer",
|
||||||
|
"take_a_tour": "Faire une visite de l'interface",
|
||||||
|
"restart_title": "Visite d'introduction",
|
||||||
|
"restart_desc": "Rejouer la visite guidée de l'interface",
|
||||||
|
"restart_button": "Relancer la visite",
|
||||||
|
"sidebar_title": "Vos boîtes mail",
|
||||||
|
"sidebar_desc": "Voici votre barre latérale de dossiers. Cliquez sur une boîte pour voir ses emails. Vous pouvez créer des dossiers, glisser des emails entre eux et voir les compteurs de non lus.",
|
||||||
|
"compose_title": "Rédiger un email",
|
||||||
|
"compose_desc": "Cliquez ici pour écrire un nouvel email. Ajoutez des destinataires, des pièces jointes et utilisez la mise en forme enrichie.",
|
||||||
|
"search_title": "Rechercher vos emails",
|
||||||
|
"search_desc": "Recherchez par expéditeur, objet ou contenu. Cliquez sur l'icône de filtre pour les options avancées comme la plage de dates, les pièces jointes et les messages suivis.",
|
||||||
|
"email_list_title": "Votre liste d'emails",
|
||||||
|
"email_list_desc": "Les emails apparaissent ici. Cliquez sur un email pour le lire à droite. Utilisez la case à cocher pour en sélectionner plusieurs, puis déplacez, supprimez ou étiquetez-les.",
|
||||||
|
"email_viewer_title": "Panneau de lecture",
|
||||||
|
"email_viewer_desc": "L'email sélectionné s'ouvre ici. Répondez, transférez, archivez ou supprimez avec les boutons de la barre d'outils. Vous pouvez aussi marquer les emails ou ajouter des étiquettes de couleur.",
|
||||||
|
"keywords_title": "Étiquettes de couleur",
|
||||||
|
"keywords_desc": "Organisez vos emails avec des étiquettes colorées. Glissez un email sur une étiquette pour le marquer, ou faites un clic droit pour assigner des étiquettes.",
|
||||||
|
"calendar_title": "Calendrier",
|
||||||
|
"calendar_desc": "Accédez au calendrier pour gérer vos événements. Créez des événements, définissez des rappels et consultez les vues jour, semaine ou mois.",
|
||||||
|
"contacts_title": "Contacts",
|
||||||
|
"contacts_desc": "Votre carnet d'adresses se trouve ici. Importez des contacts, créez des groupes et cliquez sur un contact pour voir ses détails.",
|
||||||
|
"settings_title": "Paramètres",
|
||||||
|
"settings_desc": "Personnalisez tout : thème, densité, signatures, filtres, raccourcis clavier, paramètres du calendrier et plus encore.",
|
||||||
|
"shortcuts_title": "Raccourcis clavier",
|
||||||
|
"shortcuts_desc": "Les utilisateurs avancés adorent ça. Appuyez sur ? à tout moment pour voir tous les raccourcis disponibles. Naviguez, rédigez et gérez vos emails sans toucher à la souris.",
|
||||||
|
"calendar_view_title": "Votre calendrier",
|
||||||
|
"calendar_view_desc": "Voici votre calendrier avec des événements exemples. Basculez entre les vues jour, semaine, mois et agenda avec la barre d'outils.",
|
||||||
|
"contacts_list_title": "Vos contacts",
|
||||||
|
"contacts_list_desc": "Voici vos contacts. Cliquez sur un contact pour voir ses détails à droite. Vous pouvez aussi créer de nouveaux contacts, importer des vCards ou organiser les contacts en groupes.",
|
||||||
|
"files_title": "Stockage de fichiers",
|
||||||
|
"settings_tabs_title": "Menu des paramètres",
|
||||||
|
"settings_tabs_desc": "Voici toutes les catégories de paramètres. Personnalisez l'apparence, gérez les identités, configurez les filtres de messagerie, paramétrez votre calendrier et bien plus encore.",
|
||||||
|
"files_desc": "Votre gestionnaire de fichiers vous permet de téléverser, organiser et partager des fichiers — comme un cloud personnel intégré à votre messagerie.",
|
||||||
|
"demo_banner_title": "Contrôles de démo",
|
||||||
|
"demo_banner_desc": "Vous êtes en mode démo — tout reste dans votre navigateur. Cliquez sur 'Réinitialiser la démo' à tout moment pour repartir avec des données fraîches.",
|
||||||
|
"quota_title": "Utilisation du stockage",
|
||||||
|
"quota_desc": "Suivez la taille de votre boîte mail ici. Le cercle se remplit au fur et à mesure que vous utilisez plus d'espace."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+96
-4
@@ -34,9 +34,18 @@
|
|||||||
"dismiss": "Chiudi",
|
"dismiss": "Chiudi",
|
||||||
"or": "o",
|
"or": "o",
|
||||||
"sign_in_sso": "Accedi con SSO",
|
"sign_in_sso": "Accedi con SSO",
|
||||||
|
"add_account_title": "Aggiungi account",
|
||||||
|
"add_account_subtitle": "Accedi con un altro account",
|
||||||
|
"cancel": "Annulla",
|
||||||
"website": "Sito web",
|
"website": "Sito web",
|
||||||
"imprint": "Note legali",
|
"imprint": "Note legali",
|
||||||
"privacy_policy": "Informativa sulla privacy",
|
"privacy_policy": "Informativa sulla privacy",
|
||||||
|
"try_demo": "Prova la demo",
|
||||||
|
"demo_description": "Esplora con dati di esempio — nessun account necessario",
|
||||||
|
"demo_launching": "Avvio demo...",
|
||||||
|
"demo_login_button": "Avvia demo",
|
||||||
|
"demo_tagline": "Scopri un client di posta completo. Nessun account richiesto.",
|
||||||
|
"demo_no_signup": "Nessuna registrazione — esplora liberamente con dati di esempio",
|
||||||
"oauth_completing": "Completamento dell'accesso...",
|
"oauth_completing": "Completamento dell'accesso...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Autenticazione non riuscita",
|
"title": "Autenticazione non riuscita",
|
||||||
@@ -58,6 +67,11 @@
|
|||||||
"storage_free": "Libero",
|
"storage_free": "Libero",
|
||||||
"storage_total": "Totale",
|
"storage_total": "Totale",
|
||||||
"sign_out": "Esci",
|
"sign_out": "Esci",
|
||||||
|
"sign_out_of": "Disconnetti da {account}",
|
||||||
|
"sign_out_all": "Disconnetti da tutti gli account",
|
||||||
|
"add_account": "Aggiungi account",
|
||||||
|
"set_as_default": "Imposta come predefinito",
|
||||||
|
"switch_account": "Cambia account",
|
||||||
"contacts": "Contatti",
|
"contacts": "Contatti",
|
||||||
"calendar": "Calendario",
|
"calendar": "Calendario",
|
||||||
"settings": "Impostazioni",
|
"settings": "Impostazioni",
|
||||||
@@ -96,6 +110,9 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Cancella ricerca",
|
"clear_search": "Cancella ricerca",
|
||||||
"vacation_active": "Risponditore automatico attivo",
|
"vacation_active": "Risponditore automatico attivo",
|
||||||
|
"demo_banner": "Modalità demo",
|
||||||
|
"demo_reset": "Reimposta",
|
||||||
|
"demo_tour": "Tour",
|
||||||
"tags": "Etichette",
|
"tags": "Etichette",
|
||||||
"mail": "Posta",
|
"mail": "Posta",
|
||||||
"nav_label": "Navigazione",
|
"nav_label": "Navigazione",
|
||||||
@@ -202,6 +219,7 @@
|
|||||||
"attachments": "Allegati",
|
"attachments": "Allegati",
|
||||||
"important": "Importante",
|
"important": "Importante",
|
||||||
"download": "Scarica",
|
"download": "Scarica",
|
||||||
|
"download_all": "Scarica tutto",
|
||||||
"from": "Da",
|
"from": "Da",
|
||||||
"to": "A",
|
"to": "A",
|
||||||
"cc": "CC",
|
"cc": "CC",
|
||||||
@@ -393,7 +411,8 @@
|
|||||||
},
|
},
|
||||||
"previous": "Precedente",
|
"previous": "Precedente",
|
||||||
"next": "Successivo",
|
"next": "Successivo",
|
||||||
"send": "Invia"
|
"send": "Invia",
|
||||||
|
"more": "altri"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "Nuovo messaggio",
|
"new_message": "Nuovo messaggio",
|
||||||
@@ -745,6 +764,12 @@
|
|||||||
"preview": "Anteprima quando possibile",
|
"preview": "Anteprima quando possibile",
|
||||||
"download": "Scarica immediatamente"
|
"download": "Scarica immediatamente"
|
||||||
},
|
},
|
||||||
|
"attachment_position": {
|
||||||
|
"label": "Posizione degli allegati",
|
||||||
|
"description": "Dove visualizzare gli allegati nell'intestazione dell'email",
|
||||||
|
"beside-sender": "Accanto al mittente",
|
||||||
|
"below-header": "Sotto l'intestazione"
|
||||||
|
},
|
||||||
"emails_per_page": {
|
"emails_per_page": {
|
||||||
"25": "25 messaggi",
|
"25": "25 messaggi",
|
||||||
"50": "50 messaggi",
|
"50": "50 messaggi",
|
||||||
@@ -831,6 +856,9 @@
|
|||||||
"account": {
|
"account": {
|
||||||
"title": "Account",
|
"title": "Account",
|
||||||
"description": "Visualizza le informazioni del tuo account",
|
"description": "Visualizza le informazioni del tuo account",
|
||||||
|
"name_label": "Nome visualizzato",
|
||||||
|
"account_type_label": "Tipo di account",
|
||||||
|
"demo_account": "Account dimostrativo",
|
||||||
"email": {
|
"email": {
|
||||||
"label": "Indirizzo email",
|
"label": "Indirizzo email",
|
||||||
"value": "{email}"
|
"value": "{email}"
|
||||||
@@ -1454,6 +1482,9 @@
|
|||||||
"title": "Contatti",
|
"title": "Contatti",
|
||||||
"search_placeholder": "Cerca contatti...",
|
"search_placeholder": "Cerca contatti...",
|
||||||
"create_new": "Nuovo contatto",
|
"create_new": "Nuovo contatto",
|
||||||
|
"no_category": "Senza categoria",
|
||||||
|
"category_added": "Contatto aggiunto a {name}",
|
||||||
|
"category_added_plural": "{count} contatti aggiunti a {name}",
|
||||||
"empty_state": "Nessun contatto",
|
"empty_state": "Nessun contatto",
|
||||||
"empty_state_title": "Nessun contatto",
|
"empty_state_title": "Nessun contatto",
|
||||||
"empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard",
|
"empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard",
|
||||||
@@ -1473,7 +1504,8 @@
|
|||||||
"title": "Condivisi"
|
"title": "Condivisi"
|
||||||
},
|
},
|
||||||
"address_books": {
|
"address_books": {
|
||||||
"title": "Rubriche",
|
"title": "Le mie Rubriche",
|
||||||
|
"shared_prefix": "Condiviso: {name}",
|
||||||
"moved": "Contatto spostato in {name}",
|
"moved": "Contatto spostato in {name}",
|
||||||
"moved_plural": "{count} contatti spostati in {name}",
|
"moved_plural": "{count} contatti spostati in {name}",
|
||||||
"move_failed": "Impossibile spostare il contatto",
|
"move_failed": "Impossibile spostare il contatto",
|
||||||
@@ -1596,7 +1628,8 @@
|
|||||||
"level_low": "Basso",
|
"level_low": "Basso",
|
||||||
"categories": "Categorie",
|
"categories": "Categorie",
|
||||||
"categories_placeholder": "es., Famiglia, Amici, Colleghi",
|
"categories_placeholder": "es., Famiglia, Amici, Colleghi",
|
||||||
"categories_hint": "Separare con virgole",
|
"categories_hint": "Digita per cercare o aggiungere",
|
||||||
|
"category_add": "Aggiungi",
|
||||||
"note": "Note",
|
"note": "Note",
|
||||||
"note_placeholder": "Aggiungi una nota...",
|
"note_placeholder": "Aggiungi una nota...",
|
||||||
"gender": "Genere",
|
"gender": "Genere",
|
||||||
@@ -1938,6 +1971,12 @@
|
|||||||
"deleted": "Abbonamento rimosso",
|
"deleted": "Abbonamento rimosso",
|
||||||
"delete_error": "Impossibile rimuovere l'abbonamento",
|
"delete_error": "Impossibile rimuovere l'abbonamento",
|
||||||
"last_refreshed": "Ultimo aggiornamento: {time}"
|
"last_refreshed": "Ultimo aggiornamento: {time}"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"no_tasks": "Nessuna attività",
|
||||||
|
"no_title": "(Senza titolo)",
|
||||||
|
"mark_complete": "Segna come completata",
|
||||||
|
"mark_incomplete": "Segna come non completata"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"advanced_search": {
|
"advanced_search": {
|
||||||
@@ -1978,7 +2017,17 @@
|
|||||||
"tip_settings": "Personalizza la tua esperienza nelle Impostazioni",
|
"tip_settings": "Personalizza la tua esperienza nelle Impostazioni",
|
||||||
"got_it": "Ho capito",
|
"got_it": "Ho capito",
|
||||||
"settings": "Impostazioni",
|
"settings": "Impostazioni",
|
||||||
"dismiss": "Chiudi"
|
"dismiss": "Chiudi",
|
||||||
|
"start_tour": "Inizia il tour"
|
||||||
|
},
|
||||||
|
"demo_welcome": {
|
||||||
|
"title": "Benvenuto su Bulwark Mail",
|
||||||
|
"description": "Esplora un client webmail completo — direttamente nel tuo browser. Tutti i dati restano sul tuo dispositivo, quindi prova tutto.",
|
||||||
|
"feature_email": "Leggere e scrivere email",
|
||||||
|
"feature_organize": "Tag, stelle e cartelle",
|
||||||
|
"feature_shortcuts": "Scorciatoie da tastiera",
|
||||||
|
"feature_privacy": "Demo 100% privata",
|
||||||
|
"hint": "Clicca su un'email a sinistra per iniziare, oppure fai il tour."
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"title": "File",
|
"title": "File",
|
||||||
@@ -2158,5 +2207,48 @@
|
|||||||
"export_passphrase_desc": "Scegli una passphrase per proteggere il file PKCS#12 esportato",
|
"export_passphrase_desc": "Scegli una passphrase per proteggere il file PKCS#12 esportato",
|
||||||
"export_storage_desc": "Inserisci la passphrase di archiviazione per decrittare la chiave prima dell'esportazione",
|
"export_storage_desc": "Inserisci la passphrase di archiviazione per decrittare la chiave prima dell'esportazione",
|
||||||
"incorrect_passphrase": "Passphrase errata"
|
"incorrect_passphrase": "Passphrase errata"
|
||||||
|
},
|
||||||
|
"tour": {
|
||||||
|
"step_counter": "Passo {current} di {total}",
|
||||||
|
"skip": "Salta il tour",
|
||||||
|
"back": "Indietro",
|
||||||
|
"next": "Avanti",
|
||||||
|
"finish": "Fine",
|
||||||
|
"take_a_tour": "Fai un tour dell'interfaccia",
|
||||||
|
"restart_title": "Tour introduttivo",
|
||||||
|
"restart_desc": "Rivedi la guida dell'interfaccia",
|
||||||
|
"restart_button": "Riavvia il tour",
|
||||||
|
"sidebar_title": "Le tue caselle di posta",
|
||||||
|
"sidebar_desc": "Questa è la barra laterale delle cartelle. Clicca su una casella per vedere le email. Puoi creare cartelle, trascinare email tra loro e vedere i conteggi dei non letti.",
|
||||||
|
"compose_title": "Scrivi un'email",
|
||||||
|
"compose_desc": "Clicca qui per scrivere una nuova email. Puoi aggiungere destinatari, allegati e usare la formattazione RTF.",
|
||||||
|
"search_title": "Cerca nella posta",
|
||||||
|
"search_desc": "Cerca per mittente, oggetto o contenuto. Clicca sull'icona del filtro per opzioni avanzate come intervallo di date, allegati e messaggi speciali.",
|
||||||
|
"email_list_title": "La tua lista email",
|
||||||
|
"email_list_desc": "Le email appaiono qui. Clicca su una per leggerla a destra. Usa la casella di controllo per selezionarne più di una, poi sposta, elimina o etichetta in blocco.",
|
||||||
|
"email_viewer_title": "Pannello di lettura",
|
||||||
|
"email_viewer_desc": "L'email selezionata si apre qui. Rispondi, inoltra, archivia o elimina con i pulsanti della barra degli strumenti. Puoi anche contrassegnare le email o aggiungere etichette colorate.",
|
||||||
|
"keywords_title": "Etichette colorate",
|
||||||
|
"keywords_desc": "Organizza le tue email con etichette colorate. Trascina un'email su un'etichetta per contrassegnarla, o fai clic destro per assegnare etichette.",
|
||||||
|
"calendar_title": "Calendario",
|
||||||
|
"calendar_desc": "Passa al calendario per gestire i tuoi eventi. Crea eventi, imposta promemoria e visualizza le viste giorno, settimana o mese.",
|
||||||
|
"contacts_title": "Contatti",
|
||||||
|
"contacts_desc": "La tua rubrica si trova qui. Importa contatti, crea gruppi e clicca su un contatto per vedere i suoi dettagli completi.",
|
||||||
|
"settings_title": "Impostazioni",
|
||||||
|
"settings_desc": "Personalizza tutto: tema, densità, firme, filtri, scorciatoie da tastiera, impostazioni del calendario e altro ancora.",
|
||||||
|
"shortcuts_title": "Scorciatoie da tastiera",
|
||||||
|
"shortcuts_desc": "Gli utenti esperti adorano questo. Premi ? in qualsiasi momento per vedere tutte le scorciatoie disponibili. Puoi navigare, comporre e gestire le email senza toccare il mouse.",
|
||||||
|
"calendar_view_title": "Il tuo calendario",
|
||||||
|
"calendar_view_desc": "Ecco il tuo calendario con eventi di esempio. Puoi passare tra le viste giorno, settimana, mese e agenda usando la barra degli strumenti.",
|
||||||
|
"contacts_list_title": "I tuoi contatti",
|
||||||
|
"contacts_list_desc": "Ecco i tuoi contatti. Clicca su un contatto per vedere i suoi dettagli a destra. Puoi anche creare nuovi contatti, importare vCard o organizzare i contatti in gruppi.",
|
||||||
|
"files_title": "Archiviazione file",
|
||||||
|
"settings_tabs_title": "Menu impostazioni",
|
||||||
|
"settings_tabs_desc": "Ecco tutte le categorie di impostazioni. Personalizza l'aspetto, gestisci le identità, configura i filtri email, imposta il calendario e molto altro.",
|
||||||
|
"files_desc": "Il tuo file browser ti permette di caricare, organizzare e condividere file — come un cloud personale integrato nella tua posta.",
|
||||||
|
"demo_banner_title": "Controlli demo",
|
||||||
|
"demo_banner_desc": "Sei in modalità demo — tutto rimane nel tuo browser. Premi 'Reimposta Demo' in qualsiasi momento per ricominciare con dati puliti.",
|
||||||
|
"quota_title": "Utilizzo dello spazio",
|
||||||
|
"quota_desc": "Monitora le dimensioni della tua casella qui. Il cerchio si riempie man mano che utilizzi più spazio."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+97
-5
@@ -34,9 +34,18 @@
|
|||||||
"dismiss": "閉じる",
|
"dismiss": "閉じる",
|
||||||
"or": "または",
|
"or": "または",
|
||||||
"sign_in_sso": "SSOでサインイン",
|
"sign_in_sso": "SSOでサインイン",
|
||||||
|
"add_account_title": "アカウントを追加",
|
||||||
|
"add_account_subtitle": "別のアカウントでサインイン",
|
||||||
|
"cancel": "キャンセル",
|
||||||
"website": "ウェブサイト",
|
"website": "ウェブサイト",
|
||||||
"imprint": "サイト運営者情報",
|
"imprint": "サイト運営者情報",
|
||||||
"privacy_policy": "プライバシーポリシー",
|
"privacy_policy": "プライバシーポリシー",
|
||||||
|
"try_demo": "デモを試す",
|
||||||
|
"demo_description": "サンプルデータで探索 — アカウント不要",
|
||||||
|
"demo_launching": "デモを起動中...",
|
||||||
|
"demo_login_button": "デモを開始",
|
||||||
|
"demo_tagline": "フル機能のメールクライアントを体験。アカウント不要。",
|
||||||
|
"demo_no_signup": "登録不要 — サンプルデータで自由に探索",
|
||||||
"oauth_completing": "サインイン処理中...",
|
"oauth_completing": "サインイン処理中...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "認証に失敗しました",
|
"title": "認証に失敗しました",
|
||||||
@@ -58,6 +67,11 @@
|
|||||||
"storage_free": "空き",
|
"storage_free": "空き",
|
||||||
"storage_total": "合計",
|
"storage_total": "合計",
|
||||||
"sign_out": "サインアウト",
|
"sign_out": "サインアウト",
|
||||||
|
"sign_out_of": "{account} からサインアウト",
|
||||||
|
"sign_out_all": "すべてのアカウントからサインアウト",
|
||||||
|
"add_account": "アカウントを追加",
|
||||||
|
"set_as_default": "デフォルトに設定",
|
||||||
|
"switch_account": "アカウントを切り替え",
|
||||||
"contacts": "連絡先",
|
"contacts": "連絡先",
|
||||||
"calendar": "カレンダー",
|
"calendar": "カレンダー",
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
@@ -96,6 +110,9 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "検索をクリア",
|
"clear_search": "検索をクリア",
|
||||||
"vacation_active": "不在応答が有効です",
|
"vacation_active": "不在応答が有効です",
|
||||||
|
"demo_banner": "デモモード",
|
||||||
|
"demo_reset": "リセット",
|
||||||
|
"demo_tour": "ツアー",
|
||||||
"tags": "タグ",
|
"tags": "タグ",
|
||||||
"mail": "メール",
|
"mail": "メール",
|
||||||
"nav_label": "ナビゲーション",
|
"nav_label": "ナビゲーション",
|
||||||
@@ -202,6 +219,7 @@
|
|||||||
"attachments": "添付ファイル",
|
"attachments": "添付ファイル",
|
||||||
"important": "重要",
|
"important": "重要",
|
||||||
"download": "ダウンロード",
|
"download": "ダウンロード",
|
||||||
|
"download_all": "すべてダウンロード",
|
||||||
"from": "送信者",
|
"from": "送信者",
|
||||||
"to": "宛先",
|
"to": "宛先",
|
||||||
"cc": "CC",
|
"cc": "CC",
|
||||||
@@ -393,7 +411,8 @@
|
|||||||
},
|
},
|
||||||
"previous": "前へ",
|
"previous": "前へ",
|
||||||
"next": "次へ",
|
"next": "次へ",
|
||||||
"send": "送信"
|
"send": "送信",
|
||||||
|
"more": "他"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "新規メッセージ",
|
"new_message": "新規メッセージ",
|
||||||
@@ -745,6 +764,12 @@
|
|||||||
"preview": "可能ならプレビューを開く",
|
"preview": "可能ならプレビューを開く",
|
||||||
"download": "すぐにダウンロード"
|
"download": "すぐにダウンロード"
|
||||||
},
|
},
|
||||||
|
"attachment_position": {
|
||||||
|
"label": "添付ファイルの位置",
|
||||||
|
"description": "メールヘッダー内での添付ファイルの表示位置",
|
||||||
|
"beside-sender": "送信者の横",
|
||||||
|
"below-header": "ヘッダーの下"
|
||||||
|
},
|
||||||
"emails_per_page": {
|
"emails_per_page": {
|
||||||
"25": "25件",
|
"25": "25件",
|
||||||
"50": "50件",
|
"50": "50件",
|
||||||
@@ -831,6 +856,9 @@
|
|||||||
"account": {
|
"account": {
|
||||||
"title": "アカウント",
|
"title": "アカウント",
|
||||||
"description": "アカウント情報を表示",
|
"description": "アカウント情報を表示",
|
||||||
|
"name_label": "表示名",
|
||||||
|
"account_type_label": "アカウントタイプ",
|
||||||
|
"demo_account": "デモアカウント",
|
||||||
"email": {
|
"email": {
|
||||||
"label": "メールアドレス",
|
"label": "メールアドレス",
|
||||||
"value": "{email}"
|
"value": "{email}"
|
||||||
@@ -1454,6 +1482,9 @@
|
|||||||
"title": "連絡先",
|
"title": "連絡先",
|
||||||
"search_placeholder": "連絡先を検索...",
|
"search_placeholder": "連絡先を検索...",
|
||||||
"create_new": "新しい連絡先",
|
"create_new": "新しい連絡先",
|
||||||
|
"no_category": "カテゴリなし",
|
||||||
|
"category_added": "{name} に連絡先を追加しました",
|
||||||
|
"category_added_plural": "{count} 件の連絡先を {name} に追加しました",
|
||||||
"empty_state": "連絡先がありません",
|
"empty_state": "連絡先がありません",
|
||||||
"empty_state_title": "連絡先がありません",
|
"empty_state_title": "連絡先がありません",
|
||||||
"empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください",
|
"empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください",
|
||||||
@@ -1473,11 +1504,12 @@
|
|||||||
"title": "共有"
|
"title": "共有"
|
||||||
},
|
},
|
||||||
"address_books": {
|
"address_books": {
|
||||||
"title": "ディレクトリ",
|
"title": "マイアドレス帳",
|
||||||
|
"shared_prefix": "共有: {name}",
|
||||||
"moved": "連絡先を {name} に移動しました",
|
"moved": "連絡先を {name} に移動しました",
|
||||||
"moved_plural": "{count} 件の連絡先を {name} に移動しました",
|
"moved_plural": "{count} 件の連絡先を {name} に移動しました",
|
||||||
"move_failed": "連絡先の移動に失敗しました",
|
"move_failed": "連絡先の移動に失敗しました",
|
||||||
"address_book": "ディレクトリ"
|
"address_book": "アドレス帳"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "メールアドレス",
|
"emails": "メールアドレス",
|
||||||
@@ -1596,7 +1628,8 @@
|
|||||||
"level_low": "低",
|
"level_low": "低",
|
||||||
"categories": "カテゴリー",
|
"categories": "カテゴリー",
|
||||||
"categories_placeholder": "例:家族、友人、同僚",
|
"categories_placeholder": "例:家族、友人、同僚",
|
||||||
"categories_hint": "カンマで区切ってください",
|
"categories_hint": "検索または追加するには入力",
|
||||||
|
"category_add": "追加",
|
||||||
"note": "メモ",
|
"note": "メモ",
|
||||||
"note_placeholder": "メモを追加...",
|
"note_placeholder": "メモを追加...",
|
||||||
"gender": "性別",
|
"gender": "性別",
|
||||||
@@ -1938,6 +1971,12 @@
|
|||||||
"deleted": "購読を解除しました",
|
"deleted": "購読を解除しました",
|
||||||
"delete_error": "購読の解除に失敗しました",
|
"delete_error": "購読の解除に失敗しました",
|
||||||
"last_refreshed": "最終更新: {time}"
|
"last_refreshed": "最終更新: {time}"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"no_tasks": "タスクなし",
|
||||||
|
"no_title": "(タイトルなし)",
|
||||||
|
"mark_complete": "完了にする",
|
||||||
|
"mark_incomplete": "未完了にする"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"advanced_search": {
|
"advanced_search": {
|
||||||
@@ -1978,7 +2017,17 @@
|
|||||||
"tip_settings": "設定でカスタマイズできます",
|
"tip_settings": "設定でカスタマイズできます",
|
||||||
"got_it": "了解",
|
"got_it": "了解",
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"dismiss": "閉じる"
|
"dismiss": "閉じる",
|
||||||
|
"start_tour": "ツアーを開始"
|
||||||
|
},
|
||||||
|
"demo_welcome": {
|
||||||
|
"title": "Bulwark Mail へようこそ",
|
||||||
|
"description": "フル機能のウェブメールクライアントをブラウザで体験できます。すべてのデータはお使いのデバイスに保存されるので、自由にお試しください。",
|
||||||
|
"feature_email": "メールの読み書き",
|
||||||
|
"feature_organize": "タグ・スター・フォルダ",
|
||||||
|
"feature_shortcuts": "キーボードショートカット",
|
||||||
|
"feature_privacy": "100% プライベートなデモ",
|
||||||
|
"hint": "左のメールをクリックして始めるか、ツアーを開始してください。"
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"title": "ファイル",
|
"title": "ファイル",
|
||||||
@@ -2158,5 +2207,48 @@
|
|||||||
"export_passphrase_desc": "エクスポートする PKCS#12 ファイルを保護するパスフレーズを選択してください",
|
"export_passphrase_desc": "エクスポートする PKCS#12 ファイルを保護するパスフレーズを選択してください",
|
||||||
"export_storage_desc": "エクスポートのために鍵を復号する保存用パスフレーズを入力してください",
|
"export_storage_desc": "エクスポートのために鍵を復号する保存用パスフレーズを入力してください",
|
||||||
"incorrect_passphrase": "パスフレーズが正しくありません"
|
"incorrect_passphrase": "パスフレーズが正しくありません"
|
||||||
|
},
|
||||||
|
"tour": {
|
||||||
|
"step_counter": "ステップ {current} / {total}",
|
||||||
|
"skip": "ツアーをスキップ",
|
||||||
|
"back": "戻る",
|
||||||
|
"next": "次へ",
|
||||||
|
"finish": "完了",
|
||||||
|
"take_a_tour": "インターフェースのツアーを見る",
|
||||||
|
"restart_title": "紹介ツアー",
|
||||||
|
"restart_desc": "インターフェースのガイドツアーを再生する",
|
||||||
|
"restart_button": "ツアーを再開",
|
||||||
|
"sidebar_title": "メールボックス",
|
||||||
|
"sidebar_desc": "フォルダーサイドバーです。メールボックスをクリックしてメールを表示できます。フォルダーの作成、メールのドラッグ移動、未読数の確認ができます。",
|
||||||
|
"compose_title": "メールを作成",
|
||||||
|
"compose_desc": "ここをクリックして新しいメールを作成します。宛先、添付ファイルの追加やリッチテキスト書式が使えます。",
|
||||||
|
"search_title": "メールを検索",
|
||||||
|
"search_desc": "送信者、件名、内容で検索できます。フィルターアイコンをクリックすると、日付範囲、添付ファイル、スター付きメッセージなどの詳細オプションが使えます。",
|
||||||
|
"email_list_title": "メール一覧",
|
||||||
|
"email_list_desc": "メールがここに表示されます。クリックすると右側で読めます。チェックボックスで複数選択し、一括で移動、削除、タグ付けができます。",
|
||||||
|
"email_viewer_title": "閲覧パネル",
|
||||||
|
"email_viewer_desc": "選択したメールがここに開きます。ツールバーのボタンで返信、転送、アーカイブ、削除ができます。メールにスターやカラータグも付けられます。",
|
||||||
|
"keywords_title": "カラータグ",
|
||||||
|
"keywords_desc": "色分けされたタグでメールを整理できます。メールをタグにドラッグしてラベル付けするか、右クリックでタグを割り当てられます。",
|
||||||
|
"calendar_title": "カレンダー",
|
||||||
|
"calendar_desc": "カレンダーに切り替えてイベントを管理できます。イベントの作成、リマインダーの設定、日・週・月表示の切り替えができます。",
|
||||||
|
"contacts_title": "連絡先",
|
||||||
|
"contacts_desc": "アドレス帳がここにあります。連絡先のインポート、グループの作成、連絡先をクリックして詳細を確認できます。",
|
||||||
|
"settings_title": "設定",
|
||||||
|
"settings_desc": "すべてをカスタマイズできます:テーマ、表示密度、署名、フィルター、キーボードショートカット、カレンダー設定など。",
|
||||||
|
"shortcuts_title": "キーボードショートカット",
|
||||||
|
"shortcuts_desc": "パワーユーザー向けの機能です。いつでも ? を押すと利用可能なすべてのショートカットが表示されます。マウスを使わずにナビゲーション、作成、メール管理ができます。",
|
||||||
|
"calendar_view_title": "カレンダー表示",
|
||||||
|
"calendar_view_desc": "サンプルイベント付きのカレンダーです。ツールバーで日、週、月、アジェンダビューを切り替えられます。",
|
||||||
|
"contacts_list_title": "連絡先一覧",
|
||||||
|
"contacts_list_desc": "連絡先の一覧です。連絡先をクリックすると右側に詳細が表示されます。新しい連絡先の作成、vCardのインポート、グループへの整理もできます。",
|
||||||
|
"files_title": "ファイルストレージ",
|
||||||
|
"settings_tabs_title": "設定メニュー",
|
||||||
|
"settings_tabs_desc": "すべての設定カテゴリがここにあります。外観のカスタマイズ、IDの管理、メールフィルターの設定、カレンダーの構成など、多数の項目を調整できます。",
|
||||||
|
"files_desc": "ファイルブラウザでファイルのアップロード、整理、共有ができます。メールに統合されたパーソナルクラウドのようなものです。",
|
||||||
|
"demo_banner_title": "デモコントロール",
|
||||||
|
"demo_banner_desc": "デモモードです。すべてブラウザ内に保存されます。「デモをリセット」をクリックすると、いつでもクリーンなサンプルデータで再開できます。",
|
||||||
|
"quota_title": "ストレージ使用量",
|
||||||
|
"quota_desc": "メールボックスのサイズをここで確認できます。使用量が増えるとサークルが満たされます。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+96
-4
@@ -34,9 +34,18 @@
|
|||||||
"dismiss": "Sluiten",
|
"dismiss": "Sluiten",
|
||||||
"or": "of",
|
"or": "of",
|
||||||
"sign_in_sso": "Inloggen met SSO",
|
"sign_in_sso": "Inloggen met SSO",
|
||||||
|
"add_account_title": "Account toevoegen",
|
||||||
|
"add_account_subtitle": "Inloggen met een ander account",
|
||||||
|
"cancel": "Annuleren",
|
||||||
"website": "Website",
|
"website": "Website",
|
||||||
"imprint": "Colofon",
|
"imprint": "Colofon",
|
||||||
"privacy_policy": "Privacybeleid",
|
"privacy_policy": "Privacybeleid",
|
||||||
|
"try_demo": "Demo proberen",
|
||||||
|
"demo_description": "Verken met voorbeeldgegevens — geen account nodig",
|
||||||
|
"demo_launching": "Demo starten...",
|
||||||
|
"demo_login_button": "Demo starten",
|
||||||
|
"demo_tagline": "Ervaar een complete e-mailclient. Geen account nodig.",
|
||||||
|
"demo_no_signup": "Geen registratie nodig — verken vrij met voorbeeldgegevens",
|
||||||
"oauth_completing": "Aanmelding voltooien...",
|
"oauth_completing": "Aanmelding voltooien...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Authenticatie mislukt",
|
"title": "Authenticatie mislukt",
|
||||||
@@ -58,6 +67,11 @@
|
|||||||
"storage_free": "Vrij",
|
"storage_free": "Vrij",
|
||||||
"storage_total": "Totaal",
|
"storage_total": "Totaal",
|
||||||
"sign_out": "Afmelden",
|
"sign_out": "Afmelden",
|
||||||
|
"sign_out_of": "Uitloggen van {account}",
|
||||||
|
"sign_out_all": "Uitloggen van alle accounts",
|
||||||
|
"add_account": "Account toevoegen",
|
||||||
|
"set_as_default": "Als standaard instellen",
|
||||||
|
"switch_account": "Account wisselen",
|
||||||
"contacts": "Contacten",
|
"contacts": "Contacten",
|
||||||
"calendar": "Agenda",
|
"calendar": "Agenda",
|
||||||
"settings": "Instellingen",
|
"settings": "Instellingen",
|
||||||
@@ -96,6 +110,9 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Zoekopdracht wissen",
|
"clear_search": "Zoekopdracht wissen",
|
||||||
"vacation_active": "Afwezigheidsmelder is actief",
|
"vacation_active": "Afwezigheidsmelder is actief",
|
||||||
|
"demo_banner": "Demomodus",
|
||||||
|
"demo_reset": "Resetten",
|
||||||
|
"demo_tour": "Rondleiding",
|
||||||
"tags": "Labels",
|
"tags": "Labels",
|
||||||
"mail": "E-mail",
|
"mail": "E-mail",
|
||||||
"nav_label": "Navigatie",
|
"nav_label": "Navigatie",
|
||||||
@@ -202,6 +219,7 @@
|
|||||||
"attachments": "Bijlagen",
|
"attachments": "Bijlagen",
|
||||||
"important": "Belangrijk",
|
"important": "Belangrijk",
|
||||||
"download": "Downloaden",
|
"download": "Downloaden",
|
||||||
|
"download_all": "Alles downloaden",
|
||||||
"from": "Van",
|
"from": "Van",
|
||||||
"to": "Aan",
|
"to": "Aan",
|
||||||
"cc": "CC",
|
"cc": "CC",
|
||||||
@@ -393,7 +411,8 @@
|
|||||||
},
|
},
|
||||||
"previous": "Vorige",
|
"previous": "Vorige",
|
||||||
"next": "Volgende",
|
"next": "Volgende",
|
||||||
"send": "Verzenden"
|
"send": "Verzenden",
|
||||||
|
"more": "meer"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "Nieuw bericht",
|
"new_message": "Nieuw bericht",
|
||||||
@@ -745,6 +764,12 @@
|
|||||||
"preview": "Voorbeeld tonen indien mogelijk",
|
"preview": "Voorbeeld tonen indien mogelijk",
|
||||||
"download": "Direct downloaden"
|
"download": "Direct downloaden"
|
||||||
},
|
},
|
||||||
|
"attachment_position": {
|
||||||
|
"label": "Positie van bijlagen",
|
||||||
|
"description": "Waar bijlagen in de e-mailkop worden weergegeven",
|
||||||
|
"beside-sender": "Naast de afzender",
|
||||||
|
"below-header": "Onder de kop"
|
||||||
|
},
|
||||||
"emails_per_page": {
|
"emails_per_page": {
|
||||||
"25": "25 e-mails",
|
"25": "25 e-mails",
|
||||||
"50": "50 e-mails",
|
"50": "50 e-mails",
|
||||||
@@ -831,6 +856,9 @@
|
|||||||
"account": {
|
"account": {
|
||||||
"title": "Account",
|
"title": "Account",
|
||||||
"description": "Bekijk je accountinformatie",
|
"description": "Bekijk je accountinformatie",
|
||||||
|
"name_label": "Weergavenaam",
|
||||||
|
"account_type_label": "Accounttype",
|
||||||
|
"demo_account": "Demoaccount",
|
||||||
"email": {
|
"email": {
|
||||||
"label": "E-mailadres",
|
"label": "E-mailadres",
|
||||||
"value": "{email}"
|
"value": "{email}"
|
||||||
@@ -1454,6 +1482,9 @@
|
|||||||
"title": "Contacten",
|
"title": "Contacten",
|
||||||
"search_placeholder": "Contacten zoeken...",
|
"search_placeholder": "Contacten zoeken...",
|
||||||
"create_new": "Nieuw contact",
|
"create_new": "Nieuw contact",
|
||||||
|
"no_category": "Geen categorie",
|
||||||
|
"category_added": "Contact toegevoegd aan {name}",
|
||||||
|
"category_added_plural": "{count} contacten toegevoegd aan {name}",
|
||||||
"empty_state": "Geen contacten",
|
"empty_state": "Geen contacten",
|
||||||
"empty_state_title": "Geen contacten",
|
"empty_state_title": "Geen contacten",
|
||||||
"empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand",
|
"empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand",
|
||||||
@@ -1473,7 +1504,8 @@
|
|||||||
"title": "Gedeeld"
|
"title": "Gedeeld"
|
||||||
},
|
},
|
||||||
"address_books": {
|
"address_books": {
|
||||||
"title": "Adresboeken",
|
"title": "Mijn Adresboeken",
|
||||||
|
"shared_prefix": "Gedeeld: {name}",
|
||||||
"moved": "Contact verplaatst naar {name}",
|
"moved": "Contact verplaatst naar {name}",
|
||||||
"moved_plural": "{count} contacten verplaatst naar {name}",
|
"moved_plural": "{count} contacten verplaatst naar {name}",
|
||||||
"move_failed": "Verplaatsen van contact mislukt",
|
"move_failed": "Verplaatsen van contact mislukt",
|
||||||
@@ -1596,7 +1628,8 @@
|
|||||||
"level_low": "Laag",
|
"level_low": "Laag",
|
||||||
"categories": "Categorieën",
|
"categories": "Categorieën",
|
||||||
"categories_placeholder": "bijv. Familie, Vrienden, Collega's",
|
"categories_placeholder": "bijv. Familie, Vrienden, Collega's",
|
||||||
"categories_hint": "Scheiden met komma's",
|
"categories_hint": "Typ om te zoeken of toe te voegen",
|
||||||
|
"category_add": "Toevoegen",
|
||||||
"note": "Notities",
|
"note": "Notities",
|
||||||
"note_placeholder": "Notitie toevoegen...",
|
"note_placeholder": "Notitie toevoegen...",
|
||||||
"gender": "Geslacht",
|
"gender": "Geslacht",
|
||||||
@@ -1938,6 +1971,12 @@
|
|||||||
"deleted": "Abonnement verwijderd",
|
"deleted": "Abonnement verwijderd",
|
||||||
"delete_error": "Kan abonnement niet verwijderen",
|
"delete_error": "Kan abonnement niet verwijderen",
|
||||||
"last_refreshed": "Laatst bijgewerkt: {time}"
|
"last_refreshed": "Laatst bijgewerkt: {time}"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"no_tasks": "Geen taken",
|
||||||
|
"no_title": "(Geen titel)",
|
||||||
|
"mark_complete": "Markeren als voltooid",
|
||||||
|
"mark_incomplete": "Markeren als onvoltooid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"advanced_search": {
|
"advanced_search": {
|
||||||
@@ -1978,7 +2017,17 @@
|
|||||||
"tip_settings": "Pas uw ervaring aan in Instellingen",
|
"tip_settings": "Pas uw ervaring aan in Instellingen",
|
||||||
"got_it": "Begrepen",
|
"got_it": "Begrepen",
|
||||||
"settings": "Instellingen",
|
"settings": "Instellingen",
|
||||||
"dismiss": "Sluiten"
|
"dismiss": "Sluiten",
|
||||||
|
"start_tour": "Tour starten"
|
||||||
|
},
|
||||||
|
"demo_welcome": {
|
||||||
|
"title": "Welkom bij Bulwark Mail",
|
||||||
|
"description": "Ontdek een volwaardige webmail-client — rechtstreeks in je browser. Alle gegevens blijven op je apparaat, dus test gerust alles.",
|
||||||
|
"feature_email": "E-mails lezen en schrijven",
|
||||||
|
"feature_organize": "Tags, sterren en mappen",
|
||||||
|
"feature_shortcuts": "Sneltoetsen",
|
||||||
|
"feature_privacy": "100% privédemo",
|
||||||
|
"hint": "Klik links op een e-mail om te beginnen, of start de tour."
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Bestanden",
|
"title": "Bestanden",
|
||||||
@@ -2158,5 +2207,48 @@
|
|||||||
"export_passphrase_desc": "Kies een wachtwoordzin om het geëxporteerde PKCS#12-bestand te beschermen",
|
"export_passphrase_desc": "Kies een wachtwoordzin om het geëxporteerde PKCS#12-bestand te beschermen",
|
||||||
"export_storage_desc": "Voer de opslagwachtwoordzin in om de sleutel voor export te ontsleutelen",
|
"export_storage_desc": "Voer de opslagwachtwoordzin in om de sleutel voor export te ontsleutelen",
|
||||||
"incorrect_passphrase": "Onjuiste wachtwoordzin"
|
"incorrect_passphrase": "Onjuiste wachtwoordzin"
|
||||||
|
},
|
||||||
|
"tour": {
|
||||||
|
"step_counter": "Stap {current} van {total}",
|
||||||
|
"skip": "Tour overslaan",
|
||||||
|
"back": "Terug",
|
||||||
|
"next": "Volgende",
|
||||||
|
"finish": "Voltooien",
|
||||||
|
"take_a_tour": "Maak een rondleiding door de interface",
|
||||||
|
"restart_title": "Introductietour",
|
||||||
|
"restart_desc": "Bekijk de rondleiding door de interface opnieuw",
|
||||||
|
"restart_button": "Tour herstarten",
|
||||||
|
"sidebar_title": "Uw mailboxen",
|
||||||
|
"sidebar_desc": "Dit is uw mappenbalk. Klik op een mailbox om de e-mails te bekijken. U kunt mappen maken, e-mails tussen mappen slepen en ongelezen aantallen zien.",
|
||||||
|
"compose_title": "E-mail schrijven",
|
||||||
|
"compose_desc": "Klik hier om een nieuwe e-mail te schrijven. U kunt ontvangers, bijlagen toevoegen en rijke tekstopmaak gebruiken.",
|
||||||
|
"search_title": "Zoek in uw mail",
|
||||||
|
"search_desc": "Zoek op afzender, onderwerp of inhoud. Klik op het filtericoon voor geavanceerde opties zoals datumbereik, bijlagen en favoriete berichten.",
|
||||||
|
"email_list_title": "Uw e-maillijst",
|
||||||
|
"email_list_desc": "E-mails verschijnen hier. Klik op een e-mail om deze rechts te lezen. Gebruik het selectievakje om meerdere te selecteren, verplaats, verwijder of label ze vervolgens.",
|
||||||
|
"email_viewer_title": "Leesvenster",
|
||||||
|
"email_viewer_desc": "De geselecteerde e-mail opent hier. Beantwoord, doorstuur, archiveer of verwijder met de werkbalkknopen. U kunt ook e-mails markeren of kleurlabels toevoegen.",
|
||||||
|
"keywords_title": "Kleurlabels",
|
||||||
|
"keywords_desc": "Organiseer uw e-mail met kleurgecodeerde labels. Sleep een e-mail naar een label om het te markeren, of klik met de rechtermuisknop om labels toe te wijzen.",
|
||||||
|
"calendar_title": "Agenda",
|
||||||
|
"calendar_desc": "Schakel naar de agenda om uw evenementen te beheren. Maak evenementen aan, stel herinneringen in en bekijk dag-, week- of maandweergaven.",
|
||||||
|
"contacts_title": "Contacten",
|
||||||
|
"contacts_desc": "Uw adresboek bevindt zich hier. Importeer contacten, maak groepen aan en klik op een contact om de volledige details te bekijken.",
|
||||||
|
"settings_title": "Instellingen",
|
||||||
|
"settings_desc": "Pas alles aan: thema, dichtheid, handtekeningen, filters, sneltoetsen, agendainstellingen en meer.",
|
||||||
|
"shortcuts_title": "Sneltoetsen",
|
||||||
|
"shortcuts_desc": "Ervaren gebruikers zijn hier dol op. Druk op ? om alle beschikbare sneltoetsen te bekijken. Navigeer, schrijf en beheer e-mails zonder de muis aan te raken.",
|
||||||
|
"calendar_view_title": "Uw agenda",
|
||||||
|
"calendar_view_desc": "Hier is uw agenda met voorbeeldevenementen. Schakel tussen dag-, week-, maand- en agendaweergave via de werkbalk.",
|
||||||
|
"contacts_list_title": "Uw contacten",
|
||||||
|
"contacts_list_desc": "Hier zijn uw contacten. Klik op een contact om de details rechts te bekijken. U kunt ook nieuwe contacten aanmaken, vCards importeren of contacten in groepen organiseren.",
|
||||||
|
"files_title": "Bestandsopslag",
|
||||||
|
"settings_tabs_title": "Instellingenmenu",
|
||||||
|
"settings_tabs_desc": "Hier vindt u alle instellingscategorieën. Pas het uiterlijk aan, beheer identiteiten, stel e-mailfilters in, configureer uw agenda en nog veel meer.",
|
||||||
|
"files_desc": "Uw bestandsbrowser laat u bestanden uploaden, organiseren en delen — als een persoonlijke cloud geïntegreerd in uw mail.",
|
||||||
|
"demo_banner_title": "Demo-bediening",
|
||||||
|
"demo_banner_desc": "U bent in demomodus — alles blijft in uw browser. Klik op 'Demo resetten' om opnieuw te beginnen met schone voorbeeldgegevens.",
|
||||||
|
"quota_title": "Opslaggebruik",
|
||||||
|
"quota_desc": "Volg de grootte van uw mailbox hier. De cirkel vult zich naarmate u meer ruimte gebruikt."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+97
-5
@@ -34,9 +34,18 @@
|
|||||||
"dismiss": "Fechar",
|
"dismiss": "Fechar",
|
||||||
"or": "ou",
|
"or": "ou",
|
||||||
"sign_in_sso": "Entrar com SSO",
|
"sign_in_sso": "Entrar com SSO",
|
||||||
|
"add_account_title": "Adicionar conta",
|
||||||
|
"add_account_subtitle": "Entrar com outra conta",
|
||||||
|
"cancel": "Cancelar",
|
||||||
"website": "Site",
|
"website": "Site",
|
||||||
"imprint": "Informações legais",
|
"imprint": "Informações legais",
|
||||||
"privacy_policy": "Política de privacidade",
|
"privacy_policy": "Política de privacidade",
|
||||||
|
"try_demo": "Experimentar demo",
|
||||||
|
"demo_description": "Explore com dados de exemplo — sem conta necessária",
|
||||||
|
"demo_launching": "Iniciando demo...",
|
||||||
|
"demo_login_button": "Iniciar demo",
|
||||||
|
"demo_tagline": "Experimente um cliente de e-mail completo. Sem necessidade de conta.",
|
||||||
|
"demo_no_signup": "Sem registo — explore livremente com dados de exemplo",
|
||||||
"oauth_completing": "Concluindo login...",
|
"oauth_completing": "Concluindo login...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Falha na autenticação",
|
"title": "Falha na autenticação",
|
||||||
@@ -58,6 +67,11 @@
|
|||||||
"storage_free": "Livre",
|
"storage_free": "Livre",
|
||||||
"storage_total": "Total",
|
"storage_total": "Total",
|
||||||
"sign_out": "Sair",
|
"sign_out": "Sair",
|
||||||
|
"sign_out_of": "Sair de {account}",
|
||||||
|
"sign_out_all": "Sair de todas as contas",
|
||||||
|
"add_account": "Adicionar conta",
|
||||||
|
"set_as_default": "Definir como padrão",
|
||||||
|
"switch_account": "Trocar conta",
|
||||||
"contacts": "Contatos",
|
"contacts": "Contatos",
|
||||||
"calendar": "Calendário",
|
"calendar": "Calendário",
|
||||||
"settings": "Configurações",
|
"settings": "Configurações",
|
||||||
@@ -96,6 +110,9 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Limpar busca",
|
"clear_search": "Limpar busca",
|
||||||
"vacation_active": "Resposta automática ativa",
|
"vacation_active": "Resposta automática ativa",
|
||||||
|
"demo_banner": "Modo de demonstração",
|
||||||
|
"demo_reset": "Repor",
|
||||||
|
"demo_tour": "Tour",
|
||||||
"tags": "Etiquetas",
|
"tags": "Etiquetas",
|
||||||
"mail": "E-mail",
|
"mail": "E-mail",
|
||||||
"nav_label": "Navegação",
|
"nav_label": "Navegação",
|
||||||
@@ -202,6 +219,7 @@
|
|||||||
"attachments": "Anexos",
|
"attachments": "Anexos",
|
||||||
"important": "Importante",
|
"important": "Importante",
|
||||||
"download": "Baixar",
|
"download": "Baixar",
|
||||||
|
"download_all": "Baixar tudo",
|
||||||
"from": "De",
|
"from": "De",
|
||||||
"to": "Para",
|
"to": "Para",
|
||||||
"cc": "CC",
|
"cc": "CC",
|
||||||
@@ -393,7 +411,8 @@
|
|||||||
},
|
},
|
||||||
"previous": "Anterior",
|
"previous": "Anterior",
|
||||||
"next": "Próximo",
|
"next": "Próximo",
|
||||||
"send": "Enviar"
|
"send": "Enviar",
|
||||||
|
"more": "mais"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "Nova Mensagem",
|
"new_message": "Nova Mensagem",
|
||||||
@@ -745,6 +764,12 @@
|
|||||||
"preview": "Visualizar quando possível",
|
"preview": "Visualizar quando possível",
|
||||||
"download": "Baixar imediatamente"
|
"download": "Baixar imediatamente"
|
||||||
},
|
},
|
||||||
|
"attachment_position": {
|
||||||
|
"label": "Posição dos anexos",
|
||||||
|
"description": "Onde exibir os anexos no cabeçalho do e-mail",
|
||||||
|
"beside-sender": "Ao lado do remetente",
|
||||||
|
"below-header": "Abaixo do cabeçalho"
|
||||||
|
},
|
||||||
"emails_per_page": {
|
"emails_per_page": {
|
||||||
"25": "25 e-mails",
|
"25": "25 e-mails",
|
||||||
"50": "50 e-mails",
|
"50": "50 e-mails",
|
||||||
@@ -831,6 +856,9 @@
|
|||||||
"account": {
|
"account": {
|
||||||
"title": "Conta",
|
"title": "Conta",
|
||||||
"description": "Visualize as informações da sua conta",
|
"description": "Visualize as informações da sua conta",
|
||||||
|
"name_label": "Nome de exibição",
|
||||||
|
"account_type_label": "Tipo de conta",
|
||||||
|
"demo_account": "Conta de demonstração",
|
||||||
"email": {
|
"email": {
|
||||||
"label": "Endereço de E-mail",
|
"label": "Endereço de E-mail",
|
||||||
"value": "{email}"
|
"value": "{email}"
|
||||||
@@ -1454,6 +1482,9 @@
|
|||||||
"title": "Contatos",
|
"title": "Contatos",
|
||||||
"search_placeholder": "Pesquisar contatos...",
|
"search_placeholder": "Pesquisar contatos...",
|
||||||
"create_new": "Novo contato",
|
"create_new": "Novo contato",
|
||||||
|
"no_category": "Sem categoria",
|
||||||
|
"category_added": "Contato adicionado a {name}",
|
||||||
|
"category_added_plural": "{count} contatos adicionados a {name}",
|
||||||
"empty_state": "Nenhum contato",
|
"empty_state": "Nenhum contato",
|
||||||
"empty_state_title": "Sem contatos",
|
"empty_state_title": "Sem contatos",
|
||||||
"empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard",
|
"empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard",
|
||||||
@@ -1473,11 +1504,12 @@
|
|||||||
"title": "Compartilhados"
|
"title": "Compartilhados"
|
||||||
},
|
},
|
||||||
"address_books": {
|
"address_books": {
|
||||||
"title": "Diretórios",
|
"title": "Meus Catálogos de Endereços",
|
||||||
|
"shared_prefix": "Compartilhado: {name}",
|
||||||
"moved": "Contato movido para {name}",
|
"moved": "Contato movido para {name}",
|
||||||
"moved_plural": "{count} contatos movidos para {name}",
|
"moved_plural": "{count} contatos movidos para {name}",
|
||||||
"move_failed": "Falha ao mover o contato",
|
"move_failed": "Falha ao mover o contato",
|
||||||
"address_book": "Diretório"
|
"address_book": "Catálogo de endereços"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Endereços de e-mail",
|
"emails": "Endereços de e-mail",
|
||||||
@@ -1596,7 +1628,8 @@
|
|||||||
"level_low": "Baixo",
|
"level_low": "Baixo",
|
||||||
"categories": "Categorias",
|
"categories": "Categorias",
|
||||||
"categories_placeholder": "ex., Família, Amigos, Colegas",
|
"categories_placeholder": "ex., Família, Amigos, Colegas",
|
||||||
"categories_hint": "Separar com vírgulas",
|
"categories_hint": "Digite para pesquisar ou adicionar",
|
||||||
|
"category_add": "Adicionar",
|
||||||
"note": "Notas",
|
"note": "Notas",
|
||||||
"note_placeholder": "Adicionar uma nota...",
|
"note_placeholder": "Adicionar uma nota...",
|
||||||
"gender": "Gênero",
|
"gender": "Gênero",
|
||||||
@@ -1938,6 +1971,12 @@
|
|||||||
"deleted": "Assinatura removida",
|
"deleted": "Assinatura removida",
|
||||||
"delete_error": "Falha ao remover assinatura",
|
"delete_error": "Falha ao remover assinatura",
|
||||||
"last_refreshed": "Última atualização: {time}"
|
"last_refreshed": "Última atualização: {time}"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"no_tasks": "Sem tarefas",
|
||||||
|
"no_title": "(Sem título)",
|
||||||
|
"mark_complete": "Marcar como concluída",
|
||||||
|
"mark_incomplete": "Marcar como não concluída"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"advanced_search": {
|
"advanced_search": {
|
||||||
@@ -1978,7 +2017,17 @@
|
|||||||
"tip_settings": "Personalize sua experiência nas Configurações",
|
"tip_settings": "Personalize sua experiência nas Configurações",
|
||||||
"got_it": "Entendi",
|
"got_it": "Entendi",
|
||||||
"settings": "Configurações",
|
"settings": "Configurações",
|
||||||
"dismiss": "Fechar"
|
"dismiss": "Fechar",
|
||||||
|
"start_tour": "Iniciar tour"
|
||||||
|
},
|
||||||
|
"demo_welcome": {
|
||||||
|
"title": "Bem-vindo ao Bulwark Mail",
|
||||||
|
"description": "Explore um cliente de webmail completo — diretamente no seu navegador. Todos os dados ficam no seu dispositivo, então teste tudo.",
|
||||||
|
"feature_email": "Ler e escrever e-mails",
|
||||||
|
"feature_organize": "Tags, estrelas e pastas",
|
||||||
|
"feature_shortcuts": "Atalhos de teclado",
|
||||||
|
"feature_privacy": "Demo 100% privada",
|
||||||
|
"hint": "Clique num e-mail à esquerda para começar, ou inicie o tour."
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Ficheiros",
|
"title": "Ficheiros",
|
||||||
@@ -2158,5 +2207,48 @@
|
|||||||
"export_passphrase_desc": "Escolha uma frase secreta para proteger o arquivo PKCS#12 exportado",
|
"export_passphrase_desc": "Escolha uma frase secreta para proteger o arquivo PKCS#12 exportado",
|
||||||
"export_storage_desc": "Insira a frase secreta de armazenamento para descriptografar a chave para exportação",
|
"export_storage_desc": "Insira a frase secreta de armazenamento para descriptografar a chave para exportação",
|
||||||
"incorrect_passphrase": "Frase secreta incorreta"
|
"incorrect_passphrase": "Frase secreta incorreta"
|
||||||
|
},
|
||||||
|
"tour": {
|
||||||
|
"step_counter": "Passo {current} de {total}",
|
||||||
|
"skip": "Pular tour",
|
||||||
|
"back": "Voltar",
|
||||||
|
"next": "Próximo",
|
||||||
|
"finish": "Finalizar",
|
||||||
|
"take_a_tour": "Faça um tour pela interface",
|
||||||
|
"restart_title": "Tour introdutório",
|
||||||
|
"restart_desc": "Rever o tour guiado da interface",
|
||||||
|
"restart_button": "Reiniciar tour",
|
||||||
|
"sidebar_title": "Suas caixas de correio",
|
||||||
|
"sidebar_desc": "Esta é a barra lateral de pastas. Clique em qualquer caixa para ver seus e-mails. Você pode criar pastas, arrastar e-mails entre elas e ver contadores de não lidos.",
|
||||||
|
"compose_title": "Escrever um e-mail",
|
||||||
|
"compose_desc": "Clique aqui para escrever um novo e-mail. Você pode adicionar destinatários, anexos e usar formatação de texto enriquecido.",
|
||||||
|
"search_title": "Pesquisar sua caixa",
|
||||||
|
"search_desc": "Pesquise por remetente, assunto ou conteúdo. Clique no ícone de filtro para opções avançadas como intervalo de datas, anexos e mensagens com estrela.",
|
||||||
|
"email_list_title": "Sua lista de e-mails",
|
||||||
|
"email_list_desc": "Os e-mails aparecem aqui. Clique em um para lê-lo à direita. Use a caixa de seleção para selecionar vários, depois mova, exclua ou etiquete em massa.",
|
||||||
|
"email_viewer_title": "Painel de leitura",
|
||||||
|
"email_viewer_desc": "O e-mail selecionado abre aqui. Responda, encaminhe, arquive ou exclua com os botões da barra de ferramentas. Você também pode marcar e-mails com estrela ou adicionar etiquetas coloridas.",
|
||||||
|
"keywords_title": "Etiquetas coloridas",
|
||||||
|
"keywords_desc": "Organize seus e-mails com etiquetas coloridas. Arraste um e-mail para uma etiqueta para marcá-lo, ou clique com o botão direito para atribuir etiquetas.",
|
||||||
|
"calendar_title": "Calendário",
|
||||||
|
"calendar_desc": "Mude para o calendário para gerenciar seus eventos. Crie eventos, defina lembretes e visualize os layouts de dia, semana ou mês.",
|
||||||
|
"contacts_title": "Contatos",
|
||||||
|
"contacts_desc": "Seu livro de endereços fica aqui. Importe contatos, crie grupos e clique em qualquer contato para ver seus detalhes completos.",
|
||||||
|
"settings_title": "Configurações",
|
||||||
|
"settings_desc": "Personalize tudo: tema, densidade, assinaturas, filtros, atalhos de teclado, padrões do calendário e mais.",
|
||||||
|
"shortcuts_title": "Atalhos de teclado",
|
||||||
|
"shortcuts_desc": "Usuários avançados adoram isso. Pressione ? a qualquer momento para ver todos os atalhos disponíveis. Navegue, escreva e gerencie e-mails sem tocar no mouse.",
|
||||||
|
"calendar_view_title": "Seu calendário",
|
||||||
|
"calendar_view_desc": "Aqui está seu calendário com eventos de exemplo. Você pode alternar entre as visualizações de dia, semana, mês e agenda usando a barra de ferramentas.",
|
||||||
|
"contacts_list_title": "Seus contatos",
|
||||||
|
"contacts_list_desc": "Aqui estão seus contatos. Clique em qualquer contato para ver seus detalhes à direita. Você também pode criar novos contatos, importar vCards ou organizar contatos em grupos.",
|
||||||
|
"files_title": "Armazenamento de ficheiros",
|
||||||
|
"settings_tabs_title": "Menu de configurações",
|
||||||
|
"settings_tabs_desc": "Aqui estão todas as categorias de configurações. Personalize a aparência, gerencie identidades, configure filtros de e-mail, ajuste o calendário e muito mais.",
|
||||||
|
"files_desc": "O navegador de ficheiros permite carregar, organizar e partilhar ficheiros — como uma nuvem pessoal integrada no seu e-mail.",
|
||||||
|
"demo_banner_title": "Controlos de demonstração",
|
||||||
|
"demo_banner_desc": "Está no modo de demonstração — tudo permanece no seu navegador. Clique em 'Repor Demonstração' a qualquer momento para recomeçar com dados limpos.",
|
||||||
|
"quota_title": "Utilização do armazenamento",
|
||||||
|
"quota_desc": "Acompanhe o tamanho da sua caixa de correio aqui. O círculo preenche-se à medida que utiliza mais espaço."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.4.2",
|
"version": "1.4.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.4.2",
|
"version": "1.4.4",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.13.18",
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
@@ -5480,9 +5480,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.3.3",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user