Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9207ec563c | ||
|
|
44e0e17203 | ||
|
|
b9c9901643 | ||
|
|
a201c1617b | ||
|
|
d24f402b0c | ||
|
|
5cfc905f10 | ||
|
|
4531cfe47c | ||
|
|
927a3b8b11 | ||
|
|
4ad8396adc | ||
|
|
6aaeb34272 | ||
|
|
ab57966a94 | ||
|
|
18d9b9adf6 | ||
|
|
ed311d79e3 | ||
|
|
457400ceee | ||
|
|
88d87be685 | ||
|
|
5ddb2acfc7 | ||
|
|
5f150f039d | ||
|
|
4f54f768e8 | ||
|
|
c24762c7a3 | ||
|
|
3e85e07363 | ||
|
|
facef97fcc | ||
|
|
89d580b90d | ||
|
|
734155c939 | ||
|
|
790d7084af | ||
|
|
60efb047d8 | ||
|
|
f9052eb23f |
@@ -48,6 +48,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
|
|
||||||
# OAuth client secret (server-side only, never exposed to the browser)
|
# OAuth client secret (server-side only, never exposed to the browser)
|
||||||
# OAUTH_CLIENT_SECRET=your-client-secret
|
# OAUTH_CLIENT_SECRET=your-client-secret
|
||||||
|
# Alternatively, you can specify the path to a file containing the OAuth client secret.
|
||||||
|
# OAUTH_CLIENT_SECRET_FILE=/oauth-client-secret
|
||||||
|
|
||||||
# OpenID Connect issuer URL for discovery
|
# OpenID Connect issuer URL for discovery
|
||||||
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
||||||
@@ -60,6 +62,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# Required for both "Remember me" and settings sync features.
|
# Required for both "Remember me" and settings sync features.
|
||||||
# Generate with: openssl rand -base64 32
|
# Generate with: openssl rand -base64 32
|
||||||
# SESSION_SECRET=your-secret-key-here
|
# SESSION_SECRET=your-secret-key-here
|
||||||
|
# Alternatively, you can specify the path to a file containing the session secret.
|
||||||
|
# SESSION_SECRET_FILE=/session-secret
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Settings Sync
|
# Settings Sync
|
||||||
|
|||||||
@@ -21,11 +21,23 @@ on:
|
|||||||
- ".github/workflows/docker-publish.yml"
|
- ".github/workflows/docker-publish.yml"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
|
||||||
IMAGE_NAME: ghcr.io/${{ github.repository }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
image_name: ${{ steps.set.outputs.image_name }}
|
||||||
|
steps:
|
||||||
|
- name: Set image name
|
||||||
|
id: set
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||||
|
echo "image_name=ghcr.io/${{ github.repository }}-beta" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "image_name=ghcr.io/${{ github.repository }}-${{ github.ref_name }}" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
needs: prepare
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -57,7 +69,7 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.IMAGE_NAME }}
|
images: ${{ needs.prepare.outputs.image_name }}
|
||||||
|
|
||||||
- name: Build and push by digest
|
- name: Build and push by digest
|
||||||
id: build
|
id: build
|
||||||
@@ -66,7 +78,7 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
|
||||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||||
|
|
||||||
@@ -86,7 +98,7 @@ jobs:
|
|||||||
|
|
||||||
merge:
|
merge:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build
|
needs: [prepare, build]
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -113,17 +125,17 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.IMAGE_NAME }}
|
images: ${{ needs.prepare.outputs.image_name }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value={{branch}}
|
type=raw,value=latest
|
||||||
type=sha,prefix={{branch}}-
|
type=sha
|
||||||
|
|
||||||
- name: Create manifest list and push
|
- name: Create manifest list and push
|
||||||
working-directory: /tmp/digests
|
working-directory: /tmp/digests
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
$(printf '${{ needs.prepare.outputs.image_name }}@sha256:%s ' *)
|
||||||
|
|
||||||
- name: Inspect image
|
- name: Inspect image
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
docker buildx imagetools inspect ${{ needs.prepare.outputs.image_name }}:${{ steps.meta.outputs.version }}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
name: Publish Standalone Tarball on Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
arch: amd64
|
||||||
|
- os: ubuntu-24.04-arm
|
||||||
|
arch: arm64
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build standalone
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Package tarball
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
ARCH: ${{ matrix.arch }}
|
||||||
|
run: |
|
||||||
|
VERSION="${REF_NAME#v}"
|
||||||
|
TARBALL="bulwark-standalone-${VERSION}-linux-${ARCH}.tar.gz"
|
||||||
|
|
||||||
|
mkdir -p bulwark-standalone
|
||||||
|
cp -r .next/standalone/. bulwark-standalone/
|
||||||
|
cp -r .next/static bulwark-standalone/.next/static
|
||||||
|
cp -r public bulwark-standalone/public
|
||||||
|
|
||||||
|
tar -czf "$TARBALL" bulwark-standalone/
|
||||||
|
echo "TARBALL=$TARBALL" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Upload release asset
|
||||||
|
if: github.event_name == 'release'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||||
|
run: gh release upload "$TAG_NAME" "$TARBALL" --clobber
|
||||||
|
|
||||||
|
- name: Upload artifact (workflow_dispatch)
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: standalone-${{ matrix.arch }}
|
||||||
|
path: ${{ env.TARBALL }}
|
||||||
|
retention-days: 7
|
||||||
@@ -1,5 +1,38 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.4.13 (2026-04-12)
|
||||||
|
|
||||||
|
Thank you for your donations:
|
||||||
|
|
||||||
|
**One-time**
|
||||||
|
- [@boris22100](https://github.com/boris22100)
|
||||||
|
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||||
|
|
||||||
|
**Monthly**
|
||||||
|
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Contacts**: Store trusted senders in a dedicated JMAP address book (#176)
|
||||||
|
- **Email**: Warn on send when attachment keyword found but no file attached (#172)
|
||||||
|
- **Email**: Enable keyword reordering (#174) and multi-tag support per email (#173)
|
||||||
|
- **PWA**: Add "don't remind me again" option to install prompt
|
||||||
|
- **Auth**: Add `SESSION_SECRET_FILE` and `OAUTH_CLIENT_SECRET_FILE` environment variable support
|
||||||
|
- **Plugins**: Add `onAvatarResolve` plugin hook
|
||||||
|
- **Docker**: Publish main and dev branches as separate GHCR packages
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Email**: Style links in plain text emails
|
||||||
|
- **Email**: Seed list history entry when app initializes on an email view
|
||||||
|
- **Email**: Remount composer on draft edit and preserve identity (#60)
|
||||||
|
- **Contacts**: Display contact names stored in `name.full` (#179)
|
||||||
|
- **Contacts**: Fix category dropdown blocking Save button in contact form (#177)
|
||||||
|
- **Contacts**: Resolve TS error from optional `name.components` in vCard parser
|
||||||
|
- **Search**: Search all folders when filtering emails by tag (#175)
|
||||||
|
- **Auth**: Include mount prefix in SSO redirect URI when app is served under a subpath
|
||||||
|
- **PWA**: Correct PWA icons with proper sizing, transparency, and dark/light mode support
|
||||||
|
|
||||||
## 1.4.12 (2026-04-09)
|
## 1.4.12 (2026-04-09)
|
||||||
|
|
||||||
Thank you for your donations:
|
Thank you for your donations:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -271,6 +271,7 @@ PORT=3000 # Default listen port
|
|||||||
OAUTH_ENABLED=true
|
OAUTH_ENABLED=true
|
||||||
OAUTH_CLIENT_ID=webmail
|
OAUTH_CLIENT_ID=webmail
|
||||||
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
||||||
|
OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret
|
||||||
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
|
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -282,7 +283,8 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
|
|||||||
<summary>Remember Me</summary>
|
<summary>Remember Me</summary>
|
||||||
|
|
||||||
```env
|
```env
|
||||||
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
|
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
|
||||||
|
SESSION_SECRET_FILE=/session-secret # Path to a file containing the session secret
|
||||||
```
|
```
|
||||||
|
|
||||||
Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry).
|
Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry).
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useAuthStore } from "@/stores/auth-store";
|
|||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
|
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
||||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||||
@@ -231,7 +232,8 @@ export default function LoginPage() {
|
|||||||
const startServerSideSso = useCallback(async () => {
|
const startServerSideSso = useCallback(async () => {
|
||||||
setOauthLoading(true);
|
setOauthLoading(true);
|
||||||
try {
|
try {
|
||||||
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
|
const prefix = getPathPrefix(params.locale as string);
|
||||||
|
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||||
const res = await fetch('/api/auth/sso/start', {
|
const res = await fetch('/api/auth/sso/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
|||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
import { useIdentityStore } from "@/stores/identity-store";
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||||
@@ -61,6 +62,7 @@ export default function Home() {
|
|||||||
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
||||||
const [composerDraftText, setComposerDraftText] = useState("");
|
const [composerDraftText, setComposerDraftText] = useState("");
|
||||||
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
||||||
|
const [composerSessionId, setComposerSessionId] = useState(0);
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
@@ -79,6 +81,15 @@ export default function Home() {
|
|||||||
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
useIdentitySync();
|
useIdentitySync();
|
||||||
|
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||||
|
const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore();
|
||||||
|
|
||||||
|
// Load trusted senders address book when feature is enabled
|
||||||
|
useEffect(() => {
|
||||||
|
if (trustedSendersAddressBook && client && !trustedSendersLoaded) {
|
||||||
|
loadTrustedSendersBook(client);
|
||||||
|
}
|
||||||
|
}, [trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isRateLimited || !rateLimitUntil) {
|
if (!isRateLimited || !rateLimitUntil) {
|
||||||
@@ -642,6 +653,16 @@ export default function Home() {
|
|||||||
const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId]
|
const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId]
|
||||||
? draft.bodyValues[draft.htmlBody[0].partId].value
|
? draft.bodyValues[draft.htmlBody[0].partId].value
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Try to find the identity that matches the draft's from address to preserve it
|
||||||
|
const draftFromEmail = draft.from?.[0]?.email;
|
||||||
|
const matchedIdentity = draftFromEmail
|
||||||
|
? identities.find(id => id.email === draftFromEmail)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Increment session ID to force the composer to remount with fresh state,
|
||||||
|
// even if it was already open (e.g. right-clicking a draft while composing).
|
||||||
|
setComposerSessionId(id => id + 1);
|
||||||
setPendingDraft({
|
setPendingDraft({
|
||||||
to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '',
|
to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '',
|
||||||
cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '',
|
cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '',
|
||||||
@@ -650,7 +671,7 @@ export default function Home() {
|
|||||||
body: htmlBody || bodyText,
|
body: htmlBody || bodyText,
|
||||||
showCc: (draft.cc?.length || 0) > 0,
|
showCc: (draft.cc?.length || 0) > 0,
|
||||||
showBcc: (draft.bcc?.length || 0) > 0,
|
showBcc: (draft.bcc?.length || 0) > 0,
|
||||||
selectedIdentityId: null,
|
selectedIdentityId: matchedIdentity?.id ?? null,
|
||||||
subAddressTag: '',
|
subAddressTag: '',
|
||||||
mode: 'compose',
|
mode: 'compose',
|
||||||
draftId: draft.id,
|
draftId: draft.id,
|
||||||
@@ -830,16 +851,22 @@ export default function Home() {
|
|||||||
|
|
||||||
const keywords = { ...email.keywords };
|
const keywords = { ...email.keywords };
|
||||||
|
|
||||||
// Remove old label and legacy color tags - set to false for JMAP to remove them
|
if (color === null) {
|
||||||
Object.keys(keywords).forEach(key => {
|
// Remove all label/color tags
|
||||||
if (key.startsWith("$label:") || key.startsWith("$color:")) {
|
Object.keys(keywords).forEach(key => {
|
||||||
keywords[key] = false;
|
if (key.startsWith("$label:") || key.startsWith("$color:")) {
|
||||||
|
keywords[key] = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const jmapKey = `$label:${color}`;
|
||||||
|
if (keywords[jmapKey] === true) {
|
||||||
|
// Toggle off if already active
|
||||||
|
keywords[jmapKey] = false;
|
||||||
|
} else {
|
||||||
|
// Add the tag without disturbing others
|
||||||
|
keywords[jmapKey] = true;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Add new label tag if specified (using new $label: prefix)
|
|
||||||
if (color) {
|
|
||||||
keywords[`$label:${color}`] = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update email keywords via JMAP
|
// Update email keywords via JMAP
|
||||||
@@ -1656,8 +1683,9 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EmailComposer
|
<EmailComposer
|
||||||
|
key={composerSessionId}
|
||||||
mode={pendingDraft?.mode ?? composerMode}
|
mode={pendingDraft?.mode ?? composerMode}
|
||||||
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
|
replyTo={pendingDraft !== null ? pendingDraft.replyTo : (selectedEmail ? {
|
||||||
from: selectedEmail.from,
|
from: selectedEmail.from,
|
||||||
replyToAddresses: selectedEmail.replyTo,
|
replyToAddresses: selectedEmail.replyTo,
|
||||||
to: selectedEmail.to,
|
to: selectedEmail.to,
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
|||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
|
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
|
||||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (!process.env.SESSION_SECRET) {
|
if (!process.env.SESSION_SECRET && !readFileEnv(process.env.SESSION_SECRET_FILE)) {
|
||||||
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { logger } from '@/lib/logger';
|
|||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||||
@@ -113,7 +114,7 @@ async function attemptAllStrategies(
|
|||||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||||
|
|
||||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||||
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
|
const clientSecret = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
||||||
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
const attempts: Array<{ strategy: string; error: string }> = [];
|
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime configuration endpoint
|
* Runtime configuration endpoint
|
||||||
@@ -33,8 +34,8 @@ export async function GET() {
|
|||||||
oauthOnly,
|
oauthOnly,
|
||||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||||
rememberMeEnabled: !!process.env.SESSION_SECRET,
|
rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
|
||||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && !!process.env.SESSION_SECRET,
|
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
|
||||||
stalwartFeaturesEnabled,
|
stalwartFeaturesEnabled,
|
||||||
devMode: configManager.get<boolean>('devMode', false),
|
devMode: configManager.get<boolean>('devMode', false),
|
||||||
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
|
|||||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||||
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
|
||||||
function classifyError(error: unknown): { message: string; status: number } {
|
function classifyError(error: unknown): { message: string; status: number } {
|
||||||
const code = (error as NodeJS.ErrnoException).code;
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
@@ -48,7 +49,7 @@ function classifyError(error: unknown): { message: string; status: number } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isEnabled(): boolean {
|
function isEnabled(): boolean {
|
||||||
return process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET;
|
return process.env.SETTINGS_SYNC_ENABLED === 'true' && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strip trailing slashes so differently-formatted URLs still match. */
|
/** Strip trailing slashes so differently-formatted URLs still match. */
|
||||||
|
|||||||
@@ -208,6 +208,11 @@ body {
|
|||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.email-content-text a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.email-content {
|
.email-content {
|
||||||
font-family:
|
font-family:
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||||
|
|||||||
@@ -1,3 +1,20 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<!-- Generator: Gravit.io -->
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clipPath_Sdk6Fary7vPYZxd20WRVIKca0qb2ogEk"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clipPath_Sdk6Fary7vPYZxd20WRVIKca0qb2ogEk)"><rect width="1000" height="1000" style="fill:rgb(0,0,0)" fill-opacity="0"/><path d=" M 483.028 563.647 L 63.713 187.183 C 59.029 182.978 55.226 174.454 55.226 168.159 L 55.226 122.542 C 55.226 116.247 60.206 109.988 66.339 108.573 L 215.181 74.225 C 221.314 72.809 226.293 76.77 226.293 83.065 L 226.293 176.92 L 352.034 147.895 C 358.167 146.479 363.147 140.219 363.147 133.925 L 363.147 51.483 C 363.147 45.189 368.126 38.93 374.259 37.514 L 488.888 11.061 C 495.021 9.646 504.979 9.646 511.112 11.061 L 625.741 37.514 C 631.874 38.93 636.853 45.189 636.853 51.483 L 636.853 133.925 C 636.853 140.219 641.833 146.479 647.966 147.895 L 773.707 176.92 L 773.707 83.065 C 773.707 76.77 778.686 72.809 784.819 74.225 L 933.661 108.573 C 939.794 109.988 944.774 116.247 944.774 122.542 L 944.774 168.159 C 944.774 174.454 940.971 182.978 936.287 187.183 L 516.972 563.647 C 507.605 572.056 492.395 572.056 483.028 563.647 Z " fill="rgb(219,45,84)"/><path d=" M 944.774 332.832 L 944.774 396.969 C 944.774 403.263 941.16 411.987 936.709 416.437 L 884.411 468.736 C 879.96 473.186 876.347 481.91 876.347 488.204 L 876.347 682.08 C 876.345 718.462 866.664 750.017 847.953 778.668 L 658.833 589.547 L 944.774 332.832 Z " fill="rgb(219,45,84)"/><path d=" M 55.226 332.832 L 55.226 385.564 C 55.226 398.153 62.453 415.6 71.355 424.501 L 107.525 460.671 C 116.426 469.573 123.653 487.02 123.653 499.609 L 123.653 682.08 C 123.655 718.462 133.336 750.017 152.047 778.668 L 341.167 589.547 L 55.226 332.832 Z " fill="rgb(219,45,84)"/><path d=" M 765.645 857.641 C 701.996 901.327 612.26 941.932 500 990 Q 500 990 500 990 C 387.74 941.932 298.004 901.327 234.355 857.641 L 427.917 664.079 C 435.604 668.703 443.74 672.579 452.215 675.636 C 467.543 681.179 483.703 684.007 500 683.996 C 516.297 684.007 532.457 681.179 547.785 675.636 C 556.261 672.579 564.396 668.703 572.083 664.079 L 765.645 857.641 Z " fill="rgb(219,45,84)"/></g></svg>
|
<defs>
|
||||||
|
<clipPath id="_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV"><rect width="1000" height="1000"/></clipPath>
|
||||||
|
<style>
|
||||||
|
.icon-bg { fill: #ffffff; }
|
||||||
|
.icon-mark { fill: rgb(219,45,84); }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.icon-bg { fill: #18181b; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g clip-path="url(#_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV)">
|
||||||
|
<rect width="1000" height="1000" class="icon-bg"/>
|
||||||
|
<path d=" M 489.315 575.068 L 225.342 338.071 C 222.394 335.424 220 330.058 220 326.095 L 220 297.377 C 220 293.415 223.135 289.474 226.996 288.583 L 320.697 266.96 C 324.558 266.069 327.692 268.563 327.692 272.525 L 327.692 331.61 L 406.851 313.338 C 410.712 312.446 413.846 308.506 413.846 304.543 L 413.846 252.643 C 413.846 248.681 416.981 244.741 420.842 243.85 L 493.004 227.197 C 496.865 226.306 503.135 226.306 506.996 227.197 L 579.158 243.85 C 583.019 244.741 586.154 248.681 586.154 252.643 L 586.154 304.543 C 586.154 308.506 589.288 312.446 593.149 313.338 L 672.308 331.61 L 672.308 272.525 C 672.308 268.563 675.442 266.069 679.303 266.96 L 773.004 288.583 C 776.865 289.474 780 293.415 780 297.377 L 780 326.095 C 780 330.058 777.606 335.424 774.658 338.071 L 510.685 575.068 C 504.788 580.362 495.212 580.362 489.315 575.068 Z " class="icon-mark"/>
|
||||||
|
<path d=" M 780 429.762 L 780 470.138 C 780 474.101 777.725 479.593 774.923 482.394 L 742 515.318 C 739.198 518.12 736.923 523.612 736.923 527.574 L 736.923 649.625 C 736.922 672.529 730.827 692.394 719.048 710.431 L 599.991 591.373 L 780 429.762 Z " class="icon-mark"/>
|
||||||
|
<path d=" M 220 429.762 L 220 462.959 C 220 470.884 224.55 481.867 230.153 487.471 L 252.924 510.241 C 258.527 515.845 263.077 526.829 263.077 534.754 L 263.077 649.625 C 263.078 672.529 269.173 692.394 280.952 710.431 L 400.009 591.373 L 220 429.762 Z " class="icon-mark"/>
|
||||||
|
<path d=" M 667.232 760.147 C 627.163 787.649 570.672 813.211 500 843.472 Q 500 843.472 500 843.472 C 429.328 813.211 372.837 787.649 332.768 760.147 L 454.622 638.293 C 459.461 641.204 464.582 643.644 469.918 645.569 C 479.567 649.058 489.741 650.839 500 650.832 C 510.259 650.839 520.433 649.058 530.082 645.569 C 535.418 643.644 540.539 641.204 545.378 638.293 L 667.232 760.147 Z " class="icon-mark"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.4 KiB |
@@ -911,7 +911,6 @@ function CategoryComboBox({
|
|||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Parse current keywords from comma-separated string
|
// Parse current keywords from comma-separated string
|
||||||
@@ -946,17 +945,6 @@ function CategoryComboBox({
|
|||||||
onChange(next);
|
onChange(next);
|
||||||
}, [currentKeywords, onChange]);
|
}, [currentKeywords, onChange]);
|
||||||
|
|
||||||
// Close dropdown on outside click
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
const handler = (e: MouseEvent) => {
|
|
||||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", handler);
|
|
||||||
return () => document.removeEventListener("mousedown", handler);
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
@@ -970,7 +958,7 @@ function CategoryComboBox({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className="relative">
|
<div className="relative">
|
||||||
{/* Keyword badges */}
|
{/* Keyword badges */}
|
||||||
{currentKeywords.length > 0 && (
|
{currentKeywords.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||||
@@ -998,6 +986,7 @@ function CategoryComboBox({
|
|||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }}
|
onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }}
|
||||||
onFocus={() => setIsOpen(true)}
|
onFocus={() => setIsOpen(true)}
|
||||||
|
onBlur={() => setIsOpen(false)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={currentKeywords.length === 0 ? placeholder : ""}
|
placeholder={currentKeywords.length === 0 ? placeholder : ""}
|
||||||
/>
|
/>
|
||||||
@@ -1005,7 +994,7 @@ function CategoryComboBox({
|
|||||||
|
|
||||||
{/* Dropdown */}
|
{/* Dropdown */}
|
||||||
{isOpen && (suggestions.length > 0 || canAddNew) && (
|
{isOpen && (suggestions.length > 0 || canAddNew) && (
|
||||||
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1">
|
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1" onMouseDown={(e) => e.preventDefault()}>
|
||||||
{suggestions.map(kw => (
|
{suggestions.map(kw => (
|
||||||
<button
|
<button
|
||||||
key={kw}
|
key={kw}
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ export function EmailComposer({
|
|||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
||||||
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||||
|
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
||||||
|
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
||||||
|
|
||||||
// Initialize with reply/forward data if provided
|
// Initialize with reply/forward data if provided
|
||||||
const getInitialTo = () => {
|
const getInitialTo = () => {
|
||||||
@@ -212,6 +214,8 @@ export function EmailComposer({
|
|||||||
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
|
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
|
||||||
const [smimePassphraseInput, setSmimePassphraseInput] = useState('');
|
const [smimePassphraseInput, setSmimePassphraseInput] = useState('');
|
||||||
const [smimePassphraseError, setSmimePassphraseError] = useState('');
|
const [smimePassphraseError, setSmimePassphraseError] = useState('');
|
||||||
|
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
|
||||||
|
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
|
||||||
|
|
||||||
const saveTemplateModalRef = useFocusTrap({
|
const saveTemplateModalRef = useFocusTrap({
|
||||||
isActive: showSaveAsTemplate,
|
isActive: showSaveAsTemplate,
|
||||||
@@ -225,6 +229,12 @@ export function EmailComposer({
|
|||||||
restoreFocus: true,
|
restoreFocus: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const attachmentWarningRef = useFocusTrap({
|
||||||
|
isActive: showAttachmentWarning,
|
||||||
|
onEscape: () => setShowAttachmentWarning(false),
|
||||||
|
restoreFocus: true,
|
||||||
|
});
|
||||||
|
|
||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
const identities = useIdentityStore((s) => s.identities);
|
const identities = useIdentityStore((s) => s.identities);
|
||||||
const primaryIdentity = identities[0] ?? null;
|
const primaryIdentity = identities[0] ?? null;
|
||||||
@@ -723,7 +733,7 @@ export function EmailComposer({
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async (skipAttachmentCheck = false) => {
|
||||||
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
|
|
||||||
@@ -742,6 +752,21 @@ export function EmailComposer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attachment reminder check
|
||||||
|
if (!skipAttachmentCheck && attachmentReminderEnabled) {
|
||||||
|
const hasAttachments = attachments.some(att => att.blobId && !att.uploading && !att.error);
|
||||||
|
if (!hasAttachments) {
|
||||||
|
const bodyText = htmlToPlainText(body);
|
||||||
|
const searchText = `${subject} ${bodyText}`.toLowerCase();
|
||||||
|
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
|
||||||
|
if (matched) {
|
||||||
|
setAttachmentWarningKeyword(matched);
|
||||||
|
setShowAttachmentWarning(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let finalDraftId = draftId;
|
let finalDraftId = draftId;
|
||||||
if (saveTimeoutRef.current) {
|
if (saveTimeoutRef.current) {
|
||||||
clearTimeout(saveTimeoutRef.current);
|
clearTimeout(saveTimeoutRef.current);
|
||||||
@@ -1002,7 +1027,7 @@ export function EmailComposer({
|
|||||||
</div>
|
</div>
|
||||||
{/* Mobile: send button in header */}
|
{/* Mobile: send button in header */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSend}
|
onClick={() => handleSend()}
|
||||||
disabled={!canSend}
|
disabled={!canSend}
|
||||||
title={getSendTooltip()}
|
title={getSendTooltip()}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1367,7 +1392,7 @@ export function EmailComposer({
|
|||||||
{t('discard')}
|
{t('discard')}
|
||||||
</button>
|
</button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSend}
|
onClick={() => handleSend()}
|
||||||
disabled={!canSend}
|
disabled={!canSend}
|
||||||
title={getSendTooltip()}
|
title={getSendTooltip()}
|
||||||
className="hidden md:inline-flex"
|
className="hidden md:inline-flex"
|
||||||
@@ -1467,6 +1492,36 @@ export function EmailComposer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showAttachmentWarning && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
|
||||||
|
onClick={() => setShowAttachmentWarning(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={attachmentWarningRef}
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">{t('forgot_attachment.title')}</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{t('forgot_attachment.message', { keyword: attachmentWarningKeyword })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-3 px-6 pb-6">
|
||||||
|
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
|
||||||
|
{t('forgot_attachment.back')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true); }}>
|
||||||
|
{t('forgot_attachment.send_anyway')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showCloseDialog && (
|
{showCloseDialog && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
|
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
|
||||||
|
|||||||
@@ -89,17 +89,18 @@ const getMailboxIcon = (role?: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get current label/color from email keywords (supports both $label: and legacy $color:)
|
// Get all active label/color tag IDs from email keywords
|
||||||
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
|
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
|
||||||
if (!keywords) return null;
|
if (!keywords) return [];
|
||||||
|
const tags: string[] = [];
|
||||||
for (const key of Object.keys(keywords)) {
|
for (const key of Object.keys(keywords)) {
|
||||||
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
||||||
return key.startsWith("$label:")
|
tags.push(
|
||||||
? key.slice("$label:".length)
|
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
|
||||||
: key.slice("$color:".length);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return tags;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EmailContextMenu({
|
export function EmailContextMenu({
|
||||||
@@ -137,7 +138,7 @@ export function EmailContextMenu({
|
|||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const isDraft = email.keywords?.['$draft'] === true;
|
const isDraft = email.keywords?.['$draft'] === true;
|
||||||
const currentColor = getCurrentColor(email.keywords);
|
const currentColors = getCurrentColors(email.keywords);
|
||||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||||
|
|
||||||
@@ -306,24 +307,27 @@ export function EmailContextMenu({
|
|||||||
{/* Set tag submenu - only for single email */}
|
{/* Set tag submenu - only for single email */}
|
||||||
{!showBatchActions && (
|
{!showBatchActions && (
|
||||||
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
||||||
{colorOptions.map((option) => (
|
{colorOptions.map((option) => {
|
||||||
<button
|
const isActive = currentColors.includes(option.value);
|
||||||
key={option.value}
|
return (
|
||||||
role="menuitem"
|
<button
|
||||||
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
key={option.value}
|
||||||
className={cn(
|
role="menuitem"
|
||||||
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
|
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
||||||
currentColor === option.value && "bg-accent font-medium"
|
className={cn(
|
||||||
)}
|
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
|
||||||
>
|
isActive && "bg-accent font-medium"
|
||||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
)}
|
||||||
<span className="flex-1">{option.name}</span>
|
>
|
||||||
{currentColor === option.value && (
|
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||||
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
|
<span className="flex-1">{option.name}</span>
|
||||||
)}
|
{isActive && (
|
||||||
</button>
|
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
|
||||||
))}
|
)}
|
||||||
{currentColor && (
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{currentColors.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useLongPress } from "@/hooks/use-long-press";
|
|||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||||
import { EmailHoverActions } from "./email-hover-actions";
|
import { EmailHoverActions } from "./email-hover-actions";
|
||||||
import { getEmailColorTag } from "@/lib/thread-utils";
|
import { getEmailColorTags } from "@/lib/thread-utils";
|
||||||
|
|
||||||
interface EmailListItemProps {
|
interface EmailListItemProps {
|
||||||
email: Email;
|
email: Email;
|
||||||
@@ -51,9 +51,11 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||||
|
|
||||||
// Resolve color tag using keyword definitions from settings
|
// Resolve color tags using keyword definitions from settings
|
||||||
const colorTagId = getEmailColorTag(email.keywords);
|
const colorTagIds = getEmailColorTags(email.keywords);
|
||||||
const keywordDef = colorTagId ? emailKeywords.find(k => k.id === colorTagId) : null;
|
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
|
||||||
|
// Use first tag for background coloring
|
||||||
|
const keywordDef = keywordDefs[0] ?? null;
|
||||||
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||||
|
|
||||||
// Drag and drop functionality
|
// Drag and drop functionality
|
||||||
@@ -199,7 +201,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
|
{keywordDefs.map((kd) => (
|
||||||
|
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
|
||||||
|
))}
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'text-xs tabular-nums',
|
'text-xs tabular-nums',
|
||||||
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||||
@@ -249,15 +253,15 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
{keywordDef && (
|
{keywordDefs.map((kd) => (
|
||||||
<span className={cn(
|
<span key={kd.id} className={cn(
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||||
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
|
||||||
)}>
|
)}>
|
||||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
|
||||||
{keywordDef.label}
|
{kd.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
))}
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-xs tabular-nums",
|
"text-xs tabular-nums",
|
||||||
isUnread
|
isUnread
|
||||||
|
|||||||
@@ -193,16 +193,17 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
|
|||||||
return 'Attachment';
|
return 'Attachment';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
|
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
|
||||||
if (!keywords) return null;
|
if (!keywords) return [];
|
||||||
|
const tags: string[] = [];
|
||||||
for (const key of Object.keys(keywords)) {
|
for (const key of Object.keys(keywords)) {
|
||||||
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
||||||
return key.startsWith("$label:")
|
tags.push(
|
||||||
? key.slice("$label:".length)
|
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
|
||||||
: key.slice("$color:".length);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return tags;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to format recipients with contextual display
|
// Helper function to format recipients with contextual display
|
||||||
@@ -887,6 +888,9 @@ export function EmailViewer({
|
|||||||
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
|
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
|
||||||
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
||||||
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
||||||
|
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||||
|
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
|
||||||
|
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||||
@@ -933,7 +937,8 @@ export function EmailViewer({
|
|||||||
const moveMenuRef = useRef<HTMLDivElement>(null);
|
const moveMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||||
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
|
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
|
||||||
const currentColor = getCurrentColor(email?.keywords);
|
const currentColors = getCurrentColors(email?.keywords);
|
||||||
|
const currentColor = currentColors[0] ?? null;
|
||||||
|
|
||||||
// S/MIME state
|
// S/MIME state
|
||||||
const [smimeStatus, setSmimeStatus] = useState<SmimeStatus | null>(null);
|
const [smimeStatus, setSmimeStatus] = useState<SmimeStatus | null>(null);
|
||||||
@@ -2309,9 +2314,11 @@ export function EmailViewer({
|
|||||||
// Use shared sanitization config as base (more secure)
|
// Use shared sanitization config as base (more secure)
|
||||||
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
|
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
|
||||||
|
|
||||||
// Check if sender is trusted
|
// Check if sender is trusted (localStorage list or address book)
|
||||||
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
||||||
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false;
|
const senderIsTrusted = senderEmail
|
||||||
|
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
|
||||||
|
: false;
|
||||||
|
|
||||||
// Block external content based on policy:
|
// Block external content based on policy:
|
||||||
// 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted
|
// 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted
|
||||||
@@ -2418,7 +2425,7 @@ export function EmailViewer({
|
|||||||
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
||||||
isHtml: false
|
isHtml: false
|
||||||
};
|
};
|
||||||
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, cidBlobUrls]);
|
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, isTrustedAddressBookSender, trustedSendersAddressBook, cidBlobUrls]);
|
||||||
|
|
||||||
// Override email content with S/MIME decrypted content when available
|
// Override email content with S/MIME decrypted content when available
|
||||||
const effectiveEmailContent = useMemo(() => {
|
const effectiveEmailContent = useMemo(() => {
|
||||||
@@ -3055,43 +3062,51 @@ export function EmailViewer({
|
|||||||
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
|
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
|
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
|
||||||
currentColor && "bg-muted/50"
|
currentColors.length > 0 && "bg-muted/50"
|
||||||
)}
|
)}
|
||||||
title={t('set_color')}
|
title={t('set_color')}
|
||||||
>
|
>
|
||||||
{(() => {
|
{currentColors.length > 0 ? (
|
||||||
const kw = currentColor ? emailKeywords.find(k => k.id === currentColor) : null;
|
<>
|
||||||
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
|
<span className="flex items-center gap-0.5">
|
||||||
return dotClass ? (
|
{currentColors.slice(0, 3).map((tagId) => {
|
||||||
<>
|
const kw = emailKeywords.find(k => k.id === tagId);
|
||||||
<span className={cn("w-3 h-3 rounded-full", dotClass)} />
|
return kw ? <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot)} /> : null;
|
||||||
{showToolbarLabels && <span className="text-xs font-medium text-foreground">{kw!.label}</span>}
|
})}
|
||||||
</>
|
</span>
|
||||||
) : (
|
{showToolbarLabels && currentColors.length === 1 && (
|
||||||
<>
|
<span className="text-xs font-medium text-foreground">
|
||||||
<Tag className="w-4 h-4 text-muted-foreground" />
|
{emailKeywords.find(k => k.id === currentColors[0])?.label}
|
||||||
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
|
</span>
|
||||||
</>
|
)}
|
||||||
);
|
</>
|
||||||
})()}
|
) : (
|
||||||
|
<>
|
||||||
|
<Tag className="w-4 h-4 text-muted-foreground" />
|
||||||
|
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{tagMenuOpen && (
|
{tagMenuOpen && (
|
||||||
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
|
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
|
||||||
{colorOptions.map((option) => (
|
{colorOptions.map((option) => {
|
||||||
<button
|
const isActive = currentColors.includes(option.value);
|
||||||
key={option.value}
|
return (
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
|
<button
|
||||||
className={cn(
|
key={option.value}
|
||||||
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
|
||||||
currentColor === option.value && "bg-accent font-medium"
|
className={cn(
|
||||||
)}
|
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
||||||
>
|
isActive && "bg-accent font-medium"
|
||||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
)}
|
||||||
<span className="truncate">{option.name}</span>
|
>
|
||||||
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||||
</button>
|
<span className="truncate">{option.name}</span>
|
||||||
))}
|
{isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
||||||
{currentColor && (
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{currentColors.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<button
|
||||||
@@ -3299,21 +3314,24 @@ export function EmailViewer({
|
|||||||
</button>
|
</button>
|
||||||
{moreMenuSub === 'tag' && (
|
{moreMenuSub === 'tag' && (
|
||||||
<div className="absolute right-full top-0 mr-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
|
<div className="absolute right-full top-0 mr-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
|
||||||
{colorOptions.map((option) => (
|
{colorOptions.map((option) => {
|
||||||
<button
|
const isActive = currentColors.includes(option.value);
|
||||||
key={option.value}
|
return (
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
<button
|
||||||
className={cn(
|
key={option.value}
|
||||||
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
currentColor === option.value && "bg-accent font-medium"
|
className={cn(
|
||||||
)}
|
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
||||||
>
|
isActive && "bg-accent font-medium"
|
||||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
)}
|
||||||
<span className="truncate">{option.name}</span>
|
>
|
||||||
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||||
</button>
|
<span className="truncate">{option.name}</span>
|
||||||
))}
|
{isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
||||||
{currentColor && (
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{currentColors.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<button
|
||||||
@@ -3489,21 +3507,24 @@ export function EmailViewer({
|
|||||||
<>
|
<>
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
|
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
|
||||||
{colorOptions.map((option) => (
|
{colorOptions.map((option) => {
|
||||||
<button
|
const isActive = currentColors.includes(option.value);
|
||||||
key={option.value}
|
return (
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
|
<button
|
||||||
className={cn(
|
key={option.value}
|
||||||
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
|
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
|
||||||
currentColor === option.value && "bg-accent font-medium"
|
className={cn(
|
||||||
)}
|
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
|
||||||
>
|
isActive && "bg-accent font-medium"
|
||||||
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
|
)}
|
||||||
<span className="truncate">{option.name}</span>
|
>
|
||||||
{currentColor === option.value && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
|
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
|
||||||
</button>
|
<span className="truncate">{option.name}</span>
|
||||||
))}
|
{isActive && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
|
||||||
{currentColor && (
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{currentColors.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
|
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
|
||||||
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
|
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
|
||||||
@@ -3649,14 +3670,18 @@ export function EmailViewer({
|
|||||||
)} />
|
)} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Color tag dot */}
|
{/* Color tag dots */}
|
||||||
{currentColor && (() => {
|
{currentColors.length > 0 && (
|
||||||
const kw = emailKeywords.find(k => k.id === currentColor);
|
<span className="flex items-center gap-0.5">
|
||||||
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
|
{currentColors.map((tagId) => {
|
||||||
return dotClass ? (
|
const kw = emailKeywords.find(k => k.id === tagId);
|
||||||
<span className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
|
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
|
||||||
) : null;
|
return dotClass ? (
|
||||||
})()}
|
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{isImportant && (
|
{isImportant && (
|
||||||
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
|
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
|
||||||
{t('important')}
|
{t('important')}
|
||||||
@@ -4545,7 +4570,11 @@ export function EmailViewer({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const senderEmail = email.from?.[0]?.email;
|
const senderEmail = email.from?.[0]?.email;
|
||||||
if (senderEmail) {
|
if (senderEmail) {
|
||||||
addTrustedSender(senderEmail);
|
if (trustedSendersAddressBook && client) {
|
||||||
|
addToTrustedSendersBook(client, senderEmail).catch(console.error);
|
||||||
|
} else {
|
||||||
|
addTrustedSender(senderEmail);
|
||||||
|
}
|
||||||
setAllowExternalContent(true);
|
setAllowExternalContent(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { isFilePreviewable } from "@/lib/file-preview";
|
import { isFilePreviewable } from "@/lib/file-preview";
|
||||||
|
|
||||||
@@ -84,6 +85,10 @@ export function ThreadConversationView({
|
|||||||
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
||||||
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
||||||
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
||||||
|
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||||
|
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
|
||||||
|
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
||||||
|
const { client } = useAuthStore();
|
||||||
|
|
||||||
// Track which emails are expanded (most recent by default)
|
// Track which emails are expanded (most recent by default)
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||||
@@ -164,7 +169,9 @@ export function ThreadConversationView({
|
|||||||
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
|
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
|
||||||
{emails.map((email, index) => {
|
{emails.map((email, index) => {
|
||||||
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
||||||
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false;
|
const senderIsTrusted = senderEmail
|
||||||
|
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
|
||||||
|
: false;
|
||||||
return (
|
return (
|
||||||
<EmailCard
|
<EmailCard
|
||||||
key={email.id}
|
key={email.id}
|
||||||
@@ -175,7 +182,11 @@ export function ThreadConversationView({
|
|||||||
onToggleExpanded={() => toggleExpanded(email.id)}
|
onToggleExpanded={() => toggleExpanded(email.id)}
|
||||||
onAllowExternal={() => toggleAllowExternal(email.id)}
|
onAllowExternal={() => toggleAllowExternal(email.id)}
|
||||||
onTrustSender={senderEmail ? () => {
|
onTrustSender={senderEmail ? () => {
|
||||||
addTrustedSender(senderEmail);
|
if (trustedSendersAddressBook && client) {
|
||||||
|
addToTrustedSendersBook(client, senderEmail).catch(console.error);
|
||||||
|
} else {
|
||||||
|
addTrustedSender(senderEmail);
|
||||||
|
}
|
||||||
toggleAllowExternal(email.id);
|
toggleAllowExternal(email.id);
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onReply={onReply ? () => onReply(email) : undefined}
|
onReply={onReply ? () => onReply(email) : undefined}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSqu
|
|||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils";
|
import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
|
||||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||||
import { useLongPress } from "@/hooks/use-long-press";
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
import { ThreadEmailItem } from "./thread-email-item";
|
import { ThreadEmailItem } from "./thread-email-item";
|
||||||
@@ -67,9 +67,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||||
|
|
||||||
// Resolve color and keyword definition from keyword definitions if not passed directly
|
// Resolve color tags using keyword definitions
|
||||||
const tagId = getEmailColorTag(email.keywords);
|
const tagIds = getEmailColorTags(email.keywords);
|
||||||
const resolvedKeywordDef = tagId ? emailKeywords.find(k => k.id === tagId) : null;
|
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
|
||||||
|
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
|
||||||
const resolvedColorTag = (() => {
|
const resolvedColorTag = (() => {
|
||||||
if (colorTag) return colorTag;
|
if (colorTag) return colorTag;
|
||||||
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
|
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
|
||||||
@@ -212,7 +213,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
{resolvedKeywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || 'bg-gray-400')} />}
|
{resolvedKeywordDefs.map((kd) => (
|
||||||
|
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
|
||||||
|
))}
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'text-xs tabular-nums',
|
'text-xs tabular-nums',
|
||||||
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||||
@@ -255,15 +258,15 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
{resolvedKeywordDef && (
|
{resolvedKeywordDefs.map((kd) => (
|
||||||
<span className={cn(
|
<span key={kd.id} className={cn(
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||||
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
|
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
|
||||||
)}>
|
)}>
|
||||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
|
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
|
||||||
{resolvedKeywordDef.label}
|
{kd.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
))}
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-xs tabular-nums",
|
"text-xs tabular-nums",
|
||||||
isUnread
|
isUnread
|
||||||
@@ -584,7 +587,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
|
{keywordDef && (
|
||||||
|
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
|
||||||
|
)}
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'text-xs tabular-nums',
|
'text-xs tabular-nums',
|
||||||
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ interface BeforeInstallPromptEvent extends Event {
|
|||||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DISMISSED_KEY = "pwa-install-dismissed";
|
||||||
|
|
||||||
export function PWAInstallPrompt() {
|
export function PWAInstallPrompt() {
|
||||||
const [deferredPrompt, setDeferredPrompt] =
|
const [deferredPrompt, setDeferredPrompt] =
|
||||||
useState<BeforeInstallPromptEvent | null>(null);
|
useState<BeforeInstallPromptEvent | null>(null);
|
||||||
const [showPrompt, setShowPrompt] = useState(false);
|
const [showPrompt, setShowPrompt] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (localStorage.getItem(DISMISSED_KEY)) return;
|
||||||
|
|
||||||
const handler = (e: Event) => {
|
const handler = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||||
@@ -43,6 +47,11 @@ export function PWAInstallPrompt() {
|
|||||||
setShowPrompt(false);
|
setShowPrompt(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDismissForever = () => {
|
||||||
|
localStorage.setItem(DISMISSED_KEY, "1");
|
||||||
|
setShowPrompt(false);
|
||||||
|
};
|
||||||
|
|
||||||
if (!showPrompt || !deferredPrompt) {
|
if (!showPrompt || !deferredPrompt) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -69,18 +78,26 @@ export function PWAInstallPrompt() {
|
|||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
|
||||||
|
>
|
||||||
|
Not now
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleInstall}
|
||||||
|
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleDismiss}
|
onClick={handleDismissForever}
|
||||||
className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
|
className="w-full text-xs text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors text-center"
|
||||||
>
|
>
|
||||||
Not now
|
Don't remind me again
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleInstall}
|
|
||||||
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
Install
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import { useEmailStore } from '@/stores/email-store';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||||
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
|
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
|
||||||
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react';
|
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail, X } from 'lucide-react';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
|
import { useContactStore } from '@/stores/contact-store';
|
||||||
|
|
||||||
const MAIL_LAYOUT_PREVIEW_ROWS = [
|
const MAIL_LAYOUT_PREVIEW_ROWS = [
|
||||||
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
|
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
|
||||||
@@ -110,6 +111,8 @@ export function EmailSettings() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [newKeyword, setNewKeyword] = useState('');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
markAsReadDelay,
|
markAsReadDelay,
|
||||||
deleteAction,
|
deleteAction,
|
||||||
@@ -129,12 +132,16 @@ export function EmailSettings() {
|
|||||||
hoverActionsMode,
|
hoverActionsMode,
|
||||||
hoverActionsCorner,
|
hoverActionsCorner,
|
||||||
trustedSenders,
|
trustedSenders,
|
||||||
|
trustedSendersAddressBook,
|
||||||
|
attachmentReminderEnabled,
|
||||||
|
attachmentReminderKeywords,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
} = useSettingsStore();
|
} = useSettingsStore();
|
||||||
|
const { trustedSenderEmails } = useContactStore();
|
||||||
|
|
||||||
// Get count label for trusted senders button
|
// Get count label for trusted senders button
|
||||||
const getTrustedSendersCount = () => {
|
const getTrustedSendersCount = () => {
|
||||||
const count = trustedSenders.length;
|
const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length;
|
||||||
if (count === 0) return t('trusted_senders.count_zero');
|
if (count === 0) return t('trusted_senders.count_zero');
|
||||||
if (count === 1) return t('trusted_senders.count_one');
|
if (count === 1) return t('trusted_senders.count_one');
|
||||||
return t('trusted_senders.count_other', { count });
|
return t('trusted_senders.count_other', { count });
|
||||||
@@ -337,6 +344,63 @@ export function EmailSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Attachment Reminder */}
|
||||||
|
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={attachmentReminderEnabled}
|
||||||
|
onChange={(checked) => updateSetting('attachmentReminderEnabled', checked)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
{attachmentReminderEnabled && (
|
||||||
|
<div className="py-3 border-b border-border space-y-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">{t('attachment_reminder.keywords_label')}</label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{t('attachment_reminder.keywords_description')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{attachmentReminderKeywords.map((kw) => (
|
||||||
|
<span key={kw} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-foreground">
|
||||||
|
{kw}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('attachment_reminder.remove')}
|
||||||
|
onClick={() => updateSetting('attachmentReminderKeywords', attachmentReminderKeywords.filter(k => k !== kw))}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
className="flex gap-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = newKeyword.trim().toLowerCase();
|
||||||
|
if (trimmed && !attachmentReminderKeywords.includes(trimmed)) {
|
||||||
|
updateSetting('attachmentReminderKeywords', [...attachmentReminderKeywords, trimmed]);
|
||||||
|
}
|
||||||
|
setNewKeyword('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newKeyword}
|
||||||
|
onChange={(e) => setNewKeyword(e.target.value)}
|
||||||
|
placeholder={t('attachment_reminder.add_placeholder')}
|
||||||
|
className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!newKeyword.trim()}
|
||||||
|
className="px-3 py-1 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{t('attachment_reminder.add')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Quick Hover Actions */}
|
{/* Quick Hover Actions */}
|
||||||
{isFeatureEnabled('hoverActionsConfigEnabled') && (
|
{isFeatureEnabled('hoverActionsConfigEnabled') && (
|
||||||
<div className="py-3 border-b border-border space-y-3">
|
<div className="py-3 border-b border-border space-y-3">
|
||||||
@@ -511,6 +575,14 @@ export function EmailSettings() {
|
|||||||
</button>
|
</button>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Trusted Senders — address book storage */}
|
||||||
|
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={trustedSendersAddressBook}
|
||||||
|
onChange={(checked) => updateSetting('trustedSendersAddressBook', checked)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
{/* Trusted Senders Modal */}
|
{/* Trusted Senders Modal */}
|
||||||
<TrustedSendersModal
|
<TrustedSendersModal
|
||||||
isOpen={showTrustedModal}
|
isOpen={showTrustedModal}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
@@ -41,16 +41,39 @@ function KeywordRow({
|
|||||||
keyword,
|
keyword,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
onDragEnd,
|
||||||
|
isDragOver,
|
||||||
|
isDragging,
|
||||||
}: {
|
}: {
|
||||||
keyword: KeywordDefinition;
|
keyword: KeywordDefinition;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onDragStart: () => void;
|
||||||
|
onDragOver: (e: React.DragEvent) => void;
|
||||||
|
onDrop: () => void;
|
||||||
|
onDragEnd: () => void;
|
||||||
|
isDragOver: boolean;
|
||||||
|
isDragging: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("settings.keywords");
|
const t = useTranslations("settings.keywords");
|
||||||
const palette = KEYWORD_PALETTE[keyword.color];
|
const palette = KEYWORD_PALETTE[keyword.color];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group">
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 py-2.5 px-3 rounded-md border bg-background group transition-opacity",
|
||||||
|
isDragging ? "opacity-40" : "opacity-100",
|
||||||
|
isDragOver ? "border-primary" : "border-border"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
|
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
|
||||||
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
|
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
|
||||||
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
|
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
|
||||||
@@ -166,9 +189,39 @@ export function KeywordSettings() {
|
|||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const [isMigrating, setIsMigrating] = useState(false);
|
const [isMigrating, setIsMigrating] = useState(false);
|
||||||
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const existingIds = emailKeywords.map((k) => k.id);
|
const existingIds = emailKeywords.map((k) => k.id);
|
||||||
|
|
||||||
|
const handleDragStart = (index: number) => {
|
||||||
|
setDragIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (index !== dragOverIndex) setDragOverIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (index: number) => {
|
||||||
|
if (dragIndex === null || dragIndex === index) {
|
||||||
|
setDragIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reordered = [...emailKeywords];
|
||||||
|
const [moved] = reordered.splice(dragIndex, 1);
|
||||||
|
reordered.splice(index, 0, moved);
|
||||||
|
reorderKeywords(reordered);
|
||||||
|
setDragIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDragIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleAdd = (keyword: KeywordDefinition) => {
|
const handleAdd = (keyword: KeywordDefinition) => {
|
||||||
addKeyword(keyword);
|
addKeyword(keyword);
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
@@ -220,7 +273,7 @@ export function KeywordSettings() {
|
|||||||
{t("migrating")}
|
{t("migrating")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{emailKeywords.map((keyword) =>
|
{emailKeywords.map((keyword, index) =>
|
||||||
editingId === keyword.id ? (
|
editingId === keyword.id ? (
|
||||||
<KeywordEditForm
|
<KeywordEditForm
|
||||||
key={keyword.id}
|
key={keyword.id}
|
||||||
@@ -238,6 +291,12 @@ export function KeywordSettings() {
|
|||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
}}
|
}}
|
||||||
onDelete={() => handleDelete(keyword.id)}
|
onDelete={() => handleDelete(keyword.id)}
|
||||||
|
onDragStart={() => handleDragStart(index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDrop={() => handleDrop(index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
isDragOver={dragOverIndex === index && dragIndex !== index}
|
||||||
|
isDragging={dragIndex === index}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef, useMemo } from "react";
|
import { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { X, ShieldCheck, Search, Trash2, Plus } from "lucide-react";
|
import { X, ShieldCheck, Search, Trash2, Plus, Loader2 } from "lucide-react";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface TrustedSendersModalProps {
|
interface TrustedSendersModalProps {
|
||||||
@@ -17,22 +19,43 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
|||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const { trustedSenders, addTrustedSender, removeTrustedSender } = useSettingsStore();
|
const { trustedSenders, addTrustedSender, removeTrustedSender, trustedSendersAddressBook } = useSettingsStore();
|
||||||
|
const {
|
||||||
|
trustedSenderEmails,
|
||||||
|
trustedSendersLoaded,
|
||||||
|
trustedSendersLoading,
|
||||||
|
loadTrustedSendersBook,
|
||||||
|
addToTrustedSendersBook,
|
||||||
|
removeFromTrustedSendersBook,
|
||||||
|
} = useContactStore();
|
||||||
|
const { client } = useAuthStore();
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const [newEmail, setNewEmail] = useState("");
|
const [newEmail, setNewEmail] = useState("");
|
||||||
const [emailError, setEmailError] = useState("");
|
const [emailError, setEmailError] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// When address book mode is on, load the book on first open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && trustedSendersAddressBook && client && !trustedSendersLoaded) {
|
||||||
|
loadTrustedSendersBook(client);
|
||||||
|
}
|
||||||
|
}, [isOpen, trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]);
|
||||||
|
|
||||||
|
// The active list depends on mode
|
||||||
|
const activeSenders = trustedSendersAddressBook ? trustedSenderEmails : trustedSenders;
|
||||||
|
const isLoading = trustedSendersAddressBook && (!trustedSendersLoaded || trustedSendersLoading);
|
||||||
|
|
||||||
// Filter senders based on search query
|
// Filter senders based on search query
|
||||||
const filteredSenders = useMemo(() => {
|
const filteredSenders = useMemo(() => {
|
||||||
if (!searchQuery.trim()) return trustedSenders;
|
if (!searchQuery.trim()) return activeSenders;
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return trustedSenders.filter((email) => email.toLowerCase().includes(query));
|
return activeSenders.filter((email) => email.toLowerCase().includes(query));
|
||||||
}, [trustedSenders, searchQuery]);
|
}, [activeSenders, searchQuery]);
|
||||||
|
|
||||||
// Show search only when 5+ senders
|
// Show search only when 5+ senders
|
||||||
const showSearch = trustedSenders.length >= 5;
|
const showSearch = activeSenders.length >= 5;
|
||||||
|
|
||||||
// Close on Escape key
|
// Close on Escape key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -90,7 +113,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
|||||||
return emailRegex.test(email);
|
return emailRegex.test(email);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddSender = () => {
|
const handleAddSender = async () => {
|
||||||
const trimmedEmail = newEmail.trim().toLowerCase();
|
const trimmedEmail = newEmail.trim().toLowerCase();
|
||||||
|
|
||||||
if (!trimmedEmail) {
|
if (!trimmedEmail) {
|
||||||
@@ -103,15 +126,34 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trustedSenders.includes(trimmedEmail)) {
|
if (activeSenders.includes(trimmedEmail)) {
|
||||||
setEmailError(t("already_added"));
|
setEmailError(t("already_added"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
addTrustedSender(trimmedEmail);
|
setIsSubmitting(true);
|
||||||
setNewEmail("");
|
try {
|
||||||
setIsAdding(false);
|
if (trustedSendersAddressBook && client) {
|
||||||
setEmailError("");
|
await addToTrustedSendersBook(client, trimmedEmail);
|
||||||
|
} else {
|
||||||
|
addTrustedSender(trimmedEmail);
|
||||||
|
}
|
||||||
|
setNewEmail("");
|
||||||
|
setIsAdding(false);
|
||||||
|
setEmailError("");
|
||||||
|
} catch {
|
||||||
|
setEmailError(t("save_error"));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveSender = async (email: string) => {
|
||||||
|
if (trustedSendersAddressBook && client) {
|
||||||
|
await removeFromTrustedSendersBook(client, email);
|
||||||
|
} else {
|
||||||
|
removeTrustedSender(email);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
@@ -170,7 +212,11 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{trustedSenders.length === 0 ? (
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : activeSenders.length === 0 ? (
|
||||||
/* Empty State */
|
/* Empty State */
|
||||||
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
|
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
|
||||||
<ShieldCheck className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
<ShieldCheck className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
||||||
@@ -209,7 +255,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
|||||||
{email}
|
{email}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeTrustedSender(email)}
|
onClick={() => handleRemoveSender(email)}
|
||||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
|
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||||
aria-label={`${t("remove")} ${email}`}
|
aria-label={`${t("remove")} ${email}`}
|
||||||
>
|
>
|
||||||
@@ -222,7 +268,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer - Add sender */}
|
{/* Footer - Add sender */}
|
||||||
{trustedSenders.length > 0 && (
|
{!isLoading && activeSenders.length > 0 && (
|
||||||
<div className="px-6 py-4 border-t border-border flex-shrink-0">
|
<div className="px-6 py-4 border-t border-border flex-shrink-0">
|
||||||
{isAdding ? (
|
{isAdding ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -244,9 +290,10 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddSender}
|
onClick={handleAddSender}
|
||||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors text-sm font-medium"
|
disabled={isSubmitting}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors text-sm font-medium disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t("add_button")}
|
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : t("add_button")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{emailError && (
|
{emailError && (
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
|
import { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
|
import { avatarHooks } from "@/lib/plugin-hooks";
|
||||||
|
|
||||||
const IS_DEV = process.env.NODE_ENV !== "production";
|
const IS_DEV = process.env.NODE_ENV !== "production";
|
||||||
|
|
||||||
@@ -143,10 +144,26 @@ interface AvatarProps {
|
|||||||
|
|
||||||
export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) {
|
export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) {
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
|
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
|
||||||
|
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
|
||||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
|
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
|
||||||
const contacts = useContactStore((s) => s.contacts);
|
const contacts = useContactStore((s) => s.contacts);
|
||||||
const { devMode } = useConfig();
|
const { devMode } = useConfig();
|
||||||
|
|
||||||
|
// Ask plugins (e.g. Gravatar) to resolve an avatar URL for this email address.
|
||||||
|
// Runs whenever email or name changes; resets plugin avatar state on each change.
|
||||||
|
useEffect(() => {
|
||||||
|
setPluginAvatarUrl(null);
|
||||||
|
setPluginAvatarFailed(false);
|
||||||
|
if (!email || avatarHooks.onAvatarResolve.size === 0) return;
|
||||||
|
let cancelled = false;
|
||||||
|
avatarHooks.onAvatarResolve
|
||||||
|
.transform(null as string | null, { email, name })
|
||||||
|
.then((url) => { if (!cancelled) setPluginAvatarUrl(url); })
|
||||||
|
.catch(() => { if (!cancelled) setPluginAvatarFailed(true); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [email, name]);
|
||||||
|
|
||||||
// Look up contact photo by email from the contact store
|
// Look up contact photo by email from the contact store
|
||||||
const resolvedContactPhoto = useMemo(() => {
|
const resolvedContactPhoto = useMemo(() => {
|
||||||
if (contactPhotoUri) return contactPhotoUri;
|
if (contactPhotoUri) return contactPhotoUri;
|
||||||
@@ -202,19 +219,25 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className }:
|
|||||||
const showFavicon =
|
const showFavicon =
|
||||||
senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
|
senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
|
||||||
|
|
||||||
// Priority: contact photo > custom avatar > profile picture > company favicon > initials
|
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
|
||||||
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
||||||
|
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
|
||||||
const imgSrc = !imgError && !domainFailed
|
const imgSrc = !imgError && !domainFailed
|
||||||
? resolvedContactPhoto || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
|
? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
|
||||||
: (resolvedContactPhoto || customAvatar || profilePic || null);
|
: (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
|
||||||
|
|
||||||
const handleImgError = useCallback(() => {
|
const handleImgError = useCallback(() => {
|
||||||
|
// If the plugin avatar just failed, mark it and fall through to the next source
|
||||||
|
if (pluginAvatar && imgSrc === pluginAvatar) {
|
||||||
|
setPluginAvatarFailed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setImgError(true);
|
setImgError(true);
|
||||||
// If this was a favicon URL (not a contact photo, custom avatar or profile pic), remember the domain
|
// If this was a favicon URL (not a contact photo, plugin avatar, custom avatar or profile pic), remember the domain
|
||||||
if (faviconDomain && !resolvedContactPhoto && !customAvatar && !profilePic) {
|
if (faviconDomain && !resolvedContactPhoto && !pluginAvatar && !customAvatar && !profilePic) {
|
||||||
failedFaviconDomains.add(faviconDomain);
|
failedFaviconDomains.add(faviconDomain);
|
||||||
}
|
}
|
||||||
}, [faviconDomain, resolvedContactPhoto, customAvatar, profilePic]);
|
}, [imgSrc, pluginAvatar, faviconDomain, resolvedContactPhoto, customAvatar, profilePic]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -147,9 +147,38 @@ export function useBrowserNavigation({
|
|||||||
|
|
||||||
if (!initializedRef.current) {
|
if (!initializedRef.current) {
|
||||||
initializedRef.current = true;
|
initializedRef.current = true;
|
||||||
// Replace the current entry on the very first run so we don't
|
|
||||||
// create an extra step the user has to back through to leave the app.
|
if (emailId || threadId) {
|
||||||
window.history.replaceState(newState, "");
|
// The app is initializing directly on an email/thread view (e.g. the
|
||||||
|
// user navigated here from /settings or an external link). Seed a
|
||||||
|
// "list" history entry first so that the toolbar back button returns
|
||||||
|
// to the list instead of leaving the app entirely.
|
||||||
|
const listSnapshot: NavSnapshot = {
|
||||||
|
mailboxId,
|
||||||
|
emailId: null,
|
||||||
|
threadId: null,
|
||||||
|
composerOpen: false,
|
||||||
|
sidebarOpen,
|
||||||
|
};
|
||||||
|
const listStored: StoredNavState = {
|
||||||
|
...listSnapshot,
|
||||||
|
navId: ++navIdCounter,
|
||||||
|
};
|
||||||
|
const baseState = (window.history.state ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
window.history.replaceState(
|
||||||
|
{ ...baseState, [STATE_KEY]: listStored },
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
// Now push the actual email state on top of the synthetic list entry.
|
||||||
|
window.history.pushState(newState, "");
|
||||||
|
} else {
|
||||||
|
// Replace the current entry on the very first run so we don't
|
||||||
|
// create an extra step the user has to back through to leave the app.
|
||||||
|
window.history.replaceState(newState, "");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
window.history.pushState(newState, "");
|
window.history.pushState(newState, "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,14 +78,7 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us
|
|||||||
const email = currentEmails.find(em => em.id === emailId);
|
const email = currentEmails.find(em => em.id === emailId);
|
||||||
const keywords = { ...(email?.keywords || {}) };
|
const keywords = { ...(email?.keywords || {}) };
|
||||||
|
|
||||||
// Remove old label/color keywords
|
// Add the tag without removing existing ones
|
||||||
Object.keys(keywords).forEach(key => {
|
|
||||||
if (key.startsWith("$label:") || key.startsWith("$color:")) {
|
|
||||||
keywords[key] = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add the new tag
|
|
||||||
keywords[`$label:${tagId}`] = true;
|
keywords[`$label:${tagId}`] = true;
|
||||||
|
|
||||||
await client.updateEmailKeywords(emailId, keywords);
|
await client.updateEmailKeywords(emailId, keywords);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { unlink, writeFileSync } from "fs";
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
// Mock NextResponse before importing the route
|
// Mock NextResponse before importing the route
|
||||||
@@ -24,6 +25,7 @@ describe('config API route', () => {
|
|||||||
delete process.env.OAUTH_CLIENT_ID;
|
delete process.env.OAUTH_CLIENT_ID;
|
||||||
delete process.env.OAUTH_ISSUER_URL;
|
delete process.env.OAUTH_ISSUER_URL;
|
||||||
delete process.env.SESSION_SECRET;
|
delete process.env.SESSION_SECRET;
|
||||||
|
delete process.env.SESSION_SECRET_FILE;
|
||||||
delete process.env.SETTINGS_SYNC_ENABLED;
|
delete process.env.SETTINGS_SYNC_ENABLED;
|
||||||
delete process.env.STALWART_FEATURES;
|
delete process.env.STALWART_FEATURES;
|
||||||
delete process.env.DEV_MOCK_JMAP;
|
delete process.env.DEV_MOCK_JMAP;
|
||||||
@@ -128,6 +130,19 @@ describe('config API route', () => {
|
|||||||
|
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
|
|
||||||
|
expect(config.rememberMeEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enable rememberMe when SESSION_SECRET_FILE is set', async () => {
|
||||||
|
writeFileSync('./session-secret', 'test-secret');
|
||||||
|
process.env.SESSION_SECRET_FILE = './session-secret';
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
unlink('./session-secret', (err) => {
|
||||||
|
if (err) throw err;
|
||||||
|
});
|
||||||
|
|
||||||
expect(config.rememberMeEnabled).toBe(true);
|
expect(config.rememberMeEnabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,6 +153,23 @@ describe('config API route', () => {
|
|||||||
|
|
||||||
process.env.SESSION_SECRET = 'test-secret';
|
process.env.SESSION_SECRET = 'test-secret';
|
||||||
const config2 = await getConfig();
|
const config2 = await getConfig();
|
||||||
|
expect(config2.settingsSyncEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enable settingsSync only when both SESSION_SECRET_FILE and SETTINGS_SYNC_ENABLED are set', async () => {
|
||||||
|
process.env.SETTINGS_SYNC_ENABLED = 'true';
|
||||||
|
const config1 = await getConfig();
|
||||||
|
expect(config1.settingsSyncEnabled).toBe(false);
|
||||||
|
|
||||||
|
writeFileSync('./session-secret', 'test-secret');
|
||||||
|
process.env.SESSION_SECRET_FILE = './session-secret';
|
||||||
|
|
||||||
|
const config2 = await getConfig();
|
||||||
|
|
||||||
|
unlink('./session-secret', (err) => {
|
||||||
|
if (err) throw err;
|
||||||
|
});
|
||||||
|
|
||||||
expect(config2.settingsSyncEnabled).toBe(true);
|
expect(config2.settingsSyncEnabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
|
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
|
||||||
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types';
|
import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types';
|
||||||
import type { AdminSessionPayload } from './types';
|
import type { AdminSessionPayload } from './types';
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ const TAG_LENGTH = 16;
|
|||||||
const MIN_SECRET_LENGTH = 32;
|
const MIN_SECRET_LENGTH = 32;
|
||||||
|
|
||||||
function getKey(): Buffer {
|
function getKey(): Buffer {
|
||||||
const secret = process.env.SESSION_SECRET;
|
const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
|
||||||
if (!secret) throw new Error('SESSION_SECRET not configured');
|
if (!secret) throw new Error('SESSION_SECRET not configured');
|
||||||
if (secret.length < MIN_SECRET_LENGTH) {
|
if (secret.length < MIN_SECRET_LENGTH) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
|
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-gcm';
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
const IV_LENGTH = 12;
|
const IV_LENGTH = 12;
|
||||||
@@ -8,7 +9,7 @@ const TAG_LENGTH = 16;
|
|||||||
const MIN_SECRET_LENGTH = 32;
|
const MIN_SECRET_LENGTH = 32;
|
||||||
|
|
||||||
function getKey(): Buffer {
|
function getKey(): Buffer {
|
||||||
const secret = process.env.SESSION_SECRET;
|
const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
|
||||||
if (!secret) throw new Error('SESSION_SECRET not configured');
|
if (!secret) throw new Error('SESSION_SECRET not configured');
|
||||||
if (secret.length < MIN_SECRET_LENGTH) {
|
if (secret.length < MIN_SECRET_LENGTH) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export const debug = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const CATEGORY_KEYS = new Set<string>(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push']);
|
const CATEGORY_KEYS = new Set<string>(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push', 'contacts']);
|
||||||
function isCategoryKey(value: string): value is DebugCategory {
|
function isCategoryKey(value: string): value is DebugCategory {
|
||||||
return CATEGORY_KEYS.has(value);
|
return CATEGORY_KEYS.has(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -481,6 +481,12 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
async getAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
|
async getAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
|
||||||
async getAllAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
|
async getAllAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
|
||||||
|
|
||||||
|
async createAddressBook(name: string): Promise<AddressBook> {
|
||||||
|
const book: AddressBook = { id: `demo-book-${Date.now()}`, name };
|
||||||
|
this.data.addressBooks.push(book);
|
||||||
|
return book;
|
||||||
|
}
|
||||||
|
|
||||||
async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>): Promise<void> {
|
async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>): Promise<void> {
|
||||||
const book = this.data.addressBooks.find(b => b.id === addressBookId);
|
const book = this.data.addressBooks.find(b => b.id === addressBookId);
|
||||||
if (book) Object.assign(book, updates);
|
if (book) Object.assign(book, updates);
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ export interface IJMAPClient {
|
|||||||
getContactsAccountId(): string;
|
getContactsAccountId(): string;
|
||||||
getAddressBooks(): Promise<AddressBook[]>;
|
getAddressBooks(): Promise<AddressBook[]>;
|
||||||
getAllAddressBooks(): Promise<AddressBook[]>;
|
getAllAddressBooks(): Promise<AddressBook[]>;
|
||||||
|
createAddressBook(name: string): Promise<AddressBook>;
|
||||||
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
|
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
|
||||||
getContacts(addressBookId?: string): Promise<ContactCard[]>;
|
getContacts(addressBookId?: string): Promise<ContactCard[]>;
|
||||||
getAllContacts(): Promise<ContactCard[]>;
|
getAllContacts(): Promise<ContactCard[]>;
|
||||||
|
|||||||
@@ -2818,6 +2818,27 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createAddressBook(name: string): Promise<AddressBook> {
|
||||||
|
const accountId = this.getContactsAccountId();
|
||||||
|
const response = await this.request([
|
||||||
|
["AddressBook/set", {
|
||||||
|
accountId,
|
||||||
|
create: { "new-book": { name } },
|
||||||
|
}, "0"]
|
||||||
|
], this.contactUsing());
|
||||||
|
|
||||||
|
if (response.methodResponses?.[0]?.[0] === "AddressBook/set") {
|
||||||
|
const result = response.methodResponses[0][1];
|
||||||
|
const created = result.created?.["new-book"];
|
||||||
|
if (created) {
|
||||||
|
return { id: created.id, name, ...created } as AddressBook;
|
||||||
|
}
|
||||||
|
const err = result.notCreated?.["new-book"];
|
||||||
|
throw new Error(err?.description || "Failed to create address book");
|
||||||
|
}
|
||||||
|
throw new Error("Failed to create address book");
|
||||||
|
}
|
||||||
|
|
||||||
async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void> {
|
async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void> {
|
||||||
const accountId = targetAccountId || this.getContactsAccountId();
|
const accountId = targetAccountId || this.getContactsAccountId();
|
||||||
// Only forward server-settable properties
|
// Only forward server-settable properties
|
||||||
|
|||||||
@@ -203,12 +203,14 @@ export interface ContactCard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ContactName {
|
export interface ContactName {
|
||||||
components: NameComponent[];
|
components?: NameComponent[];
|
||||||
isOrdered?: boolean;
|
isOrdered?: boolean;
|
||||||
|
full?: string;
|
||||||
|
defaultSeparator?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NameComponent {
|
export interface NameComponent {
|
||||||
kind: 'given' | 'surname' | 'prefix' | 'suffix' | 'additional' | 'separator' | 'credential';
|
kind: 'given' | 'surname' | 'prefix' | 'suffix' | 'additional' | 'separator' | 'credential' | 'title' | 'middle' | 'given2' | 'surname2' | 'generation';
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
import type { OAuthMetadata } from '@/lib/oauth/discovery';
|
import type { OAuthMetadata } from '@/lib/oauth/discovery';
|
||||||
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
|
||||||
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || '';
|
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
|
||||||
|
|
||||||
export function getRequiredConfig() {
|
export function getRequiredConfig() {
|
||||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
|
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
|
||||||
sidebarAppHooks,
|
sidebarAppHooks, avatarHooks,
|
||||||
} from './plugin-hooks';
|
} from './plugin-hooks';
|
||||||
import { toast as appToast } from '@/stores/toast-store';
|
import { toast as appToast } from '@/stores/toast-store';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
@@ -314,6 +314,8 @@ export interface PluginHooksAPI {
|
|||||||
onSidebarAppOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onSidebarAppOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onSidebarAppClose: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onSidebarAppClose: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
|
// Avatar
|
||||||
|
onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Permission mapping for hooks ----------------------------
|
// --- Permission mapping for hooks ----------------------------
|
||||||
@@ -417,6 +419,8 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
|||||||
// Sidebar Apps
|
// Sidebar Apps
|
||||||
onSidebarAppOpen: 'ui:observe', onSidebarAppClose: 'ui:observe',
|
onSidebarAppOpen: 'ui:observe', onSidebarAppClose: 'ui:observe',
|
||||||
onSidebarAppChange: 'ui:observe',
|
onSidebarAppChange: 'ui:observe',
|
||||||
|
// Avatar
|
||||||
|
onAvatarResolve: 'email:read',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map hook names → actual HookBus instances
|
// Map hook names → actual HookBus instances
|
||||||
@@ -463,6 +467,8 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
|
|||||||
...Object.fromEntries(Object.entries(accountSecurityHooks)),
|
...Object.fromEntries(Object.entries(accountSecurityHooks)),
|
||||||
// Sidebar Apps
|
// Sidebar Apps
|
||||||
...Object.fromEntries(Object.entries(sidebarAppHooks)),
|
...Object.fromEntries(Object.entries(sidebarAppHooks)),
|
||||||
|
// Avatar
|
||||||
|
...Object.fromEntries(Object.entries(avatarHooks)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Slot registration bridge --------------------------------
|
// --- Slot registration bridge --------------------------------
|
||||||
|
|||||||
@@ -399,6 +399,13 @@ export const sidebarAppHooks = {
|
|||||||
onSidebarAppChange: new HookBus(),
|
onSidebarAppChange: new HookBus(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// §7.21 Avatar Hooks
|
||||||
|
// Transform hook: handlers receive (currentUrl: string | null, context: { email: string; name?: string })
|
||||||
|
// and return a URL string to use as the avatar, or undefined/null to pass through to the next handler.
|
||||||
|
export const avatarHooks = {
|
||||||
|
onAvatarResolve: new HookBus(),
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Aggregate: remove all handlers for a plugin across all buses ───
|
// ─── Aggregate: remove all handlers for a plugin across all buses ───
|
||||||
|
|
||||||
const allHookGroups = [
|
const allHookGroups = [
|
||||||
@@ -407,6 +414,7 @@ const allHookGroups = [
|
|||||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
|
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
|
||||||
|
avatarHooks,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function removeAllPluginHooks(pluginId: string): void {
|
export function removeAllPluginHooks(pluginId: string): void {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { readFileSync } from "fs";
|
||||||
|
|
||||||
|
export function readFileEnv(path: string | undefined): string | null {
|
||||||
|
if (!path) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return readFileSync(path, "utf-8").trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,14 @@ import { readFile, writeFile, unlink, mkdir, rename } from 'node:fs/promises';
|
|||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-gcm';
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
const IV_LENGTH = 12;
|
const IV_LENGTH = 12;
|
||||||
const TAG_LENGTH = 16;
|
const TAG_LENGTH = 16;
|
||||||
|
|
||||||
function getKey(): Buffer {
|
function getKey(): Buffer {
|
||||||
const secret = process.env.SESSION_SECRET;
|
const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
|
||||||
if (!secret) throw new Error('SESSION_SECRET not configured');
|
if (!secret) throw new Error('SESSION_SECRET not configured');
|
||||||
return createHash('sha256').update(secret).digest();
|
return createHash('sha256').update(secret).digest();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,21 +152,32 @@ export const KEYWORD_PREFIX = "$label:";
|
|||||||
export const KEYWORD_PREFIX_LEGACY = "$color:";
|
export const KEYWORD_PREFIX_LEGACY = "$color:";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets label/color tag from email keywords (if any).
|
* Gets all active label/color tag IDs from email keywords.
|
||||||
* Reads both the current $label: prefix and the legacy $color: prefix.
|
* Reads both the current $label: prefix and the legacy $color: prefix.
|
||||||
*/
|
*/
|
||||||
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
|
export function getEmailColorTags(keywords: Record<string, boolean> | undefined): string[] {
|
||||||
if (!keywords) return null;
|
if (!keywords) return [];
|
||||||
|
const tags: string[] = [];
|
||||||
for (const key of Object.keys(keywords)) {
|
for (const key of Object.keys(keywords)) {
|
||||||
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
|
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
|
||||||
return key.startsWith(KEYWORD_PREFIX)
|
tags.push(
|
||||||
? key.slice(KEYWORD_PREFIX.length)
|
key.startsWith(KEYWORD_PREFIX)
|
||||||
: key.slice(KEYWORD_PREFIX_LEGACY.length);
|
? key.slice(KEYWORD_PREFIX.length)
|
||||||
|
: key.slice(KEYWORD_PREFIX_LEGACY.length)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
/**
|
||||||
|
* Gets label/color tag from email keywords (if any).
|
||||||
|
* Reads both the current $label: prefix and the legacy $color: prefix.
|
||||||
|
* @deprecated Use getEmailColorTags for multi-tag support.
|
||||||
|
*/
|
||||||
|
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
|
||||||
|
const tags = getEmailColorTags(keywords);
|
||||||
|
return tags.length > 0 ? tags[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -527,7 +527,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasName = card.name && card.name.components.length > 0;
|
const hasName = card.name && (card.name.components?.length ?? 0) > 0 || !!card.name?.full;
|
||||||
const hasEmail = card.emails && Object.keys(card.emails).length > 0;
|
const hasEmail = card.emails && Object.keys(card.emails).length > 0;
|
||||||
if (!hasName && !hasEmail && card.kind !== "group") return null;
|
if (!hasName && !hasEmail && card.kind !== "group") return null;
|
||||||
|
|
||||||
@@ -564,7 +564,7 @@ function generateSingleVCard(contact: ContactCard): string {
|
|||||||
const suffix = components.find(c => c.kind === "suffix")?.value || "";
|
const suffix = components.find(c => c.kind === "suffix")?.value || "";
|
||||||
const additional = components.find(c => c.kind === "additional")?.value || "";
|
const additional = components.find(c => c.kind === "additional")?.value || "";
|
||||||
|
|
||||||
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ");
|
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ") || contact.name?.full || "";
|
||||||
if (fn) {
|
if (fn) {
|
||||||
lines.push(`FN:${encodeValue(fn)}`);
|
lines.push(`FN:${encodeValue(fn)}`);
|
||||||
lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`);
|
lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`);
|
||||||
|
|||||||
@@ -494,7 +494,13 @@
|
|||||||
"close_draft_message": "Sie haben ungespeicherte Änderungen. Möchten Sie diese als Entwurf speichern oder verwerfen?",
|
"close_draft_message": "Sie haben ungespeicherte Änderungen. Möchten Sie diese als Entwurf speichern oder verwerfen?",
|
||||||
"save_draft": "Entwurf speichern",
|
"save_draft": "Entwurf speichern",
|
||||||
"drop_files": "Dateien zum Anhängen ablegen",
|
"drop_files": "Dateien zum Anhängen ablegen",
|
||||||
"show_less": "Weniger anzeigen"
|
"show_less": "Weniger anzeigen",
|
||||||
|
"forgot_attachment": {
|
||||||
|
"title": "Haben Sie den Anhang vergessen?",
|
||||||
|
"message": "Ihre Nachricht enthält \"{keyword}\", aber es ist keine Datei angehängt. Trotzdem senden?",
|
||||||
|
"send_anyway": "Trotzdem senden",
|
||||||
|
"back": "Zurück zur Bearbeitung"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Bestätigen",
|
"confirm": "Bestätigen",
|
||||||
@@ -914,6 +920,15 @@
|
|||||||
"button": "Als Standard festlegen",
|
"button": "Als Standard festlegen",
|
||||||
"success": "Browser wurde aufgefordert, als Standard festzulegen",
|
"success": "Browser wurde aufgefordert, als Standard festzulegen",
|
||||||
"error": "Ihr Browser unterstützt diese Funktion nicht"
|
"error": "Ihr Browser unterstützt diese Funktion nicht"
|
||||||
|
},
|
||||||
|
"attachment_reminder": {
|
||||||
|
"label": "Erinnerung an Anhang",
|
||||||
|
"description": "Warnung anzeigen, wenn die Nachricht Anhänge erwähnt, aber keine angehängt sind",
|
||||||
|
"keywords_label": "Schlüsselwörter",
|
||||||
|
"keywords_description": "Wörter oder Phrasen, die die Erinnerung auslösen",
|
||||||
|
"add_placeholder": "Schlüsselwort hinzufügen...",
|
||||||
|
"add": "Hinzufügen",
|
||||||
|
"remove": "Entfernen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
@@ -494,7 +494,13 @@
|
|||||||
"smime_unlock_title": "Unlock S/MIME Key",
|
"smime_unlock_title": "Unlock S/MIME Key",
|
||||||
"smime_unlock_message": "Enter the passphrase to unlock your S/MIME signing key.",
|
"smime_unlock_message": "Enter the passphrase to unlock your S/MIME signing key.",
|
||||||
"smime_unlock_button": "Unlock",
|
"smime_unlock_button": "Unlock",
|
||||||
"smime_passphrase_placeholder": "Passphrase"
|
"smime_passphrase_placeholder": "Passphrase",
|
||||||
|
"forgot_attachment": {
|
||||||
|
"title": "Did you forget an attachment?",
|
||||||
|
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||||
|
"send_anyway": "Send anyway",
|
||||||
|
"back": "Back to editing"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
@@ -887,7 +893,10 @@
|
|||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"invalid_email": "Please enter a valid email address",
|
"invalid_email": "Please enter a valid email address",
|
||||||
"already_added": "This sender is already trusted"
|
"already_added": "This sender is already trusted",
|
||||||
|
"save_error": "Failed to save — check the Contacts debug log for details",
|
||||||
|
"use_address_book_label": "Sync with address book",
|
||||||
|
"use_address_book_description": "Store trusted senders in a dedicated \"Trusted Senders\" address book so they sync across all your devices"
|
||||||
},
|
},
|
||||||
"hover_actions": {
|
"hover_actions": {
|
||||||
"label": "Quick Hover Actions",
|
"label": "Quick Hover Actions",
|
||||||
@@ -914,6 +923,15 @@
|
|||||||
"button": "Set as Default",
|
"button": "Set as Default",
|
||||||
"success": "Browser prompted to set as default",
|
"success": "Browser prompted to set as default",
|
||||||
"error": "Your browser does not support this feature"
|
"error": "Your browser does not support this feature"
|
||||||
|
},
|
||||||
|
"attachment_reminder": {
|
||||||
|
"label": "Attachment Reminder",
|
||||||
|
"description": "Warn before sending when your message mentions attachments but none are attached",
|
||||||
|
"keywords_label": "Trigger keywords",
|
||||||
|
"keywords_description": "Words or phrases that trigger the reminder when found in your message",
|
||||||
|
"add_placeholder": "Add keyword...",
|
||||||
|
"add": "Add",
|
||||||
|
"remove": "Remove"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
@@ -1182,7 +1200,9 @@
|
|||||||
"email": "Email Viewing",
|
"email": "Email Viewing",
|
||||||
"email_description": "Email rendering, TNEF processing, and mark-as-read",
|
"email_description": "Email rendering, TNEF processing, and mark-as-read",
|
||||||
"push": "Push Notifications",
|
"push": "Push Notifications",
|
||||||
"push_description": "Push notification setup and delivery"
|
"push_description": "Push notification setup and delivery",
|
||||||
|
"contacts": "Contacts & Address Books",
|
||||||
|
"contacts_description": "Contact sync, address book operations, and trusted senders"
|
||||||
},
|
},
|
||||||
"settings_sync": {
|
"settings_sync": {
|
||||||
"label": "Settings Sync",
|
"label": "Settings Sync",
|
||||||
|
|||||||
@@ -494,7 +494,13 @@
|
|||||||
"close_draft_message": "Tiene cambios sin guardar. ¿Desea guardar esto como borrador o descartarlo?",
|
"close_draft_message": "Tiene cambios sin guardar. ¿Desea guardar esto como borrador o descartarlo?",
|
||||||
"save_draft": "Guardar borrador",
|
"save_draft": "Guardar borrador",
|
||||||
"drop_files": "Suelta archivos para adjuntar",
|
"drop_files": "Suelta archivos para adjuntar",
|
||||||
"show_less": "Mostrar menos"
|
"show_less": "Mostrar menos",
|
||||||
|
"forgot_attachment": {
|
||||||
|
"title": "Did you forget an attachment?",
|
||||||
|
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||||
|
"send_anyway": "Send anyway",
|
||||||
|
"back": "Back to editing"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Confirmar",
|
"confirm": "Confirmar",
|
||||||
@@ -914,6 +920,15 @@
|
|||||||
"button": "Establecer como predeterminado",
|
"button": "Establecer como predeterminado",
|
||||||
"success": "El navegador solicitó establecer como predeterminado",
|
"success": "El navegador solicitó establecer como predeterminado",
|
||||||
"error": "Su navegador no admite esta función"
|
"error": "Su navegador no admite esta función"
|
||||||
|
},
|
||||||
|
"attachment_reminder": {
|
||||||
|
"label": "Attachment Reminder",
|
||||||
|
"description": "Warn before sending when your message mentions attachments but none are attached",
|
||||||
|
"keywords_label": "Trigger keywords",
|
||||||
|
"keywords_description": "Words or phrases that trigger the reminder when found in your message",
|
||||||
|
"add_placeholder": "Add keyword...",
|
||||||
|
"add": "Add",
|
||||||
|
"remove": "Remove"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
@@ -494,7 +494,13 @@
|
|||||||
"close_draft_message": "Vous avez des modifications non enregistrées. Voulez-vous enregistrer comme brouillon ou supprimer ?",
|
"close_draft_message": "Vous avez des modifications non enregistrées. Voulez-vous enregistrer comme brouillon ou supprimer ?",
|
||||||
"save_draft": "Enregistrer le brouillon",
|
"save_draft": "Enregistrer le brouillon",
|
||||||
"drop_files": "Déposez les fichiers à joindre",
|
"drop_files": "Déposez les fichiers à joindre",
|
||||||
"show_less": "Afficher moins"
|
"show_less": "Afficher moins",
|
||||||
|
"forgot_attachment": {
|
||||||
|
"title": "Did you forget an attachment?",
|
||||||
|
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||||
|
"send_anyway": "Send anyway",
|
||||||
|
"back": "Back to editing"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Confirmer",
|
"confirm": "Confirmer",
|
||||||
@@ -914,6 +920,15 @@
|
|||||||
"button": "Définir par défaut",
|
"button": "Définir par défaut",
|
||||||
"success": "Le navigateur a été invité à définir par défaut",
|
"success": "Le navigateur a été invité à définir par défaut",
|
||||||
"error": "Votre navigateur ne prend pas en charge cette fonctionnalité"
|
"error": "Votre navigateur ne prend pas en charge cette fonctionnalité"
|
||||||
|
},
|
||||||
|
"attachment_reminder": {
|
||||||
|
"label": "Attachment Reminder",
|
||||||
|
"description": "Warn before sending when your message mentions attachments but none are attached",
|
||||||
|
"keywords_label": "Trigger keywords",
|
||||||
|
"keywords_description": "Words or phrases that trigger the reminder when found in your message",
|
||||||
|
"add_placeholder": "Add keyword...",
|
||||||
|
"add": "Add",
|
||||||
|
"remove": "Remove"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
@@ -494,7 +494,13 @@
|
|||||||
"close_draft_message": "Hai modifiche non salvate. Vuoi salvare come bozza o eliminare?",
|
"close_draft_message": "Hai modifiche non salvate. Vuoi salvare come bozza o eliminare?",
|
||||||
"save_draft": "Salva bozza",
|
"save_draft": "Salva bozza",
|
||||||
"drop_files": "Trascina i file per allegarli",
|
"drop_files": "Trascina i file per allegarli",
|
||||||
"show_less": "Mostra meno"
|
"show_less": "Mostra meno",
|
||||||
|
"forgot_attachment": {
|
||||||
|
"title": "Did you forget an attachment?",
|
||||||
|
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||||
|
"send_anyway": "Send anyway",
|
||||||
|
"back": "Back to editing"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Conferma",
|
"confirm": "Conferma",
|
||||||
@@ -914,6 +920,15 @@
|
|||||||
"button": "Imposta come predefinito",
|
"button": "Imposta come predefinito",
|
||||||
"success": "Il browser ha chiesto di impostare come predefinito",
|
"success": "Il browser ha chiesto di impostare come predefinito",
|
||||||
"error": "Il tuo browser non supporta questa funzionalità"
|
"error": "Il tuo browser non supporta questa funzionalità"
|
||||||
|
},
|
||||||
|
"attachment_reminder": {
|
||||||
|
"label": "Attachment Reminder",
|
||||||
|
"description": "Warn before sending when your message mentions attachments but none are attached",
|
||||||
|
"keywords_label": "Trigger keywords",
|
||||||
|
"keywords_description": "Words or phrases that trigger the reminder when found in your message",
|
||||||
|
"add_placeholder": "Add keyword...",
|
||||||
|
"add": "Add",
|
||||||
|
"remove": "Remove"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
@@ -494,7 +494,13 @@
|
|||||||
"close_draft_message": "未保存の変更があります。下書きとして保存しますか、それとも破棄しますか?",
|
"close_draft_message": "未保存の変更があります。下書きとして保存しますか、それとも破棄しますか?",
|
||||||
"save_draft": "下書きを保存",
|
"save_draft": "下書きを保存",
|
||||||
"drop_files": "ファイルをドロップして添付",
|
"drop_files": "ファイルをドロップして添付",
|
||||||
"show_less": "折りたたむ"
|
"show_less": "折りたたむ",
|
||||||
|
"forgot_attachment": {
|
||||||
|
"title": "Did you forget an attachment?",
|
||||||
|
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||||
|
"send_anyway": "Send anyway",
|
||||||
|
"back": "Back to editing"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "確認",
|
"confirm": "確認",
|
||||||
@@ -914,6 +920,15 @@
|
|||||||
"button": "既定に設定",
|
"button": "既定に設定",
|
||||||
"success": "ブラウザに既定として設定するよう要求しました",
|
"success": "ブラウザに既定として設定するよう要求しました",
|
||||||
"error": "お使いのブラウザはこの機能をサポートしていません"
|
"error": "お使いのブラウザはこの機能をサポートしていません"
|
||||||
|
},
|
||||||
|
"attachment_reminder": {
|
||||||
|
"label": "Attachment Reminder",
|
||||||
|
"description": "Warn before sending when your message mentions attachments but none are attached",
|
||||||
|
"keywords_label": "Trigger keywords",
|
||||||
|
"keywords_description": "Words or phrases that trigger the reminder when found in your message",
|
||||||
|
"add_placeholder": "Add keyword...",
|
||||||
|
"add": "Add",
|
||||||
|
"remove": "Remove"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
@@ -494,7 +494,13 @@
|
|||||||
"smime_unlock_title": "S/MIME 키 잠금 해제",
|
"smime_unlock_title": "S/MIME 키 잠금 해제",
|
||||||
"smime_unlock_message": "S/MIME 서명 키의 잠금을 해제하려면 비밀번호를 입력해 주세요.",
|
"smime_unlock_message": "S/MIME 서명 키의 잠금을 해제하려면 비밀번호를 입력해 주세요.",
|
||||||
"smime_unlock_button": "잠금 해제",
|
"smime_unlock_button": "잠금 해제",
|
||||||
"smime_passphrase_placeholder": "비밀번호"
|
"smime_passphrase_placeholder": "비밀번호",
|
||||||
|
"forgot_attachment": {
|
||||||
|
"title": "Did you forget an attachment?",
|
||||||
|
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||||
|
"send_anyway": "Send anyway",
|
||||||
|
"back": "Back to editing"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "확인",
|
"confirm": "확인",
|
||||||
@@ -914,6 +920,15 @@
|
|||||||
"button": "기본값으로 설정",
|
"button": "기본값으로 설정",
|
||||||
"success": "브라우저에서 기본 설정 팝업이 뜰 거예요",
|
"success": "브라우저에서 기본 설정 팝업이 뜰 거예요",
|
||||||
"error": "이 브라우저에서는 이 기능을 지원하지 않아요"
|
"error": "이 브라우저에서는 이 기능을 지원하지 않아요"
|
||||||
|
},
|
||||||
|
"attachment_reminder": {
|
||||||
|
"label": "Attachment Reminder",
|
||||||
|
"description": "Warn before sending when your message mentions attachments but none are attached",
|
||||||
|
"keywords_label": "Trigger keywords",
|
||||||
|
"keywords_description": "Words or phrases that trigger the reminder when found in your message",
|
||||||
|
"add_placeholder": "Add keyword...",
|
||||||
|
"add": "Add",
|
||||||
|
"remove": "Remove"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
@@ -493,7 +493,13 @@
|
|||||||
"smime_unlock_title": "Atbloķēt S/MIME atslēgu",
|
"smime_unlock_title": "Atbloķēt S/MIME atslēgu",
|
||||||
"smime_unlock_message": "Ievadiet paroli, lai atbloķētu savu S/MIME parakstīšanas atslēgu.",
|
"smime_unlock_message": "Ievadiet paroli, lai atbloķētu savu S/MIME parakstīšanas atslēgu.",
|
||||||
"smime_unlock_button": "Atbloķēt",
|
"smime_unlock_button": "Atbloķēt",
|
||||||
"smime_passphrase_placeholder": "Parole"
|
"smime_passphrase_placeholder": "Parole",
|
||||||
|
"forgot_attachment": {
|
||||||
|
"title": "Did you forget an attachment?",
|
||||||
|
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||||
|
"send_anyway": "Send anyway",
|
||||||
|
"back": "Back to editing"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Apstiprināt",
|
"confirm": "Apstiprināt",
|
||||||
@@ -913,6 +919,15 @@
|
|||||||
"button": "Iestatīt kā noklusējumu",
|
"button": "Iestatīt kā noklusējumu",
|
||||||
"success": "Pārlūkam nosūtīts pieprasījums iestatīt kā noklusējumu",
|
"success": "Pārlūkam nosūtīts pieprasījums iestatīt kā noklusējumu",
|
||||||
"error": "Jūsu pārlūks neatbalsta šo funkciju"
|
"error": "Jūsu pārlūks neatbalsta šo funkciju"
|
||||||
|
},
|
||||||
|
"attachment_reminder": {
|
||||||
|
"label": "Attachment Reminder",
|
||||||
|
"description": "Warn before sending when your message mentions attachments but none are attached",
|
||||||
|
"keywords_label": "Trigger keywords",
|
||||||
|
"keywords_description": "Words or phrases that trigger the reminder when found in your message",
|
||||||
|
"add_placeholder": "Add keyword...",
|
||||||
|
"add": "Add",
|
||||||
|
"remove": "Remove"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
@@ -494,7 +494,13 @@
|
|||||||
"close_draft_message": "U heeft niet-opgeslagen wijzigingen. Wilt u dit als concept opslaan of verwijderen?",
|
"close_draft_message": "U heeft niet-opgeslagen wijzigingen. Wilt u dit als concept opslaan of verwijderen?",
|
||||||
"save_draft": "Concept opslaan",
|
"save_draft": "Concept opslaan",
|
||||||
"drop_files": "Sleep bestanden om bij te voegen",
|
"drop_files": "Sleep bestanden om bij te voegen",
|
||||||
"show_less": "Minder tonen"
|
"show_less": "Minder tonen",
|
||||||
|
"forgot_attachment": {
|
||||||
|
"title": "Did you forget an attachment?",
|
||||||
|
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||||
|
"send_anyway": "Send anyway",
|
||||||
|
"back": "Back to editing"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Bevestigen",
|
"confirm": "Bevestigen",
|
||||||
@@ -914,6 +920,15 @@
|
|||||||
"button": "Instellen als standaard",
|
"button": "Instellen als standaard",
|
||||||
"success": "Browser gevraagd om als standaard in te stellen",
|
"success": "Browser gevraagd om als standaard in te stellen",
|
||||||
"error": "Uw browser ondersteunt deze functie niet"
|
"error": "Uw browser ondersteunt deze functie niet"
|
||||||
|
},
|
||||||
|
"attachment_reminder": {
|
||||||
|
"label": "Attachment Reminder",
|
||||||
|
"description": "Warn before sending when your message mentions attachments but none are attached",
|
||||||
|
"keywords_label": "Trigger keywords",
|
||||||
|
"keywords_description": "Words or phrases that trigger the reminder when found in your message",
|
||||||
|
"add_placeholder": "Add keyword...",
|
||||||
|
"add": "Add",
|
||||||
|
"remove": "Remove"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
@@ -494,7 +494,13 @@
|
|||||||
"smime_unlock_title": "Odblokuj klucz S/MIME",
|
"smime_unlock_title": "Odblokuj klucz S/MIME",
|
||||||
"smime_unlock_message": "Wprowadź hasło, aby odblokować klucz podpisywania S/MIME.",
|
"smime_unlock_message": "Wprowadź hasło, aby odblokować klucz podpisywania S/MIME.",
|
||||||
"smime_unlock_button": "Odblokuj",
|
"smime_unlock_button": "Odblokuj",
|
||||||
"smime_passphrase_placeholder": "Hasło"
|
"smime_passphrase_placeholder": "Hasło",
|
||||||
|
"forgot_attachment": {
|
||||||
|
"title": "Did you forget an attachment?",
|
||||||
|
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||||
|
"send_anyway": "Send anyway",
|
||||||
|
"back": "Back to editing"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Potwierdź",
|
"confirm": "Potwierdź",
|
||||||
@@ -916,6 +922,15 @@
|
|||||||
"button": "Ustaw jako domyślny",
|
"button": "Ustaw jako domyślny",
|
||||||
"success": "Przeglądarka poprosiła o ustawienie jako domyślnego",
|
"success": "Przeglądarka poprosiła o ustawienie jako domyślnego",
|
||||||
"error": "Twoja przeglądarka nie obsługuje tej funkcji"
|
"error": "Twoja przeglądarka nie obsługuje tej funkcji"
|
||||||
|
},
|
||||||
|
"attachment_reminder": {
|
||||||
|
"label": "Attachment Reminder",
|
||||||
|
"description": "Warn before sending when your message mentions attachments but none are attached",
|
||||||
|
"keywords_label": "Trigger keywords",
|
||||||
|
"keywords_description": "Words or phrases that trigger the reminder when found in your message",
|
||||||
|
"add_placeholder": "Add keyword...",
|
||||||
|
"add": "Add",
|
||||||
|
"remove": "Remove"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
@@ -494,7 +494,13 @@
|
|||||||
"close_draft_message": "Você tem alterações não salvas. Deseja salvar como rascunho ou descartar?",
|
"close_draft_message": "Você tem alterações não salvas. Deseja salvar como rascunho ou descartar?",
|
||||||
"save_draft": "Salvar rascunho",
|
"save_draft": "Salvar rascunho",
|
||||||
"drop_files": "Solte arquivos para anexar",
|
"drop_files": "Solte arquivos para anexar",
|
||||||
"show_less": "Mostrar menos"
|
"show_less": "Mostrar menos",
|
||||||
|
"forgot_attachment": {
|
||||||
|
"title": "Did you forget an attachment?",
|
||||||
|
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||||
|
"send_anyway": "Send anyway",
|
||||||
|
"back": "Back to editing"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Confirmar",
|
"confirm": "Confirmar",
|
||||||
@@ -914,6 +920,15 @@
|
|||||||
"button": "Definir como padrão",
|
"button": "Definir como padrão",
|
||||||
"success": "O navegador solicitou definir como padrão",
|
"success": "O navegador solicitou definir como padrão",
|
||||||
"error": "Seu navegador não suporta esta funcionalidade"
|
"error": "Seu navegador não suporta esta funcionalidade"
|
||||||
|
},
|
||||||
|
"attachment_reminder": {
|
||||||
|
"label": "Attachment Reminder",
|
||||||
|
"description": "Warn before sending when your message mentions attachments but none are attached",
|
||||||
|
"keywords_label": "Trigger keywords",
|
||||||
|
"keywords_description": "Words or phrases that trigger the reminder when found in your message",
|
||||||
|
"add_placeholder": "Add keyword...",
|
||||||
|
"add": "Add",
|
||||||
|
"remove": "Remove"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
@@ -494,7 +494,13 @@
|
|||||||
"smime_unlock_title": "Разблокировать ключ S/MIME",
|
"smime_unlock_title": "Разблокировать ключ S/MIME",
|
||||||
"smime_unlock_message": "Введите парольную фразу для разблокировки вашего ключа подписи S/MIME.",
|
"smime_unlock_message": "Введите парольную фразу для разблокировки вашего ключа подписи S/MIME.",
|
||||||
"smime_unlock_button": "Разблокировать",
|
"smime_unlock_button": "Разблокировать",
|
||||||
"smime_passphrase_placeholder": "Парольная фраза"
|
"smime_passphrase_placeholder": "Парольная фраза",
|
||||||
|
"forgot_attachment": {
|
||||||
|
"title": "Did you forget an attachment?",
|
||||||
|
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||||
|
"send_anyway": "Send anyway",
|
||||||
|
"back": "Back to editing"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Подтвердить",
|
"confirm": "Подтвердить",
|
||||||
@@ -914,6 +920,15 @@
|
|||||||
"button": "Установить по умолчанию",
|
"button": "Установить по умолчанию",
|
||||||
"success": "Браузер запрошен для установки по умолчанию",
|
"success": "Браузер запрошен для установки по умолчанию",
|
||||||
"error": "Ваш браузер не поддерживает эту функцию"
|
"error": "Ваш браузер не поддерживает эту функцию"
|
||||||
|
},
|
||||||
|
"attachment_reminder": {
|
||||||
|
"label": "Attachment Reminder",
|
||||||
|
"description": "Warn before sending when your message mentions attachments but none are attached",
|
||||||
|
"keywords_label": "Trigger keywords",
|
||||||
|
"keywords_description": "Words or phrases that trigger the reminder when found in your message",
|
||||||
|
"add_placeholder": "Add keyword...",
|
||||||
|
"add": "Add",
|
||||||
|
"remove": "Remove"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
@@ -494,7 +494,13 @@
|
|||||||
"smime_unlock_title": "解锁 S/MIME 密钥",
|
"smime_unlock_title": "解锁 S/MIME 密钥",
|
||||||
"smime_unlock_message": "输入密码以解锁您的 S/MIME 签名密钥。",
|
"smime_unlock_message": "输入密码以解锁您的 S/MIME 签名密钥。",
|
||||||
"smime_unlock_button": "解锁",
|
"smime_unlock_button": "解锁",
|
||||||
"smime_passphrase_placeholder": "输入密码"
|
"smime_passphrase_placeholder": "输入密码",
|
||||||
|
"forgot_attachment": {
|
||||||
|
"title": "Did you forget an attachment?",
|
||||||
|
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||||
|
"send_anyway": "Send anyway",
|
||||||
|
"back": "Back to editing"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
@@ -914,6 +920,15 @@
|
|||||||
"button": "设为默认",
|
"button": "设为默认",
|
||||||
"success": "浏览器已提示设置为默认",
|
"success": "浏览器已提示设置为默认",
|
||||||
"error": "您的浏览器不支持此功能"
|
"error": "您的浏览器不支持此功能"
|
||||||
|
},
|
||||||
|
"attachment_reminder": {
|
||||||
|
"label": "Attachment Reminder",
|
||||||
|
"description": "Warn before sending when your message mentions attachments but none are attached",
|
||||||
|
"keywords_label": "Trigger keywords",
|
||||||
|
"keywords_description": "Words or phrases that trigger the reminder when found in your message",
|
||||||
|
"add_placeholder": "Add keyword...",
|
||||||
|
"add": "Add",
|
||||||
|
"remove": "Remove"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.4.10",
|
"version": "1.4.13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.4.10",
|
"version": "1.4.13",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.13.18",
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.4.12",
|
"version": "1.4.13",
|
||||||
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
|
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
|
||||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Generator: Gravit.io -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV)"><rect width="1000" height="1000" style="fill:rgb(0,0,0)" fill-opacity="0"/><g><path d=" M 489.315 575.068 L 225.342 338.071 C 222.394 335.424 220 330.058 220 326.095 L 220 297.377 C 220 293.415 223.135 289.474 226.996 288.583 L 320.697 266.96 C 324.558 266.069 327.692 268.563 327.692 272.525 L 327.692 331.61 L 406.851 313.338 C 410.712 312.446 413.846 308.506 413.846 304.543 L 413.846 252.643 C 413.846 248.681 416.981 244.741 420.842 243.85 L 493.004 227.197 C 496.865 226.306 503.135 226.306 506.996 227.197 L 579.158 243.85 C 583.019 244.741 586.154 248.681 586.154 252.643 L 586.154 304.543 C 586.154 308.506 589.288 312.446 593.149 313.338 L 672.308 331.61 L 672.308 272.525 C 672.308 268.563 675.442 266.069 679.303 266.96 L 773.004 288.583 C 776.865 289.474 780 293.415 780 297.377 L 780 326.095 C 780 330.058 777.606 335.424 774.658 338.071 L 510.685 575.068 C 504.788 580.362 495.212 580.362 489.315 575.068 Z " fill="rgb(219,45,84)"/><path d=" M 780 429.762 L 780 470.138 C 780 474.101 777.725 479.593 774.923 482.394 L 742 515.318 C 739.198 518.12 736.923 523.612 736.923 527.574 L 736.923 649.625 C 736.922 672.529 730.827 692.394 719.048 710.431 L 599.991 591.373 L 780 429.762 Z " fill="rgb(219,45,84)"/><path d=" M 220 429.762 L 220 462.959 C 220 470.884 224.55 481.867 230.153 487.471 L 252.924 510.241 C 258.527 515.845 263.077 526.829 263.077 534.754 L 263.077 649.625 C 263.078 672.529 269.173 692.394 280.952 710.431 L 400.009 591.373 L 220 429.762 Z " fill="rgb(219,45,84)"/><path d=" M 667.232 760.147 C 627.163 787.649 570.672 813.211 500 843.472 Q 500 843.472 500 843.472 C 429.328 813.211 372.837 787.649 332.768 760.147 L 454.622 638.293 C 459.461 641.204 464.582 643.644 469.918 645.569 C 479.567 649.058 489.741 650.839 500 650.832 C 510.259 650.839 520.433 649.058 530.082 645.569 C 535.418 643.644 540.539 641.204 545.378 638.293 L 667.232 760.147 Z " fill="rgb(219,45,84)"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
@@ -22,16 +22,32 @@
|
|||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icon-192x192.png",
|
"src": "/icon-maskable-light-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable",
|
||||||
|
"media": "(prefers-color-scheme: light)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icon-512x512.png",
|
"src": "/icon-maskable-light-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable",
|
||||||
|
"media": "(prefers-color-scheme: light)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable-dark-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable",
|
||||||
|
"media": "(prefers-color-scheme: dark)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable-dark-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable",
|
||||||
|
"media": "(prefers-color-scheme: dark)"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"categories": ["productivity"],
|
"categories": ["productivity"],
|
||||||
|
|||||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -1,26 +1,16 @@
|
|||||||
/* eslint-disable no-undef */
|
/* eslint-disable no-undef */
|
||||||
|
|
||||||
// Self-destructing service worker.
|
// Minimal service worker – satisfies the PWA installability requirement
|
||||||
//
|
// without caching any assets. All requests fall through to the network,
|
||||||
// The previous version of this file used a cache-first strategy with no
|
// so there is no risk of serving stale chunks after a deployment.
|
||||||
// dev-mode guard, which pinned stale JS/HTML chunks until a hard reload.
|
|
||||||
// This replacement unregisters itself and wipes all caches as soon as the
|
|
||||||
// browser picks it up. Browsers re-fetch sw.js on every navigation to check
|
|
||||||
// for updates, so any client running the old worker will swap to this one
|
|
||||||
// on their next page load and then lose the worker entirely.
|
|
||||||
|
|
||||||
self.addEventListener("install", () => {
|
self.addEventListener("install", () => {
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("activate", (event) => {
|
self.addEventListener("activate", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(self.clients.claim());
|
||||||
(async () => {
|
|
||||||
const cacheNames = await caches.keys();
|
|
||||||
await Promise.all(cacheNames.map((name) => caches.delete(name)));
|
|
||||||
await self.registration.unregister();
|
|
||||||
const clients = await self.clients.matchAll({ type: "window" });
|
|
||||||
clients.forEach((client) => client.navigate(client.url));
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Network-only fetch handler – no caching.
|
||||||
|
self.addEventListener("fetch", () => {});
|
||||||
|
|||||||
@@ -3,18 +3,28 @@ import { persist } from 'zustand/middleware';
|
|||||||
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
|
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import { generateUUID } from '@/lib/utils';
|
import { generateUUID } from '@/lib/utils';
|
||||||
|
import { debug } from '@/lib/debug';
|
||||||
|
|
||||||
export function getContactDisplayName(contact: ContactCard): string {
|
export function getContactDisplayName(contact: ContactCard): string {
|
||||||
if (contact.name?.components) {
|
if (contact.name) {
|
||||||
const given = contact.name.components.find(c => c.kind === 'given')?.value || '';
|
// Try given + surname from components first
|
||||||
const surname = contact.name.components.find(c => c.kind === 'surname')?.value || '';
|
if (contact.name.components && contact.name.components.length > 0) {
|
||||||
const full = [given, surname].filter(Boolean).join(' ');
|
const given = contact.name.components.find(c => c.kind === 'given')?.value || '';
|
||||||
if (full) return full;
|
const surname = contact.name.components.find(c => c.kind === 'surname')?.value || '';
|
||||||
|
const full = [given, surname].filter(Boolean).join(' ');
|
||||||
|
if (full) return full;
|
||||||
|
}
|
||||||
|
// Fall back to name.full (RFC 9553 — used by Stalwart and other JMAP servers)
|
||||||
|
if (contact.name.full) return contact.name.full;
|
||||||
}
|
}
|
||||||
if (contact.nicknames) {
|
if (contact.nicknames) {
|
||||||
const nick = Object.values(contact.nicknames)[0];
|
const nick = Object.values(contact.nicknames)[0];
|
||||||
if (nick?.name) return nick.name;
|
if (nick?.name) return nick.name;
|
||||||
}
|
}
|
||||||
|
if (contact.organizations) {
|
||||||
|
const org = Object.values(contact.organizations)[0];
|
||||||
|
if (org?.name) return org.name;
|
||||||
|
}
|
||||||
if (contact.emails) {
|
if (contact.emails) {
|
||||||
const email = Object.values(contact.emails)[0];
|
const email = Object.values(contact.emails)[0];
|
||||||
if (email?.address) return email.address;
|
if (email?.address) return email.address;
|
||||||
@@ -35,6 +45,8 @@ export function getContactPhotoUri(contact: ContactCard): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const TRUSTED_SENDERS_BOOK_NAME = 'Trusted Senders';
|
||||||
|
|
||||||
interface ContactStore {
|
interface ContactStore {
|
||||||
contacts: ContactCard[];
|
contacts: ContactCard[];
|
||||||
addressBooks: AddressBook[];
|
addressBooks: AddressBook[];
|
||||||
@@ -44,6 +56,12 @@ interface ContactStore {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
supportsSync: boolean;
|
supportsSync: boolean;
|
||||||
|
|
||||||
|
// Trusted senders address book cache (runtime only, not persisted)
|
||||||
|
trustedSenderEmails: string[];
|
||||||
|
trustedSendersBookId: string | null;
|
||||||
|
trustedSendersLoaded: boolean;
|
||||||
|
trustedSendersLoading: boolean;
|
||||||
|
|
||||||
selectedContactIds: Set<string>;
|
selectedContactIds: Set<string>;
|
||||||
lastSelectedContactId: string | null;
|
lastSelectedContactId: string | null;
|
||||||
activeTab: 'all' | 'groups';
|
activeTab: 'all' | 'groups';
|
||||||
@@ -86,6 +104,12 @@ interface ContactStore {
|
|||||||
renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise<void>;
|
renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise<void>;
|
||||||
|
|
||||||
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise<number>;
|
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise<number>;
|
||||||
|
|
||||||
|
// Trusted senders address book
|
||||||
|
loadTrustedSendersBook: (client: IJMAPClient) => Promise<void>;
|
||||||
|
addToTrustedSendersBook: (client: IJMAPClient, email: string) => Promise<void>;
|
||||||
|
removeFromTrustedSendersBook: (client: IJMAPClient, email: string) => Promise<void>;
|
||||||
|
isTrustedAddressBookSender: (email: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useContactStore = create<ContactStore>()(
|
export const useContactStore = create<ContactStore>()(
|
||||||
@@ -130,6 +154,10 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
supportsSync: false,
|
supportsSync: false,
|
||||||
|
trustedSenderEmails: [],
|
||||||
|
trustedSendersBookId: null,
|
||||||
|
trustedSendersLoaded: false,
|
||||||
|
trustedSendersLoading: false,
|
||||||
selectedContactIds: new Set<string>(),
|
selectedContactIds: new Set<string>(),
|
||||||
lastSelectedContactId: null,
|
lastSelectedContactId: null,
|
||||||
activeTab: 'all' as const,
|
activeTab: 'all' as const,
|
||||||
@@ -658,6 +686,74 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loadTrustedSendersBook: async (client) => {
|
||||||
|
if (get().trustedSendersLoading) return;
|
||||||
|
set({ trustedSendersLoading: true });
|
||||||
|
try {
|
||||||
|
debug.log('contacts', 'Loading trusted senders address book');
|
||||||
|
const books = await client.getAddressBooks();
|
||||||
|
let book = books.find(b => b.name === TRUSTED_SENDERS_BOOK_NAME);
|
||||||
|
if (!book) {
|
||||||
|
debug.log('contacts', 'Creating new trusted senders address book');
|
||||||
|
book = await client.createAddressBook(TRUSTED_SENDERS_BOOK_NAME);
|
||||||
|
}
|
||||||
|
const bookId = book.id;
|
||||||
|
debug.log('contacts', 'Trusted senders book id:', bookId);
|
||||||
|
const contacts = await client.getContacts(bookId);
|
||||||
|
debug.log('contacts', 'Loaded', contacts.length, 'trusted sender contacts');
|
||||||
|
const emails = contacts.flatMap(c =>
|
||||||
|
c.emails ? Object.values(c.emails).map(e => e.address.toLowerCase().trim()) : []
|
||||||
|
).filter(Boolean);
|
||||||
|
set({ trustedSendersBookId: bookId, trustedSenderEmails: emails, trustedSendersLoaded: true, trustedSendersLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
debug.error('Failed to load trusted senders address book:', error);
|
||||||
|
set({ trustedSendersLoaded: true, trustedSendersLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addToTrustedSendersBook: async (client, email) => {
|
||||||
|
const normalizedEmail = email.toLowerCase().trim();
|
||||||
|
const { trustedSenderEmails } = get();
|
||||||
|
if (trustedSenderEmails.includes(normalizedEmail)) return;
|
||||||
|
|
||||||
|
let bookId = get().trustedSendersBookId;
|
||||||
|
if (!bookId) {
|
||||||
|
await get().loadTrustedSendersBook(client);
|
||||||
|
bookId = get().trustedSendersBookId;
|
||||||
|
}
|
||||||
|
if (!bookId) throw new Error('Could not find or create trusted senders address book');
|
||||||
|
|
||||||
|
debug.log('contacts', 'Adding trusted sender:', normalizedEmail, 'to book:', bookId);
|
||||||
|
await client.createContact({
|
||||||
|
addressBookIds: { [bookId]: true },
|
||||||
|
emails: { email: { address: normalizedEmail } },
|
||||||
|
});
|
||||||
|
set((state) => ({ trustedSenderEmails: [...state.trustedSenderEmails, normalizedEmail] }));
|
||||||
|
debug.log('contacts', 'Trusted sender added successfully');
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFromTrustedSendersBook: async (client, email) => {
|
||||||
|
const normalizedEmail = email.toLowerCase().trim();
|
||||||
|
const { trustedSendersBookId } = get();
|
||||||
|
if (!trustedSendersBookId) return;
|
||||||
|
|
||||||
|
debug.log('contacts', 'Removing trusted sender:', normalizedEmail);
|
||||||
|
const contacts = await client.getContacts(trustedSendersBookId);
|
||||||
|
const match = contacts.find(c =>
|
||||||
|
c.emails && Object.values(c.emails).some(e => e.address.toLowerCase().trim() === normalizedEmail)
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
await client.deleteContact(match.id);
|
||||||
|
debug.log('contacts', 'Trusted sender removed');
|
||||||
|
}
|
||||||
|
set((state) => ({ trustedSenderEmails: state.trustedSenderEmails.filter(e => e !== normalizedEmail) }));
|
||||||
|
},
|
||||||
|
|
||||||
|
isTrustedAddressBookSender: (email) => {
|
||||||
|
const normalizedEmail = email.toLowerCase().trim();
|
||||||
|
return get().trustedSenderEmails.includes(normalizedEmail);
|
||||||
|
},
|
||||||
|
|
||||||
importContacts: async (client, contacts) => {
|
importContacts: async (client, contacts) => {
|
||||||
const { supportsSync } = get();
|
const { supportsSync } = get();
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
|
|||||||
@@ -312,7 +312,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
const { selectedKeyword } = get();
|
const { selectedKeyword } = get();
|
||||||
const keywordFilter = selectedKeyword ? `$label:${selectedKeyword}` : undefined;
|
const keywordFilter = selectedKeyword ? `$label:${selectedKeyword}` : undefined;
|
||||||
|
|
||||||
const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter);
|
// When filtering by tag, omit the mailbox constraint so emails across
|
||||||
|
// all folders that carry the tag are returned.
|
||||||
|
const result = await client.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter);
|
||||||
set({
|
set({
|
||||||
emails: result.emails,
|
emails: result.emails,
|
||||||
hasMoreEmails: result.hasMore,
|
hasMoreEmails: result.hasMore,
|
||||||
@@ -372,7 +374,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
|
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
|
||||||
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
||||||
|
|
||||||
result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined);
|
// When filtering by tag, omit the mailbox constraint (same rationale as fetchEmails).
|
||||||
|
result = await client.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use fresh state when merging to avoid overwriting concurrent updates
|
// Use fresh state when merging to avoid overwriting concurrent updates
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const ALL_HOVER_ACTIONS: { id: HoverAction; labelKey: string }[] = [
|
|||||||
{ id: 'spam', labelKey: 'spam' },
|
{ id: 'spam', labelKey: 'spam' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export type DebugCategory = 'jmap' | 'calendar' | 'tasks' | 'auth' | 'filters' | 'email' | 'push';
|
export type DebugCategory = 'jmap' | 'calendar' | 'tasks' | 'auth' | 'filters' | 'email' | 'push' | 'contacts';
|
||||||
|
|
||||||
export const ALL_DEBUG_CATEGORIES: { id: DebugCategory; labelKey: string }[] = [
|
export const ALL_DEBUG_CATEGORIES: { id: DebugCategory; labelKey: string }[] = [
|
||||||
{ id: 'jmap', labelKey: 'jmap' },
|
{ id: 'jmap', labelKey: 'jmap' },
|
||||||
@@ -59,6 +59,7 @@ export const ALL_DEBUG_CATEGORIES: { id: DebugCategory; labelKey: string }[] = [
|
|||||||
{ id: 'filters', labelKey: 'filters' },
|
{ id: 'filters', labelKey: 'filters' },
|
||||||
{ id: 'email', labelKey: 'email' },
|
{ id: 'email', labelKey: 'email' },
|
||||||
{ id: 'push', labelKey: 'push' },
|
{ id: 'push', labelKey: 'push' },
|
||||||
|
{ id: 'contacts', labelKey: 'contacts' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface KeywordDefinition {
|
export interface KeywordDefinition {
|
||||||
@@ -140,6 +141,7 @@ interface SettingsState {
|
|||||||
// Privacy & Security
|
// Privacy & Security
|
||||||
sessionTimeout: number; // minutes (0 = never)
|
sessionTimeout: number; // minutes (0 = never)
|
||||||
trustedSenders: string[]; // Email addresses that can load external content
|
trustedSenders: string[]; // Email addresses that can load external content
|
||||||
|
trustedSendersAddressBook: boolean; // Store trusted senders in a dedicated JMAP address book
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
expandedFilterView: boolean;
|
expandedFilterView: boolean;
|
||||||
@@ -185,6 +187,10 @@ interface SettingsState {
|
|||||||
// Keywords (labels/tags)
|
// Keywords (labels/tags)
|
||||||
emailKeywords: KeywordDefinition[];
|
emailKeywords: KeywordDefinition[];
|
||||||
|
|
||||||
|
// Attachment Reminder
|
||||||
|
attachmentReminderEnabled: boolean;
|
||||||
|
attachmentReminderKeywords: string[];
|
||||||
|
|
||||||
// Sidebar Apps
|
// Sidebar Apps
|
||||||
sidebarApps: SidebarApp[];
|
sidebarApps: SidebarApp[];
|
||||||
keepAppsLoaded: boolean;
|
keepAppsLoaded: boolean;
|
||||||
@@ -269,6 +275,7 @@ const DEFAULT_SETTINGS = {
|
|||||||
// Privacy & Security
|
// Privacy & Security
|
||||||
sessionTimeout: 0, // Never
|
sessionTimeout: 0, // Never
|
||||||
trustedSenders: [] as string[],
|
trustedSenders: [] as string[],
|
||||||
|
trustedSendersAddressBook: false,
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
expandedFilterView: false,
|
expandedFilterView: false,
|
||||||
@@ -314,6 +321,37 @@ const DEFAULT_SETTINGS = {
|
|||||||
// Keywords
|
// Keywords
|
||||||
emailKeywords: DEFAULT_KEYWORDS,
|
emailKeywords: DEFAULT_KEYWORDS,
|
||||||
|
|
||||||
|
// Attachment Reminder
|
||||||
|
attachmentReminderEnabled: true,
|
||||||
|
attachmentReminderKeywords: [
|
||||||
|
// English
|
||||||
|
'attached', 'attachment', 'attachments', 'see attached', 'find attached', 'please find attached',
|
||||||
|
// German
|
||||||
|
'angehängt', 'anhang', 'anbei', 'im anhang',
|
||||||
|
// French
|
||||||
|
'ci-joint', 'pièce jointe',
|
||||||
|
// Spanish
|
||||||
|
'adjunto', 'adjunta', 'en adjunto',
|
||||||
|
// Italian
|
||||||
|
'allegato', 'in allegato',
|
||||||
|
// Dutch
|
||||||
|
'bijgevoegd', 'bijlage',
|
||||||
|
// Portuguese
|
||||||
|
'em anexo', 'anexo',
|
||||||
|
// Polish
|
||||||
|
'w załączniku',
|
||||||
|
// Russian
|
||||||
|
'во вложении',
|
||||||
|
// Japanese
|
||||||
|
'添付',
|
||||||
|
// Chinese
|
||||||
|
'附件',
|
||||||
|
// Korean
|
||||||
|
'첨부',
|
||||||
|
// Latvian
|
||||||
|
'pielikumā',
|
||||||
|
] as string[],
|
||||||
|
|
||||||
// Sidebar Apps
|
// Sidebar Apps
|
||||||
sidebarApps: [] as SidebarApp[],
|
sidebarApps: [] as SidebarApp[],
|
||||||
keepAppsLoaded: false,
|
keepAppsLoaded: false,
|
||||||
@@ -412,6 +450,8 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
senderFavicons: state.senderFavicons,
|
senderFavicons: state.senderFavicons,
|
||||||
folderIcons: state.folderIcons,
|
folderIcons: state.folderIcons,
|
||||||
emailKeywords: state.emailKeywords,
|
emailKeywords: state.emailKeywords,
|
||||||
|
attachmentReminderEnabled: state.attachmentReminderEnabled,
|
||||||
|
attachmentReminderKeywords: state.attachmentReminderKeywords,
|
||||||
sidebarApps: state.sidebarApps,
|
sidebarApps: state.sidebarApps,
|
||||||
keepAppsLoaded: state.keepAppsLoaded,
|
keepAppsLoaded: state.keepAppsLoaded,
|
||||||
debugMode: state.debugMode,
|
debugMode: state.debugMode,
|
||||||
|
|||||||