Merge branch 'main' of https://github.com/bulwarkmail/webmail
@@ -48,6 +48,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
|
||||
# OAuth client secret (server-side only, never exposed to the browser)
|
||||
# OAUTH_CLIENT_SECRET=your-client-secret
|
||||
# Alternatively, you can specify the path to a file containing the OAuth client secret.
|
||||
# OAUTH_CLIENT_SECRET_FILE=/oauth-client-secret
|
||||
|
||||
# OpenID Connect issuer URL for discovery
|
||||
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
||||
@@ -60,6 +62,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# Required for both "Remember me" and settings sync features.
|
||||
# Generate with: openssl rand -base64 32
|
||||
# SESSION_SECRET=your-secret-key-here
|
||||
# Alternatively, you can specify the path to a file containing the session secret.
|
||||
# SESSION_SECRET_FILE=/session-secret
|
||||
|
||||
# =============================================================================
|
||||
# Settings Sync
|
||||
|
||||
@@ -21,11 +21,23 @@ on:
|
||||
- ".github/workflows/docker-publish.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ghcr.io/${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
image_name: ${{ steps.set.outputs.image_name }}
|
||||
steps:
|
||||
- name: Set image name
|
||||
id: set
|
||||
run: |
|
||||
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||
echo "image_name=ghcr.io/${{ github.repository }}-beta" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "image_name=ghcr.io/${{ github.repository }}-${{ github.ref_name }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: prepare
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -57,7 +69,7 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
images: ${{ needs.prepare.outputs.image_name }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
@@ -66,7 +78,7 @@ jobs:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
|
||||
@@ -86,7 +98,7 @@ jobs:
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
needs: [prepare, build]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -113,17 +125,17 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
images: ${{ needs.prepare.outputs.image_name }}
|
||||
tags: |
|
||||
type=raw,value={{branch}}
|
||||
type=sha,prefix={{branch}}-
|
||||
type=raw,value=latest
|
||||
type=sha
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
$(printf '${{ needs.prepare.outputs.image_name }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
docker buildx imagetools inspect ${{ needs.prepare.outputs.image_name }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Publish Standalone Tarball on Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
arch: amd64
|
||||
- os: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build standalone
|
||||
run: npm run build
|
||||
|
||||
- name: Package tarball
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
VERSION="${REF_NAME#v}"
|
||||
TARBALL="bulwark-standalone-${VERSION}-linux-${ARCH}.tar.gz"
|
||||
|
||||
mkdir -p bulwark-standalone
|
||||
cp -r .next/standalone/. bulwark-standalone/
|
||||
cp -r .next/static bulwark-standalone/.next/static
|
||||
cp -r public bulwark-standalone/public
|
||||
|
||||
tar -czf "$TARBALL" bulwark-standalone/
|
||||
echo "TARBALL=$TARBALL" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Upload release asset
|
||||
if: github.event_name == 'release'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: gh release upload "$TAG_NAME" "$TARBALL" --clobber
|
||||
|
||||
- name: Upload artifact (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: standalone-${{ matrix.arch }}
|
||||
path: ${{ env.TARBALL }}
|
||||
retention-days: 7
|
||||
@@ -1,5 +1,92 @@
|
||||
# Changelog
|
||||
|
||||
## 1.4.13 (2026-04-12)
|
||||
|
||||
Thank you for your donations:
|
||||
|
||||
**One-time**
|
||||
- [@boris22100](https://github.com/boris22100)
|
||||
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||
|
||||
**Monthly**
|
||||
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||
|
||||
### Features
|
||||
|
||||
- **Contacts**: Store trusted senders in a dedicated JMAP address book (#176)
|
||||
- **Email**: Warn on send when attachment keyword found but no file attached (#172)
|
||||
- **Email**: Enable keyword reordering (#174) and multi-tag support per email (#173)
|
||||
- **PWA**: Add "don't remind me again" option to install prompt
|
||||
- **Auth**: Add `SESSION_SECRET_FILE` and `OAUTH_CLIENT_SECRET_FILE` environment variable support
|
||||
- **Plugins**: Add `onAvatarResolve` plugin hook
|
||||
- **Docker**: Publish main and dev branches as separate GHCR packages
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Email**: Style links in plain text emails
|
||||
- **Email**: Seed list history entry when app initializes on an email view
|
||||
- **Email**: Remount composer on draft edit and preserve identity (#60)
|
||||
- **Contacts**: Display contact names stored in `name.full` (#179)
|
||||
- **Contacts**: Fix category dropdown blocking Save button in contact form (#177)
|
||||
- **Contacts**: Resolve TS error from optional `name.components` in vCard parser
|
||||
- **Search**: Search all folders when filtering emails by tag (#175)
|
||||
- **Auth**: Include mount prefix in SSO redirect URI when app is served under a subpath
|
||||
- **PWA**: Correct PWA icons with proper sizing, transparency, and dark/light mode support
|
||||
|
||||
## 1.4.12 (2026-04-09)
|
||||
|
||||
Thank you for your donations:
|
||||
|
||||
**One-time**
|
||||
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||
|
||||
**Monthly**
|
||||
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||
|
||||
### Features
|
||||
|
||||
- **PWA**: Add PWA support with service worker and install prompt
|
||||
- **Calendar**: Add birthday calendar feature with settings and localization
|
||||
- **Calendar**: Clamp February 29 birthdays in non-leap years
|
||||
- **Identity**: Add automatic identity synchronization (#167)
|
||||
- **Plugins**: Disable plugins by default and require admin approval
|
||||
- **Plugins**: Replace auth header exposure with a secure HTTP proxy API for plugins
|
||||
- **Auth**: Add configurable OAuth scopes and cookie security via environment variables
|
||||
- **Email**: Sync mail view to browser history for back/forward navigation
|
||||
- **Contacts**: Add ability to rename address books (#152)
|
||||
- **UI**: Add version badge in settings
|
||||
- **i18n**: Add Latvian (lv) locale support
|
||||
- **i18n**: Add Polish language support
|
||||
- **i18n**: Add Korean language support
|
||||
- **i18n**: Add Simplified Chinese (zh_CN) locale support
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Email**: Show recipient instead of sender in Sent and Drafts folder lists
|
||||
- **Email**: Embed dropped images as data URLs and prevent duplicate attachments (#163)
|
||||
- **Email**: Fix logic for marking email as read in EmailViewer
|
||||
- **Email**: Fix archive action passing MouseEvent as argument
|
||||
- **Mailbox**: Preserve search filters on push-triggered mailbox refresh (#164)
|
||||
- **Mailbox**: Align shared account folders with primary folders (#151)
|
||||
- **Mailbox**: Fetch mailboxes on mount in FolderSettings when store is empty
|
||||
- **Mailbox**: Improve mailbox deletion error handling
|
||||
- **Calendar**: Improve calendar event retrieval by batching requests to avoid server limits (#141)
|
||||
- **Calendar**: Compute per-occurrence UTC start/end in recurrence expansion (#116)
|
||||
- **Calendar**: Guard against undefined trigger in calendar event alert popover (#143)
|
||||
- **Files**: Stream WebDAV PUT uploads to avoid buffering in memory (#162)
|
||||
- **Files**: Prune recent files against server nodes on refresh (#146)
|
||||
- **Files**: Fix file deletion logic to update recent files and handle errors (#146)
|
||||
- **Files**: Extend file drop zone to fill remaining viewport height
|
||||
- **Files**: Fallback to application/octet-stream for long MIME types
|
||||
- **Security**: Replace unguarded crypto.randomUUID() with safe generateUUID() utility
|
||||
- **Security**: Validate plugin HTTP post URL against origin with regression tests
|
||||
- **Security**: Allow blob images in CSP for inline drag-and-drop (#163)
|
||||
- **Auth**: Resolve settings sync identity mismatch for OAuth/SSO sessions (#127)
|
||||
- **Contacts**: Fix address book ID namespacing for shared contacts in create and update operations (#133)
|
||||
- **UI**: Fix focused mode expanding beyond screen bounds (#156)
|
||||
- **API**: Handle 403 on principal fetch without console error
|
||||
- **API**: Enhance error handling in Stalwart API responses
|
||||
|
||||
## 1.4.11 (2026-03-31)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
|
||||
</div>
|
||||
@@ -271,6 +271,7 @@ PORT=3000 # Default listen port
|
||||
OAUTH_ENABLED=true
|
||||
OAUTH_CLIENT_ID=webmail
|
||||
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
||||
OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret
|
||||
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
|
||||
```
|
||||
|
||||
@@ -282,7 +283,8 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
|
||||
<summary>Remember Me</summary>
|
||||
|
||||
```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).
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||
@@ -231,7 +232,8 @@ export default function LoginPage() {
|
||||
const startServerSideSso = useCallback(async () => {
|
||||
setOauthLoading(true);
|
||||
try {
|
||||
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
|
||||
const prefix = getPathPrefix(params.locale as string);
|
||||
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||
const res = await fetch('/api/auth/sso/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -14,6 +14,7 @@ import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
@@ -39,6 +40,7 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIdentitySync } from "@/hooks/use-identity-sync";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
@@ -60,6 +62,7 @@ export default function Home() {
|
||||
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
||||
const [composerDraftText, setComposerDraftText] = useState("");
|
||||
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
||||
const [composerSessionId, setComposerSessionId] = useState(0);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
@@ -77,6 +80,16 @@ export default function Home() {
|
||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
||||
const { identities } = useIdentityStore();
|
||||
useIdentitySync();
|
||||
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||
const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore();
|
||||
|
||||
// Load trusted senders address book when feature is enabled
|
||||
useEffect(() => {
|
||||
if (trustedSendersAddressBook && client && !trustedSendersLoaded) {
|
||||
loadTrustedSendersBook(client);
|
||||
}
|
||||
}, [trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRateLimited || !rateLimitUntil) {
|
||||
@@ -640,6 +653,16 @@ export default function Home() {
|
||||
const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId]
|
||||
? draft.bodyValues[draft.htmlBody[0].partId].value
|
||||
: undefined;
|
||||
|
||||
// Try to find the identity that matches the draft's from address to preserve it
|
||||
const draftFromEmail = draft.from?.[0]?.email;
|
||||
const matchedIdentity = draftFromEmail
|
||||
? identities.find(id => id.email === draftFromEmail)
|
||||
: null;
|
||||
|
||||
// Increment session ID to force the composer to remount with fresh state,
|
||||
// even if it was already open (e.g. right-clicking a draft while composing).
|
||||
setComposerSessionId(id => id + 1);
|
||||
setPendingDraft({
|
||||
to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '',
|
||||
cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '',
|
||||
@@ -648,7 +671,7 @@ export default function Home() {
|
||||
body: htmlBody || bodyText,
|
||||
showCc: (draft.cc?.length || 0) > 0,
|
||||
showBcc: (draft.bcc?.length || 0) > 0,
|
||||
selectedIdentityId: null,
|
||||
selectedIdentityId: matchedIdentity?.id ?? null,
|
||||
subAddressTag: '',
|
||||
mode: 'compose',
|
||||
draftId: draft.id,
|
||||
@@ -828,16 +851,22 @@ export default function Home() {
|
||||
|
||||
const keywords = { ...email.keywords };
|
||||
|
||||
// Remove old label and legacy color tags - set to false for JMAP to remove them
|
||||
Object.keys(keywords).forEach(key => {
|
||||
if (key.startsWith("$label:") || key.startsWith("$color:")) {
|
||||
keywords[key] = false;
|
||||
if (color === null) {
|
||||
// Remove all label/color tags
|
||||
Object.keys(keywords).forEach(key => {
|
||||
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
|
||||
@@ -1654,8 +1683,9 @@ export default function Home() {
|
||||
}}
|
||||
>
|
||||
<EmailComposer
|
||||
key={composerSessionId}
|
||||
mode={pendingDraft?.mode ?? composerMode}
|
||||
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
|
||||
replyTo={pendingDraft !== null ? pendingDraft.replyTo : (selectedEmail ? {
|
||||
from: selectedEmail.from,
|
||||
replyToAddresses: selectedEmail.replyTo,
|
||||
to: selectedEmail.to,
|
||||
|
||||
@@ -7,13 +7,14 @@ import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!process.env.SESSION_SECRET) {
|
||||
if (!process.env.SESSION_SECRET && !readFileEnv(process.env.SESSION_SECRET_FILE)) {
|
||||
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
|
||||
/**
|
||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||
@@ -113,7 +114,7 @@ async function attemptAllStrategies(
|
||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||
|
||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
|
||||
const clientSecret = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
||||
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
|
||||
/**
|
||||
* Runtime configuration endpoint
|
||||
@@ -33,8 +34,8 @@ export async function GET() {
|
||||
oauthOnly,
|
||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||
rememberMeEnabled: !!process.env.SESSION_SECRET,
|
||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && !!process.env.SESSION_SECRET,
|
||||
rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
|
||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
|
||||
stalwartFeaturesEnabled,
|
||||
devMode: configManager.get<boolean>('devMode', false),
|
||||
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
|
||||
function classifyError(error: unknown): { message: string; status: number } {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
@@ -48,7 +49,7 @@ function classifyError(error: unknown): { message: string; status: number } {
|
||||
}
|
||||
|
||||
function isEnabled(): boolean {
|
||||
return process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET;
|
||||
return process.env.SETTINGS_SYNC_ENABLED === 'true' && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE));
|
||||
}
|
||||
|
||||
/** Strip trailing slashes so differently-formatted URLs still match. */
|
||||
|
||||
@@ -208,6 +208,11 @@ body {
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.email-content-text a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.email-content {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generator: Gravit.io -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clipPath_Sdk6Fary7vPYZxd20WRVIKca0qb2ogEk"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clipPath_Sdk6Fary7vPYZxd20WRVIKca0qb2ogEk)"><rect width="1000" height="1000" style="fill:rgb(0,0,0)" fill-opacity="0"/><path d=" M 483.028 563.647 L 63.713 187.183 C 59.029 182.978 55.226 174.454 55.226 168.159 L 55.226 122.542 C 55.226 116.247 60.206 109.988 66.339 108.573 L 215.181 74.225 C 221.314 72.809 226.293 76.77 226.293 83.065 L 226.293 176.92 L 352.034 147.895 C 358.167 146.479 363.147 140.219 363.147 133.925 L 363.147 51.483 C 363.147 45.189 368.126 38.93 374.259 37.514 L 488.888 11.061 C 495.021 9.646 504.979 9.646 511.112 11.061 L 625.741 37.514 C 631.874 38.93 636.853 45.189 636.853 51.483 L 636.853 133.925 C 636.853 140.219 641.833 146.479 647.966 147.895 L 773.707 176.92 L 773.707 83.065 C 773.707 76.77 778.686 72.809 784.819 74.225 L 933.661 108.573 C 939.794 109.988 944.774 116.247 944.774 122.542 L 944.774 168.159 C 944.774 174.454 940.971 182.978 936.287 187.183 L 516.972 563.647 C 507.605 572.056 492.395 572.056 483.028 563.647 Z " fill="rgb(219,45,84)"/><path d=" M 944.774 332.832 L 944.774 396.969 C 944.774 403.263 941.16 411.987 936.709 416.437 L 884.411 468.736 C 879.96 473.186 876.347 481.91 876.347 488.204 L 876.347 682.08 C 876.345 718.462 866.664 750.017 847.953 778.668 L 658.833 589.547 L 944.774 332.832 Z " fill="rgb(219,45,84)"/><path d=" M 55.226 332.832 L 55.226 385.564 C 55.226 398.153 62.453 415.6 71.355 424.501 L 107.525 460.671 C 116.426 469.573 123.653 487.02 123.653 499.609 L 123.653 682.08 C 123.655 718.462 133.336 750.017 152.047 778.668 L 341.167 589.547 L 55.226 332.832 Z " fill="rgb(219,45,84)"/><path d=" M 765.645 857.641 C 701.996 901.327 612.26 941.932 500 990 Q 500 990 500 990 C 387.74 941.932 298.004 901.327 234.355 857.641 L 427.917 664.079 C 435.604 668.703 443.74 672.579 452.215 675.636 C 467.543 681.179 483.703 684.007 500 683.996 C 516.297 684.007 532.457 681.179 547.785 675.636 C 556.261 672.579 564.396 668.703 572.083 664.079 L 765.645 857.641 Z " fill="rgb(219,45,84)"/></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000">
|
||||
<defs>
|
||||
<clipPath id="_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV"><rect width="1000" height="1000"/></clipPath>
|
||||
<style>
|
||||
.icon-bg { fill: #ffffff; }
|
||||
.icon-mark { fill: rgb(219,45,84); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.icon-bg { fill: #18181b; }
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g clip-path="url(#_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV)">
|
||||
<rect width="1000" height="1000" class="icon-bg"/>
|
||||
<path d=" M 489.315 575.068 L 225.342 338.071 C 222.394 335.424 220 330.058 220 326.095 L 220 297.377 C 220 293.415 223.135 289.474 226.996 288.583 L 320.697 266.96 C 324.558 266.069 327.692 268.563 327.692 272.525 L 327.692 331.61 L 406.851 313.338 C 410.712 312.446 413.846 308.506 413.846 304.543 L 413.846 252.643 C 413.846 248.681 416.981 244.741 420.842 243.85 L 493.004 227.197 C 496.865 226.306 503.135 226.306 506.996 227.197 L 579.158 243.85 C 583.019 244.741 586.154 248.681 586.154 252.643 L 586.154 304.543 C 586.154 308.506 589.288 312.446 593.149 313.338 L 672.308 331.61 L 672.308 272.525 C 672.308 268.563 675.442 266.069 679.303 266.96 L 773.004 288.583 C 776.865 289.474 780 293.415 780 297.377 L 780 326.095 C 780 330.058 777.606 335.424 774.658 338.071 L 510.685 575.068 C 504.788 580.362 495.212 580.362 489.315 575.068 Z " class="icon-mark"/>
|
||||
<path d=" M 780 429.762 L 780 470.138 C 780 474.101 777.725 479.593 774.923 482.394 L 742 515.318 C 739.198 518.12 736.923 523.612 736.923 527.574 L 736.923 649.625 C 736.922 672.529 730.827 692.394 719.048 710.431 L 599.991 591.373 L 780 429.762 Z " class="icon-mark"/>
|
||||
<path d=" M 220 429.762 L 220 462.959 C 220 470.884 224.55 481.867 230.153 487.471 L 252.924 510.241 C 258.527 515.845 263.077 526.829 263.077 534.754 L 263.077 649.625 C 263.078 672.529 269.173 692.394 280.952 710.431 L 400.009 591.373 L 220 429.762 Z " class="icon-mark"/>
|
||||
<path d=" M 667.232 760.147 C 627.163 787.649 570.672 813.211 500 843.472 Q 500 843.472 500 843.472 C 429.328 813.211 372.837 787.649 332.768 760.147 L 454.622 638.293 C 459.461 641.204 464.582 643.644 469.918 645.569 C 479.567 649.058 489.741 650.839 500 650.832 C 510.259 650.839 520.433 649.058 530.082 645.569 C 535.418 643.644 540.539 641.204 545.378 638.293 L 667.232 760.147 Z " class="icon-mark"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.4 KiB |
@@ -911,7 +911,6 @@ function CategoryComboBox({
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Parse current keywords from comma-separated string
|
||||
@@ -946,17 +945,6 @@ function CategoryComboBox({
|
||||
onChange(next);
|
||||
}, [currentKeywords, onChange]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -970,7 +958,7 @@ function CategoryComboBox({
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative">
|
||||
<div className="relative">
|
||||
{/* Keyword badges */}
|
||||
{currentKeywords.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
@@ -998,6 +986,7 @@ function CategoryComboBox({
|
||||
value={inputValue}
|
||||
onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onBlur={() => setIsOpen(false)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={currentKeywords.length === 0 ? placeholder : ""}
|
||||
/>
|
||||
@@ -1005,7 +994,7 @@ function CategoryComboBox({
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (suggestions.length > 0 || canAddNew) && (
|
||||
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1">
|
||||
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1" onMouseDown={(e) => e.preventDefault()}>
|
||||
{suggestions.map(kw => (
|
||||
<button
|
||||
key={kw}
|
||||
|
||||
@@ -103,6 +103,8 @@ export function EmailComposer({
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
||||
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
||||
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
||||
|
||||
// Initialize with reply/forward data if provided
|
||||
const getInitialTo = () => {
|
||||
@@ -212,6 +214,8 @@ export function EmailComposer({
|
||||
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
|
||||
const [smimePassphraseInput, setSmimePassphraseInput] = useState('');
|
||||
const [smimePassphraseError, setSmimePassphraseError] = useState('');
|
||||
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
|
||||
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
|
||||
|
||||
const saveTemplateModalRef = useFocusTrap({
|
||||
isActive: showSaveAsTemplate,
|
||||
@@ -225,6 +229,12 @@ export function EmailComposer({
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
const attachmentWarningRef = useFocusTrap({
|
||||
isActive: showAttachmentWarning,
|
||||
onEscape: () => setShowAttachmentWarning(false),
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
const { client } = useAuthStore();
|
||||
const identities = useIdentityStore((s) => s.identities);
|
||||
const primaryIdentity = identities[0] ?? null;
|
||||
@@ -532,17 +542,18 @@ export function EmailComposer({
|
||||
}
|
||||
}, [client, t]);
|
||||
|
||||
const handleImageUpload = useCallback(async (file: File): Promise<string | null> => {
|
||||
if (!client) return null;
|
||||
try {
|
||||
const { blobId } = await client.uploadBlob(file);
|
||||
return await client.fetchBlobAsObjectUrl(blobId, file.name, file.type);
|
||||
} catch (error) {
|
||||
debug.error(`Failed to upload inline image ${file.name}:`, error);
|
||||
toast.error(t('upload_failed', { filename: file.name }));
|
||||
return null;
|
||||
}
|
||||
}, [client, t]);
|
||||
const handleImageUpload = useCallback((file: File): Promise<string | null> => {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
|
||||
reader.onerror = () => {
|
||||
debug.error(`Failed to read inline image ${file.name}`);
|
||||
toast.error(t('upload_failed', { filename: file.name }));
|
||||
resolve(null);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}, [t]);
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!event.target.files) return;
|
||||
@@ -722,7 +733,7 @@ export function EmailComposer({
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
const handleSend = async (skipAttachmentCheck = false) => {
|
||||
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
||||
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
||||
|
||||
@@ -741,6 +752,21 @@ export function EmailComposer({
|
||||
return;
|
||||
}
|
||||
|
||||
// Attachment reminder check
|
||||
if (!skipAttachmentCheck && attachmentReminderEnabled) {
|
||||
const hasAttachments = attachments.some(att => att.blobId && !att.uploading && !att.error);
|
||||
if (!hasAttachments) {
|
||||
const bodyText = htmlToPlainText(body);
|
||||
const searchText = `${subject} ${bodyText}`.toLowerCase();
|
||||
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
|
||||
if (matched) {
|
||||
setAttachmentWarningKeyword(matched);
|
||||
setShowAttachmentWarning(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let finalDraftId = draftId;
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
@@ -1001,7 +1027,7 @@ export function EmailComposer({
|
||||
</div>
|
||||
{/* Mobile: send button in header */}
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend}
|
||||
title={getSendTooltip()}
|
||||
size="sm"
|
||||
@@ -1366,7 +1392,7 @@ export function EmailComposer({
|
||||
{t('discard')}
|
||||
</button>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend}
|
||||
title={getSendTooltip()}
|
||||
className="hidden md:inline-flex"
|
||||
@@ -1466,6 +1492,36 @@ export function EmailComposer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAttachmentWarning && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
|
||||
onClick={() => setShowAttachmentWarning(false)}
|
||||
>
|
||||
<div
|
||||
ref={attachmentWarningRef}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('forgot_attachment.title')}</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t('forgot_attachment.message', { keyword: attachmentWarningKeyword })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-6">
|
||||
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
|
||||
{t('forgot_attachment.back')}
|
||||
</Button>
|
||||
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true); }}>
|
||||
{t('forgot_attachment.send_anyway')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCloseDialog && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
|
||||
|
||||
@@ -89,17 +89,18 @@ const getMailboxIcon = (role?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Get current label/color from email keywords (supports both $label: and legacy $color:)
|
||||
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
|
||||
if (!keywords) return null;
|
||||
// Get all active label/color tag IDs from email keywords
|
||||
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
|
||||
if (!keywords) return [];
|
||||
const tags: string[] = [];
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
||||
return key.startsWith("$label:")
|
||||
? key.slice("$label:".length)
|
||||
: key.slice("$color:".length);
|
||||
tags.push(
|
||||
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return tags;
|
||||
};
|
||||
|
||||
export function EmailContextMenu({
|
||||
@@ -137,7 +138,7 @@ export function EmailContextMenu({
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isDraft = email.keywords?.['$draft'] === true;
|
||||
const currentColor = getCurrentColor(email.keywords);
|
||||
const currentColors = getCurrentColors(email.keywords);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
|
||||
@@ -306,24 +307,27 @@ export function EmailContextMenu({
|
||||
{/* Set tag submenu - only for single email */}
|
||||
{!showBatchActions && (
|
||||
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
||||
{colorOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
role="menuitem"
|
||||
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
|
||||
currentColor === option.value && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||
<span className="flex-1">{option.name}</span>
|
||||
{currentColor === option.value && (
|
||||
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{currentColor && (
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
role="menuitem"
|
||||
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
|
||||
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>
|
||||
{isActive && (
|
||||
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { EmailHoverActions } from "./email-hover-actions";
|
||||
import { getEmailColorTag } from "@/lib/thread-utils";
|
||||
import { getEmailColorTags } from "@/lib/thread-utils";
|
||||
|
||||
interface EmailListItemProps {
|
||||
email: Email;
|
||||
@@ -32,7 +32,7 @@ interface EmailListItemProps {
|
||||
|
||||
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
|
||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
@@ -44,13 +44,18 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
const isImportant = email.keywords?.["$important"];
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
const sender = email.from?.[0];
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||
|
||||
// Resolve color tag using keyword definitions from settings
|
||||
const colorTagId = getEmailColorTag(email.keywords);
|
||||
const keywordDef = colorTagId ? emailKeywords.find(k => k.id === colorTagId) : null;
|
||||
// Resolve color tags using keyword definitions from settings
|
||||
const colorTagIds = getEmailColorTags(email.keywords);
|
||||
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
|
||||
// Use first tag for background coloring
|
||||
const keywordDef = keywordDefs[0] ?? null;
|
||||
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||
|
||||
// Drag and drop functionality
|
||||
@@ -196,7 +201,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
</>
|
||||
)}
|
||||
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
|
||||
{keywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
|
||||
))}
|
||||
<span className={cn(
|
||||
'text-xs tabular-nums',
|
||||
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||
@@ -246,15 +253,15 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{keywordDef && (
|
||||
<span className={cn(
|
||||
{keywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
||||
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
|
||||
)}>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
||||
{keywordDef.label}
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
|
||||
{kd.label}
|
||||
</span>
|
||||
)}
|
||||
))}
|
||||
<span className={cn(
|
||||
"text-xs tabular-nums",
|
||||
isUnread
|
||||
|
||||
@@ -193,16 +193,17 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
|
||||
return 'Attachment';
|
||||
};
|
||||
|
||||
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
|
||||
if (!keywords) return null;
|
||||
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
|
||||
if (!keywords) return [];
|
||||
const tags: string[] = [];
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
||||
return key.startsWith("$label:")
|
||||
? key.slice("$label:".length)
|
||||
: key.slice("$color:".length);
|
||||
tags.push(
|
||||
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return tags;
|
||||
};
|
||||
|
||||
// Helper function to format recipients with contextual display
|
||||
@@ -887,6 +888,9 @@ export function EmailViewer({
|
||||
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
|
||||
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
||||
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
||||
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
|
||||
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||
@@ -933,7 +937,8 @@ export function EmailViewer({
|
||||
const moveMenuRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
|
||||
const currentColor = getCurrentColor(email?.keywords);
|
||||
const currentColors = getCurrentColors(email?.keywords);
|
||||
const currentColor = currentColors[0] ?? null;
|
||||
|
||||
// S/MIME state
|
||||
const [smimeStatus, setSmimeStatus] = useState<SmimeStatus | null>(null);
|
||||
@@ -2309,9 +2314,11 @@ export function EmailViewer({
|
||||
// Use shared sanitization config as base (more secure)
|
||||
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
|
||||
|
||||
// Check if sender is trusted
|
||||
// Check if sender is trusted (localStorage list or address book)
|
||||
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
||||
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false;
|
||||
const senderIsTrusted = senderEmail
|
||||
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
|
||||
: false;
|
||||
|
||||
// Block external content based on policy:
|
||||
// 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted
|
||||
@@ -2418,7 +2425,7 @@ export function EmailViewer({
|
||||
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
||||
isHtml: false
|
||||
};
|
||||
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, cidBlobUrls]);
|
||||
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, isTrustedAddressBookSender, trustedSendersAddressBook, cidBlobUrls]);
|
||||
|
||||
// Override email content with S/MIME decrypted content when available
|
||||
const effectiveEmailContent = useMemo(() => {
|
||||
@@ -3055,43 +3062,51 @@ export function EmailViewer({
|
||||
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
|
||||
className={cn(
|
||||
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
|
||||
currentColor && "bg-muted/50"
|
||||
currentColors.length > 0 && "bg-muted/50"
|
||||
)}
|
||||
title={t('set_color')}
|
||||
>
|
||||
{(() => {
|
||||
const kw = currentColor ? emailKeywords.find(k => k.id === currentColor) : null;
|
||||
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
|
||||
return dotClass ? (
|
||||
<>
|
||||
<span className={cn("w-3 h-3 rounded-full", dotClass)} />
|
||||
{showToolbarLabels && <span className="text-xs font-medium text-foreground">{kw!.label}</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tag className="w-4 h-4 text-muted-foreground" />
|
||||
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{currentColors.length > 0 ? (
|
||||
<>
|
||||
<span className="flex items-center gap-0.5">
|
||||
{currentColors.slice(0, 3).map((tagId) => {
|
||||
const kw = emailKeywords.find(k => k.id === tagId);
|
||||
return kw ? <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot)} /> : null;
|
||||
})}
|
||||
</span>
|
||||
{showToolbarLabels && currentColors.length === 1 && (
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{emailKeywords.find(k => k.id === currentColors[0])?.label}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tag className="w-4 h-4 text-muted-foreground" />
|
||||
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{tagMenuOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
|
||||
{colorOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
||||
currentColor === option.value && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<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" />}
|
||||
</button>
|
||||
))}
|
||||
{currentColor && (
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
||||
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>
|
||||
{isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
@@ -3299,21 +3314,24 @@ export function EmailViewer({
|
||||
</button>
|
||||
{moreMenuSub === 'tag' && (
|
||||
<div className="absolute right-full top-0 mr-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
|
||||
{colorOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
||||
currentColor === option.value && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<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" />}
|
||||
</button>
|
||||
))}
|
||||
{currentColor && (
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
||||
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>
|
||||
{isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
@@ -3489,21 +3507,24 @@ export function EmailViewer({
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
|
||||
{colorOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
|
||||
className={cn(
|
||||
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
|
||||
currentColor === option.value && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<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" />}
|
||||
</button>
|
||||
))}
|
||||
{currentColor && (
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
|
||||
className={cn(
|
||||
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
|
||||
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>
|
||||
{isActive && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<button
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
|
||||
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
|
||||
@@ -3649,14 +3670,18 @@ export function EmailViewer({
|
||||
)} />
|
||||
</button>
|
||||
)}
|
||||
{/* Color tag dot */}
|
||||
{currentColor && (() => {
|
||||
const kw = emailKeywords.find(k => k.id === currentColor);
|
||||
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
|
||||
return dotClass ? (
|
||||
<span className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
|
||||
) : null;
|
||||
})()}
|
||||
{/* Color tag dots */}
|
||||
{currentColors.length > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
{currentColors.map((tagId) => {
|
||||
const kw = emailKeywords.find(k => k.id === tagId);
|
||||
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
|
||||
return dotClass ? (
|
||||
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
|
||||
) : null;
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{isImportant && (
|
||||
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
|
||||
{t('important')}
|
||||
@@ -4545,7 +4570,11 @@ export function EmailViewer({
|
||||
onClick={() => {
|
||||
const senderEmail = email.from?.[0]?.email;
|
||||
if (senderEmail) {
|
||||
addTrustedSender(senderEmail);
|
||||
if (trustedSendersAddressBook && client) {
|
||||
addToTrustedSendersBook(client, senderEmail).catch(console.error);
|
||||
} else {
|
||||
addTrustedSender(senderEmail);
|
||||
}
|
||||
setAllowExternalContent(true);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -118,6 +118,7 @@ export function RichTextEditor({
|
||||
);
|
||||
if (imageFiles.length === 0) return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
for (const file of imageFiles) {
|
||||
upload(file).then((url) => {
|
||||
if (url) {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
|
||||
@@ -84,6 +85,10 @@ export function ThreadConversationView({
|
||||
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
||||
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
||||
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
||||
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
|
||||
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
||||
const { client } = useAuthStore();
|
||||
|
||||
// Track which emails are expanded (most recent by default)
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
@@ -164,7 +169,9 @@ export function ThreadConversationView({
|
||||
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
|
||||
{emails.map((email, index) => {
|
||||
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
||||
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false;
|
||||
const senderIsTrusted = senderEmail
|
||||
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
|
||||
: false;
|
||||
return (
|
||||
<EmailCard
|
||||
key={email.id}
|
||||
@@ -175,7 +182,11 @@ export function ThreadConversationView({
|
||||
onToggleExpanded={() => toggleExpanded(email.id)}
|
||||
onAllowExternal={() => toggleAllowExternal(email.id)}
|
||||
onTrustSender={senderEmail ? () => {
|
||||
addTrustedSender(senderEmail);
|
||||
if (trustedSendersAddressBook && client) {
|
||||
addToTrustedSendersBook(client, senderEmail).catch(console.error);
|
||||
} else {
|
||||
addTrustedSender(senderEmail);
|
||||
}
|
||||
toggleAllowExternal(email.id);
|
||||
} : 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 { useUIStore } from "@/stores/ui-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils";
|
||||
import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
|
||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { ThreadEmailItem } from "./thread-email-item";
|
||||
@@ -55,8 +55,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
const sender = email.from?.[0];
|
||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
@@ -64,9 +67,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||
|
||||
// Resolve color and keyword definition from keyword definitions if not passed directly
|
||||
const tagId = getEmailColorTag(email.keywords);
|
||||
const resolvedKeywordDef = tagId ? emailKeywords.find(k => k.id === tagId) : null;
|
||||
// Resolve color tags using keyword definitions
|
||||
const tagIds = getEmailColorTags(email.keywords);
|
||||
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
|
||||
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
|
||||
const resolvedColorTag = (() => {
|
||||
if (colorTag) return colorTag;
|
||||
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
|
||||
@@ -209,7 +213,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</>
|
||||
)}
|
||||
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{resolvedKeywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || 'bg-gray-400')} />}
|
||||
{resolvedKeywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
|
||||
))}
|
||||
<span className={cn(
|
||||
'text-xs tabular-nums',
|
||||
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||
@@ -252,15 +258,15 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{resolvedKeywordDef && (
|
||||
<span className={cn(
|
||||
{resolvedKeywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
|
||||
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
|
||||
)}>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
|
||||
{resolvedKeywordDef.label}
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
|
||||
{kd.label}
|
||||
</span>
|
||||
)}
|
||||
))}
|
||||
<span className={cn(
|
||||
"text-xs tabular-nums",
|
||||
isUnread
|
||||
@@ -339,7 +345,16 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
|
||||
|
||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const displayNames = showRecipient
|
||||
? Array.from(new Set(
|
||||
thread.emails.flatMap(e => (e.to ?? []).map(r => r.name || r.email.split('@')[0]))
|
||||
)).slice(0, 4)
|
||||
: participantNames;
|
||||
const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0];
|
||||
|
||||
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
|
||||
email: latestEmail,
|
||||
@@ -522,8 +537,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
|
||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={latestEmail.from?.[0]?.name}
|
||||
email={latestEmail.from?.[0]?.email}
|
||||
name={avatarPerson?.name}
|
||||
email={avatarPerson?.email}
|
||||
size="md"
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
/>
|
||||
@@ -537,7 +552,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
'w-32 shrink-0 truncate text-sm lg:w-44',
|
||||
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||
)}>
|
||||
{participantNames.join(', ')}
|
||||
{displayNames.join(', ')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -572,7 +587,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</>
|
||||
)}
|
||||
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
|
||||
{keywordDef && (
|
||||
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
|
||||
)}
|
||||
<span className={cn(
|
||||
'text-xs tabular-nums',
|
||||
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||
@@ -591,7 +608,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
? "font-bold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{participantNames.join(", ")}
|
||||
{displayNames.join(", ")}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -14,6 +14,10 @@ function useSyncIdentities() {
|
||||
const syncIdentities = useAuthStore((state) => state.syncIdentities);
|
||||
return syncIdentities;
|
||||
}
|
||||
|
||||
function useRefreshIdentities() {
|
||||
return useAuthStore((state) => state.refreshIdentities);
|
||||
}
|
||||
import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
@@ -49,6 +53,15 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary);
|
||||
const syncIdentities = useSyncIdentities();
|
||||
|
||||
const refreshIdentitiesFromServer = useRefreshIdentities();
|
||||
|
||||
// Refresh identities from server whenever the modal is opened
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
refreshIdentitiesFromServer();
|
||||
}
|
||||
}, [isOpen, refreshIdentitiesFromServer]);
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
@@ -8,12 +8,16 @@ interface BeforeInstallPromptEvent extends Event {
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
const DISMISSED_KEY = "pwa-install-dismissed";
|
||||
|
||||
export function PWAInstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] =
|
||||
useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem(DISMISSED_KEY)) return;
|
||||
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||
@@ -43,6 +47,11 @@ export function PWAInstallPrompt() {
|
||||
setShowPrompt(false);
|
||||
};
|
||||
|
||||
const handleDismissForever = () => {
|
||||
localStorage.setItem(DISMISSED_KEY, "1");
|
||||
setShowPrompt(false);
|
||||
};
|
||||
|
||||
if (!showPrompt || !deferredPrompt) {
|
||||
return null;
|
||||
}
|
||||
@@ -69,18 +78,26 @@ export function PWAInstallPrompt() {
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</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
|
||||
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"
|
||||
onClick={handleDismissForever}
|
||||
className="w-full text-xs text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors text-center"
|
||||
>
|
||||
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
|
||||
Don't remind me again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,8 +11,9 @@ import { useEmailStore } from '@/stores/email-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
|
||||
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react';
|
||||
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail, X } from 'lucide-react';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
|
||||
const MAIL_LAYOUT_PREVIEW_ROWS = [
|
||||
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
|
||||
@@ -110,6 +111,8 @@ export function EmailSettings() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
|
||||
const {
|
||||
markAsReadDelay,
|
||||
deleteAction,
|
||||
@@ -129,12 +132,16 @@ export function EmailSettings() {
|
||||
hoverActionsMode,
|
||||
hoverActionsCorner,
|
||||
trustedSenders,
|
||||
trustedSendersAddressBook,
|
||||
attachmentReminderEnabled,
|
||||
attachmentReminderKeywords,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
const { trustedSenderEmails } = useContactStore();
|
||||
|
||||
// Get count label for trusted senders button
|
||||
const getTrustedSendersCount = () => {
|
||||
const count = trustedSenders.length;
|
||||
const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length;
|
||||
if (count === 0) return t('trusted_senders.count_zero');
|
||||
if (count === 1) return t('trusted_senders.count_one');
|
||||
return t('trusted_senders.count_other', { count });
|
||||
@@ -337,6 +344,63 @@ export function EmailSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Attachment Reminder */}
|
||||
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
|
||||
<ToggleSwitch
|
||||
checked={attachmentReminderEnabled}
|
||||
onChange={(checked) => updateSetting('attachmentReminderEnabled', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
{attachmentReminderEnabled && (
|
||||
<div className="py-3 border-b border-border space-y-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{t('attachment_reminder.keywords_label')}</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('attachment_reminder.keywords_description')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{attachmentReminderKeywords.map((kw) => (
|
||||
<span key={kw} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-foreground">
|
||||
{kw}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('attachment_reminder.remove')}
|
||||
onClick={() => updateSetting('attachmentReminderKeywords', attachmentReminderKeywords.filter(k => k !== kw))}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const trimmed = newKeyword.trim().toLowerCase();
|
||||
if (trimmed && !attachmentReminderKeywords.includes(trimmed)) {
|
||||
updateSetting('attachmentReminderKeywords', [...attachmentReminderKeywords, trimmed]);
|
||||
}
|
||||
setNewKeyword('');
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={newKeyword}
|
||||
onChange={(e) => setNewKeyword(e.target.value)}
|
||||
placeholder={t('attachment_reminder.add_placeholder')}
|
||||
className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newKeyword.trim()}
|
||||
className="px-3 py-1 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t('attachment_reminder.add')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Hover Actions */}
|
||||
{isFeatureEnabled('hoverActionsConfigEnabled') && (
|
||||
<div className="py-3 border-b border-border space-y-3">
|
||||
@@ -511,6 +575,14 @@ export function EmailSettings() {
|
||||
</button>
|
||||
</SettingItem>
|
||||
|
||||
{/* Trusted Senders — address book storage */}
|
||||
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
|
||||
<ToggleSwitch
|
||||
checked={trustedSendersAddressBook}
|
||||
onChange={(checked) => updateSetting('trustedSendersAddressBook', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Trusted Senders Modal */}
|
||||
<TrustedSendersModal
|
||||
isOpen={showTrustedModal}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
@@ -41,16 +41,39 @@ function KeywordRow({
|
||||
keyword,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
isDragOver,
|
||||
isDragging,
|
||||
}: {
|
||||
keyword: KeywordDefinition;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onDragStart: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDrop: () => void;
|
||||
onDragEnd: () => void;
|
||||
isDragOver: boolean;
|
||||
isDragging: boolean;
|
||||
}) {
|
||||
const t = useTranslations("settings.keywords");
|
||||
const palette = KEYWORD_PALETTE[keyword.color];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group">
|
||||
<div
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onDragEnd={onDragEnd}
|
||||
className={cn(
|
||||
"flex items-center gap-3 py-2.5 px-3 rounded-md border bg-background group transition-opacity",
|
||||
isDragging ? "opacity-40" : "opacity-100",
|
||||
isDragOver ? "border-primary" : "border-border"
|
||||
)}
|
||||
>
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
|
||||
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
|
||||
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
|
||||
@@ -166,9 +189,39 @@ export function KeywordSettings() {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [isMigrating, setIsMigrating] = useState(false);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
const existingIds = emailKeywords.map((k) => k.id);
|
||||
|
||||
const handleDragStart = (index: number) => {
|
||||
setDragIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (index !== dragOverIndex) setDragOverIndex(index);
|
||||
};
|
||||
|
||||
const handleDrop = (index: number) => {
|
||||
if (dragIndex === null || dragIndex === index) {
|
||||
setDragIndex(null);
|
||||
setDragOverIndex(null);
|
||||
return;
|
||||
}
|
||||
const reordered = [...emailKeywords];
|
||||
const [moved] = reordered.splice(dragIndex, 1);
|
||||
reordered.splice(index, 0, moved);
|
||||
reorderKeywords(reordered);
|
||||
setDragIndex(null);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDragIndex(null);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
const handleAdd = (keyword: KeywordDefinition) => {
|
||||
addKeyword(keyword);
|
||||
setIsAdding(false);
|
||||
@@ -220,7 +273,7 @@ export function KeywordSettings() {
|
||||
{t("migrating")}
|
||||
</div>
|
||||
)}
|
||||
{emailKeywords.map((keyword) =>
|
||||
{emailKeywords.map((keyword, index) =>
|
||||
editingId === keyword.id ? (
|
||||
<KeywordEditForm
|
||||
key={keyword.id}
|
||||
@@ -238,6 +291,12 @@ export function KeywordSettings() {
|
||||
setIsAdding(false);
|
||||
}}
|
||||
onDelete={() => handleDelete(keyword.id)}
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={() => handleDrop(index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
isDragOver={dragOverIndex === index && dragIndex !== index}
|
||||
isDragging={dragIndex === index}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, ShieldCheck, Search, Trash2, Plus } from "lucide-react";
|
||||
import { X, ShieldCheck, Search, Trash2, Plus, Loader2 } from "lucide-react";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TrustedSendersModalProps {
|
||||
@@ -17,22 +19,43 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { trustedSenders, addTrustedSender, removeTrustedSender } = useSettingsStore();
|
||||
const { trustedSenders, addTrustedSender, removeTrustedSender, trustedSendersAddressBook } = useSettingsStore();
|
||||
const {
|
||||
trustedSenderEmails,
|
||||
trustedSendersLoaded,
|
||||
trustedSendersLoading,
|
||||
loadTrustedSendersBook,
|
||||
addToTrustedSendersBook,
|
||||
removeFromTrustedSendersBook,
|
||||
} = useContactStore();
|
||||
const { client } = useAuthStore();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [emailError, setEmailError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// When address book mode is on, load the book on first open
|
||||
useEffect(() => {
|
||||
if (isOpen && trustedSendersAddressBook && client && !trustedSendersLoaded) {
|
||||
loadTrustedSendersBook(client);
|
||||
}
|
||||
}, [isOpen, trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]);
|
||||
|
||||
// The active list depends on mode
|
||||
const activeSenders = trustedSendersAddressBook ? trustedSenderEmails : trustedSenders;
|
||||
const isLoading = trustedSendersAddressBook && (!trustedSendersLoaded || trustedSendersLoading);
|
||||
|
||||
// Filter senders based on search query
|
||||
const filteredSenders = useMemo(() => {
|
||||
if (!searchQuery.trim()) return trustedSenders;
|
||||
if (!searchQuery.trim()) return activeSenders;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return trustedSenders.filter((email) => email.toLowerCase().includes(query));
|
||||
}, [trustedSenders, searchQuery]);
|
||||
return activeSenders.filter((email) => email.toLowerCase().includes(query));
|
||||
}, [activeSenders, searchQuery]);
|
||||
|
||||
// Show search only when 5+ senders
|
||||
const showSearch = trustedSenders.length >= 5;
|
||||
const showSearch = activeSenders.length >= 5;
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
@@ -90,7 +113,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
const handleAddSender = () => {
|
||||
const handleAddSender = async () => {
|
||||
const trimmedEmail = newEmail.trim().toLowerCase();
|
||||
|
||||
if (!trimmedEmail) {
|
||||
@@ -103,15 +126,34 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
||||
return;
|
||||
}
|
||||
|
||||
if (trustedSenders.includes(trimmedEmail)) {
|
||||
if (activeSenders.includes(trimmedEmail)) {
|
||||
setEmailError(t("already_added"));
|
||||
return;
|
||||
}
|
||||
|
||||
addTrustedSender(trimmedEmail);
|
||||
setNewEmail("");
|
||||
setIsAdding(false);
|
||||
setEmailError("");
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (trustedSendersAddressBook && client) {
|
||||
await addToTrustedSendersBook(client, trimmedEmail);
|
||||
} else {
|
||||
addTrustedSender(trimmedEmail);
|
||||
}
|
||||
setNewEmail("");
|
||||
setIsAdding(false);
|
||||
setEmailError("");
|
||||
} catch {
|
||||
setEmailError(t("save_error"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSender = async (email: string) => {
|
||||
if (trustedSendersAddressBook && client) {
|
||||
await removeFromTrustedSendersBook(client, email);
|
||||
} else {
|
||||
removeTrustedSender(email);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
@@ -170,7 +212,11 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{trustedSenders.length === 0 ? (
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : activeSenders.length === 0 ? (
|
||||
/* Empty State */
|
||||
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
|
||||
<ShieldCheck className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
||||
@@ -209,7 +255,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
||||
{email}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeTrustedSender(email)}
|
||||
onClick={() => handleRemoveSender(email)}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
aria-label={`${t("remove")} ${email}`}
|
||||
>
|
||||
@@ -222,7 +268,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
||||
</div>
|
||||
|
||||
{/* Footer - Add sender */}
|
||||
{trustedSenders.length > 0 && (
|
||||
{!isLoading && activeSenders.length > 0 && (
|
||||
<div className="px-6 py-4 border-t border-border flex-shrink-0">
|
||||
{isAdding ? (
|
||||
<div className="space-y-2">
|
||||
@@ -244,9 +290,10 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddSender}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors text-sm font-medium"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t("add_button")}
|
||||
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : t("add_button")}
|
||||
</button>
|
||||
</div>
|
||||
{emailError && (
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { avatarHooks } from "@/lib/plugin-hooks";
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV !== "production";
|
||||
|
||||
@@ -143,10 +144,26 @@ interface AvatarProps {
|
||||
|
||||
export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
|
||||
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
|
||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
|
||||
const contacts = useContactStore((s) => s.contacts);
|
||||
const { devMode } = useConfig();
|
||||
|
||||
// Ask plugins (e.g. Gravatar) to resolve an avatar URL for this email address.
|
||||
// Runs whenever email or name changes; resets plugin avatar state on each change.
|
||||
useEffect(() => {
|
||||
setPluginAvatarUrl(null);
|
||||
setPluginAvatarFailed(false);
|
||||
if (!email || avatarHooks.onAvatarResolve.size === 0) return;
|
||||
let cancelled = false;
|
||||
avatarHooks.onAvatarResolve
|
||||
.transform(null as string | null, { email, name })
|
||||
.then((url) => { if (!cancelled) setPluginAvatarUrl(url); })
|
||||
.catch(() => { if (!cancelled) setPluginAvatarFailed(true); });
|
||||
return () => { cancelled = true; };
|
||||
}, [email, name]);
|
||||
|
||||
// Look up contact photo by email from the contact store
|
||||
const resolvedContactPhoto = useMemo(() => {
|
||||
if (contactPhotoUri) return contactPhotoUri;
|
||||
@@ -202,19 +219,25 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className }:
|
||||
const showFavicon =
|
||||
senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
|
||||
|
||||
// Priority: contact photo > custom avatar > profile picture > company favicon > initials
|
||||
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
|
||||
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
||||
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
|
||||
const imgSrc = !imgError && !domainFailed
|
||||
? resolvedContactPhoto || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
|
||||
: (resolvedContactPhoto || customAvatar || profilePic || null);
|
||||
? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
|
||||
: (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
|
||||
|
||||
const handleImgError = useCallback(() => {
|
||||
// If the plugin avatar just failed, mark it and fall through to the next source
|
||||
if (pluginAvatar && imgSrc === pluginAvatar) {
|
||||
setPluginAvatarFailed(true);
|
||||
return;
|
||||
}
|
||||
setImgError(true);
|
||||
// If this was a favicon URL (not a contact photo, custom avatar or profile pic), remember the domain
|
||||
if (faviconDomain && !resolvedContactPhoto && !customAvatar && !profilePic) {
|
||||
// If this was a favicon URL (not a contact photo, plugin avatar, custom avatar or profile pic), remember the domain
|
||||
if (faviconDomain && !resolvedContactPhoto && !pluginAvatar && !customAvatar && !profilePic) {
|
||||
failedFaviconDomains.add(faviconDomain);
|
||||
}
|
||||
}, [faviconDomain, resolvedContactPhoto, customAvatar, profilePic]);
|
||||
}, [imgSrc, pluginAvatar, faviconDomain, resolvedContactPhoto, customAvatar, profilePic]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -147,9 +147,38 @@ export function useBrowserNavigation({
|
||||
|
||||
if (!initializedRef.current) {
|
||||
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.
|
||||
window.history.replaceState(newState, "");
|
||||
|
||||
if (emailId || threadId) {
|
||||
// The app is initializing directly on an email/thread view (e.g. the
|
||||
// user navigated here from /settings or an external link). Seed a
|
||||
// "list" history entry first so that the toolbar back button returns
|
||||
// to the list instead of leaving the app entirely.
|
||||
const listSnapshot: NavSnapshot = {
|
||||
mailboxId,
|
||||
emailId: null,
|
||||
threadId: null,
|
||||
composerOpen: false,
|
||||
sidebarOpen,
|
||||
};
|
||||
const listStored: StoredNavState = {
|
||||
...listSnapshot,
|
||||
navId: ++navIdCounter,
|
||||
};
|
||||
const baseState = (window.history.state ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
window.history.replaceState(
|
||||
{ ...baseState, [STATE_KEY]: listStored },
|
||||
"",
|
||||
);
|
||||
// Now push the actual email state on top of the synthetic list entry.
|
||||
window.history.pushState(newState, "");
|
||||
} else {
|
||||
// Replace the current entry on the very first run so we don't
|
||||
// create an extra step the user has to back through to leave the app.
|
||||
window.history.replaceState(newState, "");
|
||||
}
|
||||
} else {
|
||||
window.history.pushState(newState, "");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
// Re-sync identities every 30 minutes while the app is open
|
||||
const SYNC_INTERVAL_MS = 30 * 60 * 1000;
|
||||
|
||||
export function useIdentitySync() {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const refreshIdentities = useAuthStore((s) => s.refreshIdentities);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
// Sync when the user returns to the tab (e.g. after adding an alias in Stalwart)
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
refreshIdentities();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
const interval = setInterval(refreshIdentities, SYNC_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [isAuthenticated, refreshIdentities]);
|
||||
}
|
||||
@@ -78,14 +78,7 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us
|
||||
const email = currentEmails.find(em => em.id === emailId);
|
||||
const keywords = { ...(email?.keywords || {}) };
|
||||
|
||||
// Remove old label/color keywords
|
||||
Object.keys(keywords).forEach(key => {
|
||||
if (key.startsWith("$label:") || key.startsWith("$color:")) {
|
||||
keywords[key] = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Add the new tag
|
||||
// Add the tag without removing existing ones
|
||||
keywords[`$label:${tagId}`] = true;
|
||||
|
||||
await client.updateEmailKeywords(emailId, keywords);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { unlink, writeFileSync } from "fs";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Mock NextResponse before importing the route
|
||||
@@ -24,6 +25,7 @@ describe('config API route', () => {
|
||||
delete process.env.OAUTH_CLIENT_ID;
|
||||
delete process.env.OAUTH_ISSUER_URL;
|
||||
delete process.env.SESSION_SECRET;
|
||||
delete process.env.SESSION_SECRET_FILE;
|
||||
delete process.env.SETTINGS_SYNC_ENABLED;
|
||||
delete process.env.STALWART_FEATURES;
|
||||
delete process.env.DEV_MOCK_JMAP;
|
||||
@@ -128,6 +130,19 @@ describe('config API route', () => {
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -138,6 +153,23 @@ describe('config API route', () => {
|
||||
|
||||
process.env.SESSION_SECRET = 'test-secret';
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types';
|
||||
import type { AdminSessionPayload } from './types';
|
||||
|
||||
@@ -11,7 +12,7 @@ const TAG_LENGTH = 16;
|
||||
const MIN_SECRET_LENGTH = 32;
|
||||
|
||||
function getKey(): Buffer {
|
||||
const secret = process.env.SESSION_SECRET;
|
||||
const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
|
||||
if (!secret) throw new Error('SESSION_SECRET not configured');
|
||||
if (secret.length < MIN_SECRET_LENGTH) {
|
||||
throw new Error(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
@@ -8,7 +9,7 @@ const TAG_LENGTH = 16;
|
||||
const MIN_SECRET_LENGTH = 32;
|
||||
|
||||
function getKey(): Buffer {
|
||||
const secret = process.env.SESSION_SECRET;
|
||||
const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
|
||||
if (!secret) throw new Error('SESSION_SECRET not configured');
|
||||
if (secret.length < MIN_SECRET_LENGTH) {
|
||||
throw new Error(
|
||||
|
||||
@@ -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 {
|
||||
return CATEGORY_KEYS.has(value);
|
||||
}
|
||||
|
||||
@@ -481,6 +481,12 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
async getAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
|
||||
async getAllAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
|
||||
|
||||
async createAddressBook(name: string): Promise<AddressBook> {
|
||||
const book: AddressBook = { id: `demo-book-${Date.now()}`, name };
|
||||
this.data.addressBooks.push(book);
|
||||
return book;
|
||||
}
|
||||
|
||||
async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>): Promise<void> {
|
||||
const book = this.data.addressBooks.find(b => b.id === addressBookId);
|
||||
if (book) Object.assign(book, updates);
|
||||
|
||||
@@ -178,6 +178,7 @@ export interface IJMAPClient {
|
||||
getContactsAccountId(): string;
|
||||
getAddressBooks(): Promise<AddressBook[]>;
|
||||
getAllAddressBooks(): Promise<AddressBook[]>;
|
||||
createAddressBook(name: string): Promise<AddressBook>;
|
||||
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
|
||||
getContacts(addressBookId?: string): Promise<ContactCard[]>;
|
||||
getAllContacts(): Promise<ContactCard[]>;
|
||||
|
||||
@@ -2868,6 +2868,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> {
|
||||
const accountId = targetAccountId || this.getContactsAccountId();
|
||||
// Only forward server-settable properties
|
||||
|
||||
@@ -203,12 +203,14 @@ export interface ContactCard {
|
||||
}
|
||||
|
||||
export interface ContactName {
|
||||
components: NameComponent[];
|
||||
components?: NameComponent[];
|
||||
isOrdered?: boolean;
|
||||
full?: string;
|
||||
defaultSeparator?: string;
|
||||
}
|
||||
|
||||
export interface NameComponent {
|
||||
kind: 'given' | 'surname' | 'prefix' | 'suffix' | 'additional' | 'separator' | 'credential';
|
||||
kind: 'given' | 'surname' | 'prefix' | 'suffix' | 'additional' | 'separator' | 'credential' | 'title' | 'middle' | 'given2' | 'surname2' | 'generation';
|
||||
value: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import type { OAuthMetadata } from '@/lib/oauth/discovery';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
|
||||
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || '';
|
||||
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
|
||||
|
||||
export function getRequiredConfig() {
|
||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
|
||||
sidebarAppHooks,
|
||||
sidebarAppHooks, avatarHooks,
|
||||
} from './plugin-hooks';
|
||||
import { toast as appToast } from '@/stores/toast-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
@@ -314,6 +314,8 @@ export interface PluginHooksAPI {
|
||||
onSidebarAppOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onSidebarAppClose: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// Avatar
|
||||
onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
}
|
||||
|
||||
// --- Permission mapping for hooks ----------------------------
|
||||
@@ -417,6 +419,8 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
||||
// Sidebar Apps
|
||||
onSidebarAppOpen: 'ui:observe', onSidebarAppClose: 'ui:observe',
|
||||
onSidebarAppChange: 'ui:observe',
|
||||
// Avatar
|
||||
onAvatarResolve: 'email:read',
|
||||
};
|
||||
|
||||
// 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)),
|
||||
// Sidebar Apps
|
||||
...Object.fromEntries(Object.entries(sidebarAppHooks)),
|
||||
// Avatar
|
||||
...Object.fromEntries(Object.entries(avatarHooks)),
|
||||
};
|
||||
|
||||
// --- Slot registration bridge --------------------------------
|
||||
|
||||
@@ -399,6 +399,13 @@ export const sidebarAppHooks = {
|
||||
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 ───
|
||||
|
||||
const allHookGroups = [
|
||||
@@ -407,6 +414,7 @@ const allHookGroups = [
|
||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
|
||||
avatarHooks,
|
||||
];
|
||||
|
||||
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 path from 'node:path';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
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');
|
||||
return createHash('sha256').update(secret).digest();
|
||||
}
|
||||
|
||||
@@ -152,21 +152,32 @@ export const KEYWORD_PREFIX = "$label:";
|
||||
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.
|
||||
*/
|
||||
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
|
||||
if (!keywords) return null;
|
||||
|
||||
export function getEmailColorTags(keywords: Record<string, boolean> | undefined): string[] {
|
||||
if (!keywords) return [];
|
||||
const tags: string[] = [];
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
|
||||
return key.startsWith(KEYWORD_PREFIX)
|
||||
? key.slice(KEYWORD_PREFIX.length)
|
||||
: key.slice(KEYWORD_PREFIX_LEGACY.length);
|
||||
tags.push(
|
||||
key.startsWith(KEYWORD_PREFIX)
|
||||
? 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;
|
||||
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 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) {
|
||||
lines.push(`FN:${encodeValue(fn)}`);
|
||||
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?",
|
||||
"save_draft": "Entwurf speichern",
|
||||
"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": "Bestätigen",
|
||||
@@ -914,6 +920,15 @@
|
||||
"button": "Als Standard festlegen",
|
||||
"success": "Browser wurde aufgefordert, als Standard festzulegen",
|
||||
"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": {
|
||||
|
||||
@@ -494,7 +494,13 @@
|
||||
"smime_unlock_title": "Unlock S/MIME Key",
|
||||
"smime_unlock_message": "Enter the passphrase to unlock your S/MIME signing key.",
|
||||
"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": "Confirm",
|
||||
@@ -887,7 +893,10 @@
|
||||
"remove": "Remove",
|
||||
"close": "Close",
|
||||
"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": {
|
||||
"label": "Quick Hover Actions",
|
||||
@@ -914,6 +923,15 @@
|
||||
"button": "Set as Default",
|
||||
"success": "Browser prompted to set as default",
|
||||
"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": {
|
||||
@@ -1182,7 +1200,9 @@
|
||||
"email": "Email Viewing",
|
||||
"email_description": "Email rendering, TNEF processing, and mark-as-read",
|
||||
"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": {
|
||||
"label": "Settings Sync",
|
||||
|
||||
@@ -494,7 +494,13 @@
|
||||
"close_draft_message": "Tiene cambios sin guardar. ¿Desea guardar esto como borrador o descartarlo?",
|
||||
"save_draft": "Guardar borrador",
|
||||
"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": "Confirmar",
|
||||
@@ -914,6 +920,15 @@
|
||||
"button": "Establecer como predeterminado",
|
||||
"success": "El navegador solicitó establecer como predeterminado",
|
||||
"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": {
|
||||
|
||||
@@ -494,7 +494,13 @@
|
||||
"close_draft_message": "Vous avez des modifications non enregistrées. Voulez-vous enregistrer comme brouillon ou supprimer ?",
|
||||
"save_draft": "Enregistrer le brouillon",
|
||||
"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": "Confirmer",
|
||||
@@ -914,6 +920,15 @@
|
||||
"button": "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é"
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -494,7 +494,13 @@
|
||||
"close_draft_message": "Hai modifiche non salvate. Vuoi salvare come bozza o eliminare?",
|
||||
"save_draft": "Salva bozza",
|
||||
"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": "Conferma",
|
||||
@@ -914,6 +920,15 @@
|
||||
"button": "Imposta come predefinito",
|
||||
"success": "Il browser ha chiesto di impostare come predefinito",
|
||||
"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": {
|
||||
|
||||
@@ -494,7 +494,13 @@
|
||||
"close_draft_message": "未保存の変更があります。下書きとして保存しますか、それとも破棄しますか?",
|
||||
"save_draft": "下書きを保存",
|
||||
"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": "確認",
|
||||
@@ -914,6 +920,15 @@
|
||||
"button": "既定に設定",
|
||||
"success": "ブラウザに既定として設定するよう要求しました",
|
||||
"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": {
|
||||
|
||||
@@ -494,7 +494,13 @@
|
||||
"smime_unlock_title": "S/MIME 키 잠금 해제",
|
||||
"smime_unlock_message": "S/MIME 서명 키의 잠금을 해제하려면 비밀번호를 입력해 주세요.",
|
||||
"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": "확인",
|
||||
@@ -914,6 +920,15 @@
|
||||
"button": "기본값으로 설정",
|
||||
"success": "브라우저에서 기본 설정 팝업이 뜰 거예요",
|
||||
"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": {
|
||||
|
||||
@@ -493,7 +493,13 @@
|
||||
"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_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": "Apstiprināt",
|
||||
@@ -913,6 +919,15 @@
|
||||
"button": "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"
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -494,7 +494,13 @@
|
||||
"close_draft_message": "U heeft niet-opgeslagen wijzigingen. Wilt u dit als concept opslaan of verwijderen?",
|
||||
"save_draft": "Concept opslaan",
|
||||
"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": "Bevestigen",
|
||||
@@ -914,6 +920,15 @@
|
||||
"button": "Instellen als standaard",
|
||||
"success": "Browser gevraagd om als standaard in te stellen",
|
||||
"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": {
|
||||
|
||||
@@ -494,7 +494,13 @@
|
||||
"smime_unlock_title": "Odblokuj klucz S/MIME",
|
||||
"smime_unlock_message": "Wprowadź hasło, aby odblokować klucz podpisywania S/MIME.",
|
||||
"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": "Potwierdź",
|
||||
@@ -916,6 +922,15 @@
|
||||
"button": "Ustaw jako domyślny",
|
||||
"success": "Przeglądarka poprosiła o ustawienie jako domyślnego",
|
||||
"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": {
|
||||
|
||||
@@ -494,7 +494,13 @@
|
||||
"close_draft_message": "Você tem alterações não salvas. Deseja salvar como rascunho ou descartar?",
|
||||
"save_draft": "Salvar rascunho",
|
||||
"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": "Confirmar",
|
||||
@@ -914,6 +920,15 @@
|
||||
"button": "Definir como padrão",
|
||||
"success": "O navegador solicitou definir como padrão",
|
||||
"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": {
|
||||
|
||||
@@ -494,7 +494,13 @@
|
||||
"smime_unlock_title": "Разблокировать ключ S/MIME",
|
||||
"smime_unlock_message": "Введите парольную фразу для разблокировки вашего ключа подписи S/MIME.",
|
||||
"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": "Подтвердить",
|
||||
@@ -914,6 +920,15 @@
|
||||
"button": "Установить по умолчанию",
|
||||
"success": "Браузер запрошен для установки по умолчанию",
|
||||
"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": {
|
||||
|
||||
@@ -494,7 +494,13 @@
|
||||
"smime_unlock_title": "解锁 S/MIME 密钥",
|
||||
"smime_unlock_message": "输入密码以解锁您的 S/MIME 签名密钥。",
|
||||
"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": "确认",
|
||||
@@ -914,6 +920,15 @@
|
||||
"button": "设为默认",
|
||||
"success": "浏览器已提示设置为默认",
|
||||
"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": {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.4.10",
|
||||
"version": "1.4.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.4.10",
|
||||
"version": "1.4.13",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.4.11",
|
||||
"version": "1.4.13",
|
||||
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
|
||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192x192.png",
|
||||
"src": "/icon-maskable-light-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
"purpose": "maskable",
|
||||
"media": "(prefers-color-scheme: light)"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512x512.png",
|
||||
"src": "/icon-maskable-light-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"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"],
|
||||
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -1,26 +1,16 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
// Self-destructing service worker.
|
||||
//
|
||||
// The previous version of this file used a cache-first strategy with no
|
||||
// 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.
|
||||
// Minimal service worker – satisfies the PWA installability requirement
|
||||
// without caching any assets. All requests fall through to the network,
|
||||
// so there is no risk of serving stale chunks after a deployment.
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
(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));
|
||||
})(),
|
||||
);
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
// Network-only fetch handler – no caching.
|
||||
self.addEventListener("fetch", () => {});
|
||||
|
||||
@@ -47,6 +47,7 @@ interface AuthState {
|
||||
checkAuth: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
syncIdentities: () => void;
|
||||
refreshIdentities: () => Promise<void>;
|
||||
getClientForAccount: (accountId: string) => JMAPClient | undefined;
|
||||
}
|
||||
|
||||
@@ -1513,6 +1514,18 @@ export const useAuthStore = create<AuthState>()(
|
||||
set({ identities, primaryIdentity });
|
||||
},
|
||||
|
||||
refreshIdentities: async () => {
|
||||
const { client, username } = get();
|
||||
if (!client || !username) return;
|
||||
try {
|
||||
const rawIdentities = await client.getIdentities();
|
||||
const { identities, primaryIdentity } = loadIdentities(rawIdentities, username);
|
||||
set({ identities, primaryIdentity });
|
||||
} catch {
|
||||
// Silently fail — background sync should not surface errors to the user
|
||||
}
|
||||
},
|
||||
|
||||
getClientForAccount: (accountId: string) => {
|
||||
return clients.get(accountId);
|
||||
},
|
||||
|
||||
@@ -3,18 +3,28 @@ import { persist } from 'zustand/middleware';
|
||||
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { debug } from '@/lib/debug';
|
||||
|
||||
export function getContactDisplayName(contact: ContactCard): string {
|
||||
if (contact.name?.components) {
|
||||
const given = contact.name.components.find(c => c.kind === 'given')?.value || '';
|
||||
const surname = contact.name.components.find(c => c.kind === 'surname')?.value || '';
|
||||
const full = [given, surname].filter(Boolean).join(' ');
|
||||
if (full) return full;
|
||||
if (contact.name) {
|
||||
// Try given + surname from components first
|
||||
if (contact.name.components && contact.name.components.length > 0) {
|
||||
const given = contact.name.components.find(c => c.kind === 'given')?.value || '';
|
||||
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) {
|
||||
const nick = Object.values(contact.nicknames)[0];
|
||||
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) {
|
||||
const email = Object.values(contact.emails)[0];
|
||||
if (email?.address) return email.address;
|
||||
@@ -35,6 +45,8 @@ export function getContactPhotoUri(contact: ContactCard): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const TRUSTED_SENDERS_BOOK_NAME = 'Trusted Senders';
|
||||
|
||||
interface ContactStore {
|
||||
contacts: ContactCard[];
|
||||
addressBooks: AddressBook[];
|
||||
@@ -44,6 +56,12 @@ interface ContactStore {
|
||||
error: string | null;
|
||||
supportsSync: boolean;
|
||||
|
||||
// Trusted senders address book cache (runtime only, not persisted)
|
||||
trustedSenderEmails: string[];
|
||||
trustedSendersBookId: string | null;
|
||||
trustedSendersLoaded: boolean;
|
||||
trustedSendersLoading: boolean;
|
||||
|
||||
selectedContactIds: Set<string>;
|
||||
lastSelectedContactId: string | null;
|
||||
activeTab: 'all' | 'groups';
|
||||
@@ -86,6 +104,12 @@ interface ContactStore {
|
||||
renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise<void>;
|
||||
|
||||
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>()(
|
||||
@@ -130,6 +154,10 @@ export const useContactStore = create<ContactStore>()(
|
||||
isLoading: false,
|
||||
error: null,
|
||||
supportsSync: false,
|
||||
trustedSenderEmails: [],
|
||||
trustedSendersBookId: null,
|
||||
trustedSendersLoaded: false,
|
||||
trustedSendersLoading: false,
|
||||
selectedContactIds: new Set<string>(),
|
||||
lastSelectedContactId: null,
|
||||
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) => {
|
||||
const { supportsSync } = get();
|
||||
let imported = 0;
|
||||
|
||||
@@ -312,7 +312,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const { selectedKeyword } = get();
|
||||
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({
|
||||
emails: result.emails,
|
||||
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)
|
||||
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
|
||||
@@ -1242,7 +1245,18 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// Get emails per page from settings
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
|
||||
const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
|
||||
// Respect active search filters / query so that a push-triggered refresh
|
||||
// does not silently replace a filtered list with an unfiltered one.
|
||||
const { searchQuery, searchFilters } = get();
|
||||
const hasFilters = !isFilterEmpty(searchFilters);
|
||||
|
||||
let result;
|
||||
if (hasFilters || searchQuery) {
|
||||
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
|
||||
result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, 0);
|
||||
} else {
|
||||
result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
|
||||
}
|
||||
|
||||
const currentEmails = get().emails;
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export const ALL_HOVER_ACTIONS: { id: HoverAction; labelKey: string }[] = [
|
||||
{ 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 }[] = [
|
||||
{ id: 'jmap', labelKey: 'jmap' },
|
||||
@@ -59,6 +59,7 @@ export const ALL_DEBUG_CATEGORIES: { id: DebugCategory; labelKey: string }[] = [
|
||||
{ id: 'filters', labelKey: 'filters' },
|
||||
{ id: 'email', labelKey: 'email' },
|
||||
{ id: 'push', labelKey: 'push' },
|
||||
{ id: 'contacts', labelKey: 'contacts' },
|
||||
];
|
||||
|
||||
export interface KeywordDefinition {
|
||||
@@ -140,6 +141,7 @@ interface SettingsState {
|
||||
// Privacy & Security
|
||||
sessionTimeout: number; // minutes (0 = never)
|
||||
trustedSenders: string[]; // Email addresses that can load external content
|
||||
trustedSendersAddressBook: boolean; // Store trusted senders in a dedicated JMAP address book
|
||||
|
||||
// Filters
|
||||
expandedFilterView: boolean;
|
||||
@@ -185,6 +187,10 @@ interface SettingsState {
|
||||
// Keywords (labels/tags)
|
||||
emailKeywords: KeywordDefinition[];
|
||||
|
||||
// Attachment Reminder
|
||||
attachmentReminderEnabled: boolean;
|
||||
attachmentReminderKeywords: string[];
|
||||
|
||||
// Sidebar Apps
|
||||
sidebarApps: SidebarApp[];
|
||||
keepAppsLoaded: boolean;
|
||||
@@ -269,6 +275,7 @@ const DEFAULT_SETTINGS = {
|
||||
// Privacy & Security
|
||||
sessionTimeout: 0, // Never
|
||||
trustedSenders: [] as string[],
|
||||
trustedSendersAddressBook: false,
|
||||
|
||||
// Filters
|
||||
expandedFilterView: false,
|
||||
@@ -314,6 +321,37 @@ const DEFAULT_SETTINGS = {
|
||||
// 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
|
||||
sidebarApps: [] as SidebarApp[],
|
||||
keepAppsLoaded: false,
|
||||
@@ -412,6 +450,8 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
senderFavicons: state.senderFavicons,
|
||||
folderIcons: state.folderIcons,
|
||||
emailKeywords: state.emailKeywords,
|
||||
attachmentReminderEnabled: state.attachmentReminderEnabled,
|
||||
attachmentReminderKeywords: state.attachmentReminderKeywords,
|
||||
sidebarApps: state.sidebarApps,
|
||||
keepAppsLoaded: state.keepAppsLoaded,
|
||||
debugMode: state.debugMode,
|
||||
|
||||