From f42f57b8d09ee1323657fe72c08e9d8014cf9d6d Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Thu, 9 Apr 2026 19:10:34 +0200 Subject: [PATCH 01/30] fix: preserve search filters on push-triggered mailbox refresh #164 --- stores/email-store.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/stores/email-store.ts b/stores/email-store.ts index db01b5be..42630eac 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -1241,7 +1241,18 @@ export const useEmailStore = create((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; From d0cc439fd1d026f65e1a011cc781aca04c35f383 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Thu, 9 Apr 2026 19:39:04 +0200 Subject: [PATCH 02/30] feat: add automatic identity synchronization (#167) --- app/[locale]/page.tsx | 2 ++ .../identity/identity-manager-modal.tsx | 13 ++++++++ hooks/use-identity-sync.ts | 31 +++++++++++++++++++ stores/auth-store.ts | 13 ++++++++ 4 files changed, 59 insertions(+) create mode 100644 hooks/use-identity-sync.ts diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index c174de69..0e9a6ee3 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -39,6 +39,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"; @@ -77,6 +78,7 @@ export default function Home() { const markAsReadTimeoutRef = useRef(null); const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); const { identities } = useIdentityStore(); + useIdentitySync(); useEffect(() => { if (!isRateLimited || !rateLimitUntil) { diff --git a/components/identity/identity-manager-modal.tsx b/components/identity/identity-manager-modal.tsx index cb0d960f..ea6a916a 100644 --- a/components/identity/identity-manager-modal.tsx +++ b/components/identity/identity-manager-modal.tsx @@ -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(null); const [isCreating, setIsCreating] = useState(false); const [deletingId, setDeletingId] = useState(null); diff --git a/hooks/use-identity-sync.ts b/hooks/use-identity-sync.ts new file mode 100644 index 00000000..72bc5c37 --- /dev/null +++ b/hooks/use-identity-sync.ts @@ -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]); +} diff --git a/stores/auth-store.ts b/stores/auth-store.ts index aa421232..3d141452 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -47,6 +47,7 @@ interface AuthState { checkAuth: () => Promise; clearError: () => void; syncIdentities: () => void; + refreshIdentities: () => Promise; getClientForAccount: (accountId: string) => JMAPClient | undefined; } @@ -1513,6 +1514,18 @@ export const useAuthStore = create()( 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); }, From f19aaf6207a46a961d3df51ddd23e63e16808f7a Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Thu, 9 Apr 2026 19:50:42 +0200 Subject: [PATCH 03/30] fix: embed dropped images as data URLs and prevent duplicate attachment #163 --- components/email/email-composer.tsx | 23 ++++++++++++----------- components/email/rich-text-editor.tsx | 1 + 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index c98bfd77..06a49912 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -532,17 +532,18 @@ export function EmailComposer({ } }, [client, t]); - const handleImageUpload = useCallback(async (file: File): Promise => { - 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 => { + 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) => { if (!event.target.files) return; diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index 144286bf..a6c966f3 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -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) { From ae793551ba9625ce23984daddbb2bd42dad5318a Mon Sep 17 00:00:00 2001 From: Davi Maciel Date: Thu, 9 Apr 2026 10:12:47 -0300 Subject: [PATCH 04/30] fix(email-list): show recipient in Sent and Drafts folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When viewing the Sent or Drafts mailbox, the list items always displayed the sender (email.from[0]) — which is always the logged-in user — instead of the recipient. This makes it impossible to identify messages by who they were sent to. This change detects the current mailbox role from the store and swaps the displayed person to email.to[0] when the role is 'sent' or 'drafts'. For multi-email threads, participant names are computed from the collected recipients of the thread's emails. Affected components: - EmailListItem (flat list, threading disabled) - SingleEmailItem (single-email thread) - ThreadListItem (multi-email thread header + avatar) This matches the behaviour of Gmail, Outlook, Apple Mail, and every other mainstream mail client. --- components/email/email-list-item.tsx | 7 +++++-- components/email/thread-list-item.tsx | 26 +++++++++++++++++++------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 4019787f..5bb16350 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -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,7 +44,10 @@ 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}` : ''; diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 3cb62b9f..f0153957 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -55,8 +55,11 @@ const SingleEmailItem = React.forwardRef( 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); @@ -339,7 +342,16 @@ export const ThreadListItem = React.forwardRef 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 +534,8 @@ export const ThreadListItem = React.forwardRef @@ -537,7 +549,7 @@ export const ThreadListItem = React.forwardRef - {participantNames.join(', ')} + {displayNames.join(', ')} - {participantNames.join(", ")} + {displayNames.join(", ")} Date: Thu, 9 Apr 2026 20:04:06 +0200 Subject: [PATCH 05/30] chore: update version to 1.4.12 --- CHANGELOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 +- VERSION | 2 +- package.json | 2 +- 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d3da37..dcc8b628 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## 1.4.12 (2026-04-09) + +### 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 diff --git a/README.md b/README.md index 1ad8cd9a..3778ab25 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol. [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) -[![Version](https://img.shields.io/badge/version-1.4.11-green.svg?logo=git&logoColor=white)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.4.12-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) diff --git a/VERSION b/VERSION index 079d7f69..f86e0298 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.11 +1.4.12 diff --git a/package.json b/package.json index d51bf1bb..99a09683 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bulwark-webmail", - "version": "1.4.11", + "version": "1.4.12", "description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server", "author": "Bulwark Webmail ", "license": "AGPL-3.0-only", From 9c5daa39184851a1e1a0991fc157a07d3765e2da Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Thu, 9 Apr 2026 20:08:03 +0200 Subject: [PATCH 06/30] chore: update version to 1.4.12 --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcc8b628..7a261a35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## 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 From f9052eb23f81b0f12a417ff77f7ff545745e6f1d Mon Sep 17 00:00:00 2001 From: Pascal Dietrich Date: Tue, 7 Apr 2026 19:56:27 +0200 Subject: [PATCH 07/30] feat: add SESSION_SECRET_FILE and OAUTH_CLIENT_SECRET_FILE env vars --- app/api/auth/sso/start/route.ts | 3 ++- app/api/auth/totp-token-exchange/route.ts | 3 ++- app/api/config/route.ts | 5 ++-- app/api/settings/route.ts | 3 ++- lib/__tests__/config-route.test.ts | 32 +++++++++++++++++++++++ lib/admin/session.ts | 3 ++- lib/auth/crypto.ts | 3 ++- lib/oauth/token-exchange.ts | 3 ++- lib/read-file-env.ts | 13 +++++++++ lib/settings-sync.ts | 3 ++- 10 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 lib/read-file-env.ts diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts index 07240ebe..1c0b1bdd 100644 --- a/app/api/auth/sso/start/route.ts +++ b/app/api/auth/sso/start/route.ts @@ -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 }); } diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index 7ea840e4..b16e6631 100644 --- a/app/api/auth/totp-token-exchange/route.ts +++ b/app/api/auth/totp-token-exchange/route.ts @@ -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 }> = []; diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 0b3f3bb8..bd930719 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -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('oauthClientId', ''), oauthIssuerUrl: configManager.get('oauthIssuerUrl', ''), - rememberMeEnabled: !!process.env.SESSION_SECRET, - settingsSyncEnabled: configManager.get('settingsSyncEnabled', false) && !!process.env.SESSION_SECRET, + rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE), + settingsSyncEnabled: configManager.get('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)), stalwartFeaturesEnabled, devMode: configManager.get('devMode', false), faviconUrl: configManager.get('faviconUrl', '/branding/Bulwark_Favicon.svg'), diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 72a34ca4..5636e439 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -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. */ diff --git a/lib/__tests__/config-route.test.ts b/lib/__tests__/config-route.test.ts index 3450cbf0..414ae7ae 100644 --- a/lib/__tests__/config-route.test.ts +++ b/lib/__tests__/config-route.test.ts @@ -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); }); diff --git a/lib/admin/session.ts b/lib/admin/session.ts index 3d91369f..ecde855e 100644 --- a/lib/admin/session.ts +++ b/lib/admin/session.ts @@ -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( diff --git a/lib/auth/crypto.ts b/lib/auth/crypto.ts index ca0506c9..670bcc68 100644 --- a/lib/auth/crypto.ts +++ b/lib/auth/crypto.ts @@ -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( diff --git a/lib/oauth/token-exchange.ts b/lib/oauth/token-exchange.ts index c1cbb443..abeb2348 100644 --- a/lib/oauth/token-exchange.ts +++ b/lib/oauth/token-exchange.ts @@ -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; diff --git a/lib/read-file-env.ts b/lib/read-file-env.ts new file mode 100644 index 00000000..72a8cc8c --- /dev/null +++ b/lib/read-file-env.ts @@ -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; + } +} diff --git a/lib/settings-sync.ts b/lib/settings-sync.ts index 47404e8f..297baa29 100644 --- a/lib/settings-sync.ts +++ b/lib/settings-sync.ts @@ -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(); } From 60efb047d8133c6951ffa6f4747a8dba7a6c5aa8 Mon Sep 17 00:00:00 2001 From: Pascal Dietrich Date: Tue, 7 Apr 2026 20:14:09 +0200 Subject: [PATCH 08/30] docs: add SESSION_SECRET_FILE and OAUTH_CLIENT_SECRET_FILE env vars --- .env.example | 4 ++++ README.md | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 777871b6..9e714ad5 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 3778ab25..ac4b2966 100644 --- a/README.md +++ b/README.md @@ -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 `. Remember Me ```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). From 790d7084af5990d09c554f9c4c7caae3f2d1ee0a Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Thu, 9 Apr 2026 22:19:56 +0200 Subject: [PATCH 09/30] fix: restore service worker and add manifest icons --- public/icon-192x192.png | Bin 0 -> 4276 bytes public/icon-512x512.png | Bin 0 -> 15365 bytes public/screenshot-1280x720.png | Bin 0 -> 14573 bytes public/screenshot-540x720.png | Bin 0 -> 8675 bytes public/sw.js | 24 +++++++----------------- 5 files changed, 7 insertions(+), 17 deletions(-) create mode 100644 public/icon-192x192.png create mode 100644 public/icon-512x512.png create mode 100644 public/screenshot-1280x720.png create mode 100644 public/screenshot-540x720.png diff --git a/public/icon-192x192.png b/public/icon-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..f5a709ad8b70151380273af976392948e19c119c GIT binary patch literal 4276 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 W%^K^KA^JH00000>*iZ(2kIltV1e0m0g&5SBPv` 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 literal 0 HcmV?d00001 diff --git a/public/screenshot-540x720.png b/public/screenshot-540x720.png new file mode 100644 index 0000000000000000000000000000000000000000..35ebaa39bbe0658ff2ae10ebe1952e42dfab5e70 GIT binary patch literal 8675 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 HrI`N#4&T({ literal 0 HcmV?d00001 diff --git a/public/sw.js b/public/sw.js index db44454a..4110d0c8 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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", () => {}); From 89d580b90d3e13e13a974209e4c0ffbab831e7c0 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Fri, 10 Apr 2026 14:20:48 +0200 Subject: [PATCH 10/30] feat: add don't remind me again option to install prompt for PWA --- components/pwa-install-prompt.tsx | 37 ++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/components/pwa-install-prompt.tsx b/components/pwa-install-prompt.tsx index 727d69c8..c5ac68c9 100644 --- a/components/pwa-install-prompt.tsx +++ b/components/pwa-install-prompt.tsx @@ -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(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() { -
+
+
+ + +
-
From facef97fccd93ebc6383884bd955de4f073931a6 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Fri, 10 Apr 2026 14:33:18 +0200 Subject: [PATCH 11/30] feat: publish main and dev branches as separate GHCR packages --- .github/workflows/docker-publish.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 3e17dbed..1af541b6 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -22,7 +22,7 @@ on: workflow_dispatch: env: - IMAGE_NAME: ghcr.io/${{ github.repository }} + IMAGE_NAME: ghcr.io/${{ github.repository }}-${{ github.ref_name }} jobs: build: @@ -115,8 +115,8 @@ jobs: with: images: ${{ env.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 From 3e85e073635015df521b406c281cb55fa97e3c1d Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Fri, 10 Apr 2026 14:46:04 +0200 Subject: [PATCH 12/30] feat: separate branch containers into beta and dev packages --- .github/workflows/docker-publish.yml | 30 +++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 1af541b6..43114ea9 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -21,11 +21,23 @@ on: - ".github/workflows/docker-publish.yml" workflow_dispatch: -env: - IMAGE_NAME: ghcr.io/${{ github.repository }}-${{ github.ref_name }} - 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,7 +125,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.IMAGE_NAME }} + images: ${{ needs.prepare.outputs.image_name }} tags: | type=raw,value=latest type=sha @@ -122,8 +134,8 @@ jobs: 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 }} From c24762c7a3b62d07cf85f288efbad3d340495b49 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Fri, 10 Apr 2026 14:46:04 +0200 Subject: [PATCH 13/30] feat: separate branch containers into beta and dev packages --- .github/workflows/docker-publish.yml | 30 +++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 1af541b6..43114ea9 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -21,11 +21,23 @@ on: - ".github/workflows/docker-publish.yml" workflow_dispatch: -env: - IMAGE_NAME: ghcr.io/${{ github.repository }}-${{ github.ref_name }} - 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,7 +125,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.IMAGE_NAME }} + images: ${{ needs.prepare.outputs.image_name }} tags: | type=raw,value=latest type=sha @@ -122,8 +134,8 @@ jobs: 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 }} From 4f54f768e83bb2b4efd80d6c92c0a8af9f45b6ea Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Fri, 10 Apr 2026 17:31:13 +0200 Subject: [PATCH 14/30] Feat: enable keyword reordering #174 and multi-tag support per email #173 --- app/[locale]/page.tsx | 24 ++-- components/email/email-context-menu.tsx | 56 ++++---- components/email/email-list-item.tsx | 26 ++-- components/email/email-viewer.tsx | 172 +++++++++++++---------- components/email/thread-list-item.tsx | 29 ++-- components/settings/keyword-settings.tsx | 65 ++++++++- hooks/use-tag-drop.ts | 9 +- lib/thread-utils.ts | 27 ++-- 8 files changed, 255 insertions(+), 153 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 0e9a6ee3..7f57ae21 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -830,16 +830,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 diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 1e5e93d2..983fd526 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -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 | undefined) => { - if (!keywords) return null; +// Get all active label/color tag IDs from email keywords +const getCurrentColors = (keywords: Record | 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 && ( - {colorOptions.map((option) => ( - - ))} - {currentColor && ( + {colorOptions.map((option) => { + const isActive = currentColors.includes(option.value); + return ( + + ); + })} + {currentColors.length > 0 && ( <> 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 @@ -199,7 +201,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl )} {email.hasAttachment && } - {keywordDef && } + {keywordDefs.map((kd) => ( + + ))}
- {keywordDef && ( - ( + - - {keywordDef.label} + + {kd.label} - )} + ))} | undefined) => { - if (!keywords) return null; +const getCurrentColors = (keywords: Record | 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 @@ -933,7 +934,8 @@ export function EmailViewer({ const moveMenuRef = useRef(null); const toolbarRef = useRef(null); const [hiddenPriorities, setHiddenPriorities] = useState>(new Set()); - const currentColor = getCurrentColor(email?.keywords); + const currentColors = getCurrentColors(email?.keywords); + const currentColor = currentColors[0] ?? null; // S/MIME state const [smimeStatus, setSmimeStatus] = useState(null); @@ -3055,43 +3057,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 ? ( - <> - - {showToolbarLabels && {kw!.label}} - - ) : ( - <> - - {showToolbarLabels && {t('tag')}} - - ); - })()} + {currentColors.length > 0 ? ( + <> + + {currentColors.slice(0, 3).map((tagId) => { + const kw = emailKeywords.find(k => k.id === tagId); + return kw ? : null; + })} + + {showToolbarLabels && currentColors.length === 1 && ( + + {emailKeywords.find(k => k.id === currentColors[0])?.label} + + )} + + ) : ( + <> + + {showToolbarLabels && {t('tag')}} + + )} {tagMenuOpen && (
- {colorOptions.map((option) => ( - - ))} - {currentColor && ( + {colorOptions.map((option) => { + const isActive = currentColors.includes(option.value); + return ( + + ); + })} + {currentColors.length > 0 && ( <>
- ))} - {currentColor && ( + {colorOptions.map((option) => { + const isActive = currentColors.includes(option.value); + return ( + + ); + })} + {currentColors.length > 0 && ( <>
- ))} - {currentColor && ( + {colorOptions.map((option) => { + const isActive = currentColors.includes(option.value); + return ( + + ); + })} + {currentColors.length > 0 && ( )} - {/* Color tag dot */} - {currentColor && (() => { - const kw = emailKeywords.find(k => k.id === currentColor); - const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; - return dotClass ? ( - - ) : null; - })()} + {/* Color tag dots */} + {currentColors.length > 0 && ( + + {currentColors.map((tagId) => { + const kw = emailKeywords.find(k => k.id === tagId); + const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; + return dotClass ? ( + + ) : null; + })} + + )} {isImportant && ( {t('important')} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index f0153957..9ee8e705 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -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"; @@ -67,9 +67,10 @@ const SingleEmailItem = React.forwardRef( 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; @@ -212,7 +213,9 @@ const SingleEmailItem = React.forwardRef( )} {email.hasAttachment && } - {resolvedKeywordDef && } + {resolvedKeywordDefs.map((kd) => ( + + ))} (
- {resolvedKeywordDef && ( - ( + - - {resolvedKeywordDef.label} + + {kd.label} - )} + ))} )} {hasAttachment && } - {keywordDef && } + {keywordDef && ( + + )} 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 ( -
+
{keyword.label} @@ -166,9 +189,39 @@ export function KeywordSettings() { const [editingId, setEditingId] = useState(null); const [isAdding, setIsAdding] = useState(false); const [isMigrating, setIsMigrating] = useState(false); + const [dragIndex, setDragIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(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")}
)} - {emailKeywords.map((keyword) => + {emailKeywords.map((keyword, index) => editingId === keyword.id ? ( handleDelete(keyword.id)} + onDragStart={() => handleDragStart(index)} + onDragOver={(e) => handleDragOver(e, index)} + onDrop={() => handleDrop(index)} + onDragEnd={handleDragEnd} + isDragOver={dragOverIndex === index && dragIndex !== index} + isDragging={dragIndex === index} /> ) )} diff --git a/hooks/use-tag-drop.ts b/hooks/use-tag-drop.ts index c90c68c1..61e7a391 100644 --- a/hooks/use-tag-drop.ts +++ b/hooks/use-tag-drop.ts @@ -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); diff --git a/lib/thread-utils.ts b/lib/thread-utils.ts index 68b53f10..65dfbae0 100644 --- a/lib/thread-utils.ts +++ b/lib/thread-utils.ts @@ -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 | undefined): string | null { - if (!keywords) return null; - +export function getEmailColorTags(keywords: Record | 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 | undefined): string | null { + const tags = getEmailColorTags(keywords); + return tags.length > 0 ? tags[0] : null; } /** From 5f150f039d1b6711d130149895a397829aba341c Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Fri, 10 Apr 2026 18:24:52 +0200 Subject: [PATCH 15/30] feat: warn on send when attachment keyword found but no file attached #172 --- components/email/email-composer.tsx | 61 +++++++++++++++++++++++-- components/settings/email-settings.tsx | 63 +++++++++++++++++++++++++- locales/de/common.json | 17 ++++++- locales/en/common.json | 17 ++++++- locales/es/common.json | 17 ++++++- locales/fr/common.json | 17 ++++++- locales/it/common.json | 17 ++++++- locales/ja/common.json | 17 ++++++- locales/ko/common.json | 17 ++++++- locales/lv/common.json | 17 ++++++- locales/nl/common.json | 17 ++++++- locales/pl/common.json | 17 ++++++- locales/pt/common.json | 17 ++++++- locales/ru/common.json | 17 ++++++- locales/zh/common.json | 17 ++++++- stores/settings-store.ts | 37 +++++++++++++++ 16 files changed, 365 insertions(+), 17 deletions(-) diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 06a49912..ecbc1c87 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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; @@ -723,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); @@ -742,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); @@ -1002,7 +1027,7 @@ export function EmailComposer({
{/* Mobile: send button in header */}
)} + {showAttachmentWarning && ( +
setShowAttachmentWarning(false)} + > +
e.stopPropagation()} + className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200" + > +
+

{t('forgot_attachment.title')}

+

+ {t('forgot_attachment.message', { keyword: attachmentWarningKeyword })} +

+
+
+ + +
+
+
+ )} + {showCloseDialog && (
+ {/* Attachment Reminder */} + + updateSetting('attachmentReminderEnabled', checked)} + /> + + {attachmentReminderEnabled && ( +
+
+ +

{t('attachment_reminder.keywords_description')}

+
+
+ {attachmentReminderKeywords.map((kw) => ( + + {kw} + + + ))} +
+
{ + e.preventDefault(); + const trimmed = newKeyword.trim().toLowerCase(); + if (trimmed && !attachmentReminderKeywords.includes(trimmed)) { + updateSetting('attachmentReminderKeywords', [...attachmentReminderKeywords, trimmed]); + } + setNewKeyword(''); + }} + > + 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" + /> + +
+
+ )} + {/* Quick Hover Actions */} {isFeatureEnabled('hoverActionsConfigEnabled') && (
diff --git a/locales/de/common.json b/locales/de/common.json index bc6a92e6..0bce3a1a 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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": { diff --git a/locales/en/common.json b/locales/en/common.json index 9ecc5d70..8062a10e 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", @@ -914,6 +920,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": { diff --git a/locales/es/common.json b/locales/es/common.json index c4aed84c..d83a17bc 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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": { diff --git a/locales/fr/common.json b/locales/fr/common.json index a2f7da9a..855f4976 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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": { diff --git a/locales/it/common.json b/locales/it/common.json index 714b98ea..e48c2294 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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": { diff --git a/locales/ja/common.json b/locales/ja/common.json index 32ec986c..fc40cc62 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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": { diff --git a/locales/ko/common.json b/locales/ko/common.json index cad0abd5..f64ed071 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -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": { diff --git a/locales/lv/common.json b/locales/lv/common.json index ea3a22b7..448cfab7 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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": { diff --git a/locales/nl/common.json b/locales/nl/common.json index e180b8f1..685b19fa 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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": { diff --git a/locales/pl/common.json b/locales/pl/common.json index 3b64203f..ed1fb15a 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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": { diff --git a/locales/pt/common.json b/locales/pt/common.json index aa006639..468980a1 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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": { diff --git a/locales/ru/common.json b/locales/ru/common.json index 34e082e8..fd705832 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -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": { diff --git a/locales/zh/common.json b/locales/zh/common.json index 2fd33cef..48a47efb 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -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": { diff --git a/stores/settings-store.ts b/stores/settings-store.ts index e500a0a0..870d9fab 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -185,6 +185,10 @@ interface SettingsState { // Keywords (labels/tags) emailKeywords: KeywordDefinition[]; + // Attachment Reminder + attachmentReminderEnabled: boolean; + attachmentReminderKeywords: string[]; + // Sidebar Apps sidebarApps: SidebarApp[]; keepAppsLoaded: boolean; @@ -314,6 +318,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 +447,8 @@ export const useSettingsStore = create()( senderFavicons: state.senderFavicons, folderIcons: state.folderIcons, emailKeywords: state.emailKeywords, + attachmentReminderEnabled: state.attachmentReminderEnabled, + attachmentReminderKeywords: state.attachmentReminderKeywords, sidebarApps: state.sidebarApps, keepAppsLoaded: state.keepAppsLoaded, debugMode: state.debugMode, From 5ddb2acfc7407b07c582a0c327c6620c3c48cd3c Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Fri, 10 Apr 2026 18:40:32 +0200 Subject: [PATCH 16/30] fix: category dropdown blocking Save button in contact form #177 --- components/contacts/contact-form.tsx | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index bf1c89f2..a49f23c9 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -911,7 +911,6 @@ function CategoryComboBox({ }) { const [isOpen, setIsOpen] = useState(false); const [inputValue, setInputValue] = useState(""); - const wrapperRef = useRef(null); const inputRef = useRef(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 ( -
+
{/* Keyword badges */} {currentKeywords.length > 0 && (
@@ -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) && ( -
+
e.preventDefault()}> {suggestions.map(kw => ( + {/* Trusted Senders — address book storage */} + + updateSetting('trustedSendersAddressBook', checked)} + /> + + {/* Trusted Senders Modal */} (null); const inputRef = useRef(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) => { @@ -170,7 +212,11 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp {/* Content */}
- {trustedSenders.length === 0 ? ( + {isLoading ? ( +
+ +
+ ) : activeSenders.length === 0 ? ( /* Empty State */
@@ -209,7 +255,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp {email}
{/* Footer - Add sender */} - {trustedSenders.length > 0 && ( + {!isLoading && activeSenders.length > 0 && (
{isAdding ? (
@@ -244,9 +290,10 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp />
{emailError && ( diff --git a/lib/debug.ts b/lib/debug.ts index 116235b2..d8049dac 100644 --- a/lib/debug.ts +++ b/lib/debug.ts @@ -104,7 +104,7 @@ export const debug = { } }; -const CATEGORY_KEYS = new Set(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push']); +const CATEGORY_KEYS = new Set(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push', 'contacts']); function isCategoryKey(value: string): value is DebugCategory { return CATEGORY_KEYS.has(value); } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 2ee86860..8b1a225b 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -178,6 +178,7 @@ export interface IJMAPClient { getContactsAccountId(): string; getAddressBooks(): Promise; getAllAddressBooks(): Promise; + createAddressBook(name: string): Promise; updateAddressBook(addressBookId: string, updates: Partial, targetAccountId?: string): Promise; getContacts(addressBookId?: string): Promise; getAllContacts(): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index cddd4bea..5f5c9771 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -2818,6 +2818,27 @@ export class JMAPClient implements IJMAPClient { } } + async createAddressBook(name: string): Promise { + 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, targetAccountId?: string): Promise { const accountId = targetAccountId || this.getContactsAccountId(); // Only forward server-settable properties diff --git a/locales/en/common.json b/locales/en/common.json index 8062a10e..8a8a6549 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -893,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", @@ -1197,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", diff --git a/stores/contact-store.ts b/stores/contact-store.ts index dd844513..ee80767d 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -3,6 +3,7 @@ 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) { @@ -44,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[]; @@ -53,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; lastSelectedContactId: string | null; activeTab: 'all' | 'groups'; @@ -95,6 +104,12 @@ interface ContactStore { renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise; importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise; + + // Trusted senders address book + loadTrustedSendersBook: (client: IJMAPClient) => Promise; + addToTrustedSendersBook: (client: IJMAPClient, email: string) => Promise; + removeFromTrustedSendersBook: (client: IJMAPClient, email: string) => Promise; + isTrustedAddressBookSender: (email: string) => boolean; } export const useContactStore = create()( @@ -139,6 +154,10 @@ export const useContactStore = create()( isLoading: false, error: null, supportsSync: false, + trustedSenderEmails: [], + trustedSendersBookId: null, + trustedSendersLoaded: false, + trustedSendersLoading: false, selectedContactIds: new Set(), lastSelectedContactId: null, activeTab: 'all' as const, @@ -667,6 +686,74 @@ export const useContactStore = create()( } }, + 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; diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 870d9fab..8a5f5fb9 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -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; @@ -273,6 +275,7 @@ const DEFAULT_SETTINGS = { // Privacy & Security sessionTimeout: 0, // Never trustedSenders: [] as string[], + trustedSendersAddressBook: false, // Filters expandedFilterView: false, From 4531cfe47cd39872509e0be4912c8d1f5681f1a4 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Sun, 12 Apr 2026 01:14:25 +0200 Subject: [PATCH 25/30] fix: resolve TS errors from missing createAddressBook in demo client and client ref in thread view --- components/email/thread-conversation-view.tsx | 1 + lib/demo/demo-client.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 27908391..86fa0ce2 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -88,6 +88,7 @@ export function ThreadConversationView({ 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>(new Set()); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index ef3e28c5..2870a074 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -481,6 +481,12 @@ export class DemoJMAPClient implements IJMAPClient { async getAddressBooks(): Promise { return [...this.data.addressBooks]; } async getAllAddressBooks(): Promise { return [...this.data.addressBooks]; } + async createAddressBook(name: string): Promise { + const book: AddressBook = { id: `demo-book-${Date.now()}`, name }; + this.data.addressBooks.push(book); + return book; + } + async updateAddressBook(addressBookId: string, updates: Partial): Promise { const book = this.data.addressBooks.find(b => b.id === addressBookId); if (book) Object.assign(book, updates); From 5cfc905f100c689f70d981ad784273714050b93b Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Sun, 12 Apr 2026 01:33:28 +0200 Subject: [PATCH 26/30] fix: seed list history entry when app initializes on an email view --- hooks/use-browser-navigation.ts | 35 ++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/hooks/use-browser-navigation.ts b/hooks/use-browser-navigation.ts index 76c5f680..f4676f6f 100644 --- a/hooks/use-browser-navigation.ts +++ b/hooks/use-browser-navigation.ts @@ -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, ""); } From d24f402b0c350d29e5f27326ed07766657019ec9 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Sun, 12 Apr 2026 01:38:53 +0200 Subject: [PATCH 27/30] fix: style links in plain text emails --- app/globals.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/globals.css b/app/globals.css index 9dae64fa..65e1919e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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", From a201c1617b23241d6df32c87adbdc6a68095cc1b Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Sun, 12 Apr 2026 01:59:53 +0200 Subject: [PATCH 28/30] feat: add onAvatarResolve plugin hook --- components/ui/avatar.tsx | 37 ++++++++++++++++++++++++++++++------- lib/plugin-api.ts | 8 +++++++- lib/plugin-hooks.ts | 8 ++++++++ 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx index cdc69d18..61961d5f 100644 --- a/components/ui/avatar.tsx +++ b/components/ui/avatar.tsx @@ -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(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 (
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 = { // 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 Date: Sun, 12 Apr 2026 02:13:55 +0200 Subject: [PATCH 29/30] chore: update version to 1.4.13 --- CHANGELOG.md | 24 ++++++++++++++++++++++++ README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a261a35..b2549085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 1.4.13 (2026-04-12) + +### 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: diff --git a/README.md b/README.md index ac4b2966..f89a5989 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol. [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) -[![Version](https://img.shields.io/badge/version-1.4.12-green.svg?logo=git&logoColor=white)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.4.13-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
diff --git a/package-lock.json b/package-lock.json index af747cc5..4233198d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 99a09683..b2133cf4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bulwark-webmail", - "version": "1.4.12", + "version": "1.4.13", "description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server", "author": "Bulwark Webmail ", "license": "AGPL-3.0-only", From 44e0e172038ebb6f93097da99be617ce00bd960d Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Sun, 12 Apr 2026 02:17:54 +0200 Subject: [PATCH 30/30] chore: update version to 1.4.13 --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2549085..169df843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## 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)