From e7e78072d40bc5551eb4400c3b88bb1f7036decb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 7 May 2026 17:49:39 +0200 Subject: [PATCH 001/133] fix: render plain-text-only emails as text, not HTML --- components/email/email-viewer.tsx | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 2c20f0ad..8d1b6616 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -2294,17 +2294,24 @@ export function EmailViewer({ if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) { htmlContent = email.bodyValues[email.htmlBody[0].partId].value; - // Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting). - // Server-generated HTML from text/plain emails often lacks
tags, collapsing newlines. - // Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody — - // in that case there is no real plain-text alternative, so always render the HTML. - const textPartId = email.textBody?.[0]?.partId; - const htmlPartId = email.htmlBody[0].partId; - const hasDistinctTextBody = !!textPartId && textPartId !== htmlPartId && !!email.bodyValues[textPartId]; - if (hasDistinctTextBody && htmlContent) { - useHtmlVersion = hasMeaningfulHtmlBody(htmlContent); + // Per RFC 8621 § 4.1.4, when a message has only one alternative the server + // exposes the same part in both htmlBody and textBody. The shared part may + // actually be text/plain (plain-text-only mail) — rendering that as HTML + // collapses newlines and skips linkification, so route by the part's type. + const htmlPart = email.htmlBody[0]; + if (htmlPart.type && htmlPart.type.toLowerCase() !== 'text/html') { + useHtmlVersion = false; } else { - useHtmlVersion = !!htmlContent; + // Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting). + // Server-generated HTML from text/plain emails often lacks
tags, collapsing newlines. + const textPartId = email.textBody?.[0]?.partId; + const htmlPartId = htmlPart.partId; + const hasDistinctTextBody = !!textPartId && textPartId !== htmlPartId && !!email.bodyValues[textPartId]; + if (hasDistinctTextBody && htmlContent) { + useHtmlVersion = hasMeaningfulHtmlBody(htmlContent); + } else { + useHtmlVersion = !!htmlContent; + } } } From 41c9f4926cc1e96b2f0ccd23a0a4cb2aca6ea138 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 8 May 2026 02:49:58 +0200 Subject: [PATCH 002/133] fix: prevent white-on-white in dark mode for nested bgcolor containers --- components/email/email-viewer.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 8d1b6616..ae949616 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -2689,6 +2689,11 @@ export function EmailViewer({ // Re-invert leaf media elements so they appear normal. // Container selectors (bgcolor, background, etc.) use :not(:has(...)) to avoid // double re-inverting images nested inside those containers. + // Nested bgcolor containers must NOT add another invert layer: each filter + // toggles the inversion, so an odd number of stacked filters (e.g. body + + // outer bgcolor table + inner bgcolor table) produces an inverted result — + // i.e. light-on-light. The second rule disables filter on bgcolor-like + // elements that are descendants of another bgcolor-like element. const darkModeCSS = isDark && !emailHasNativeDarkMode ? ` html { background: #1a1a1a; } body { filter: invert(1) hue-rotate(180deg); } @@ -2703,6 +2708,10 @@ export function EmailViewer({ table[background]:not(:has(img, video, svg, canvas, object, embed)) { filter: invert(1) hue-rotate(180deg); } + :where([style*="background-image"], [style*="background:"], [background], [bgcolor]) + :where([style*="background-image"], [style*="background:"], [background], [bgcolor]):not(:has(img, video, svg, canvas, object, embed)) { + filter: none !important; + } ` : ''; const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light'; From 562080b7a39a1a3929aeb5ebfc0e56c3b15d5892 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 8 May 2026 19:53:32 +0200 Subject: [PATCH 003/133] fix: request shareWith explicitly so calendar/address book shares survive a re-login #257 --- .gitignore | 4 +++- lib/jmap/client.ts | 45 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 162a9448..0b311b8f 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,6 @@ next-env.d.ts /local-data/ # Sibling repos -/repos/ \ No newline at end of file +/repos/ +# benchmark +benchmark/ diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index f10c297b..ac1087cf 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -95,6 +95,41 @@ const EMAIL_LIST_PROPERTIES = [ "hasAttachment", ] as const; +// Stalwart's default property list for Calendar/get omits shareWith, isVisible, +// includeInAvailability, and the default-alerts properties. Without an explicit +// `properties` list the share indicator and share dialog can't see existing +// shares after a fresh login (only the optimistic in-memory update from the +// share action would carry it). Always request the full set we render. +const CALENDAR_PROPERTIES = [ + "id", + "name", + "description", + "color", + "sortOrder", + "isSubscribed", + "isVisible", + "isDefault", + "includeInAvailability", + "defaultAlertsWithTime", + "defaultAlertsWithoutTime", + "timeZone", + "shareWith", + "myRights", +] as const; + +// Stalwart's default property list for AddressBook/get omits shareWith, so +// existing shares would be invisible after a fresh login. +const ADDRESS_BOOK_PROPERTIES = [ + "id", + "name", + "description", + "sortOrder", + "isDefault", + "isSubscribed", + "shareWith", + "myRights", +] as const; + /** * Detect whether a calendar object returned by the server is actually a * task (VTODO) rather than an event (VEVENT). CalDAV clients like @@ -3143,7 +3178,7 @@ export class JMAPClient implements IJMAPClient { try { const accountId = this.getContactsAccountId(); const response = await this.request([ - ["AddressBook/get", { accountId }, "0"] + ["AddressBook/get", { accountId, properties: ADDRESS_BOOK_PROPERTIES }, "0"] ], this.contactUsing()); if (response.methodResponses?.[0]?.[0] === "AddressBook/get") { @@ -3168,7 +3203,7 @@ export class JMAPClient implements IJMAPClient { try { const response = await this.request([ - ["AddressBook/get", { accountId }, "0"] + ["AddressBook/get", { accountId, properties: ADDRESS_BOOK_PROPERTIES }, "0"] ], this.contactUsing()); if (response.methodResponses?.[0]?.[0] === "AddressBook/get") { @@ -3612,7 +3647,7 @@ export class JMAPClient implements IJMAPClient { try { const accountId = this.getCalendarsAccountId(); const response = await this.request([ - ["Calendar/get", { accountId }, "0"] + ["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "0"] ], this.calendarUsing()); if (response.methodResponses?.[0]?.[0] === "Calendar/get") { @@ -3637,7 +3672,7 @@ export class JMAPClient implements IJMAPClient { try { const response = await this.request([ - ["Calendar/get", { accountId }, "0"] + ["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "0"] ], this.calendarUsing()); if (response.methodResponses?.[0]?.[0] === "Calendar/get") { @@ -3689,7 +3724,7 @@ export class JMAPClient implements IJMAPClient { // Fetch from the target account to find the created calendar const fetchAccountId = targetAccountId || this.getCalendarsAccountId(); const fetchResponse = await this.request([ - ["Calendar/get", { accountId: fetchAccountId, ids: [createdId] }, "0"] + ["Calendar/get", { accountId: fetchAccountId, ids: [createdId], properties: CALENDAR_PROPERTIES }, "0"] ], this.calendarUsing()); if (fetchResponse.methodResponses?.[0]?.[0] === "Calendar/get") { const list = fetchResponse.methodResponses[0][1].list || []; From abd63d124fee3eb257aba136fda3d417dad4a5cf Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 8 May 2026 19:56:21 +0200 Subject: [PATCH 004/133] fix: add benchmark directory to ESLint ignore list --- eslint.config.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index 50e63af0..3c490472 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -77,6 +77,7 @@ export default [ "*.config.mjs", "e2e/**", "local-data/**/*.mjs", + "benchmark/**", ], }, ]; From 55596556ef7db4117db2a51d25a19e81fd08aca3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 8 May 2026 20:12:33 +0200 Subject: [PATCH 005/133] feat: import .eml files via folder right-click menu --- app/[locale]/page.tsx | 38 ++++++++++++++++++++++ components/layout/mailbox-context-menu.tsx | 13 ++++++++ components/layout/sidebar.tsx | 3 ++ locales/en/common.json | 1 + 4 files changed, 55 insertions(+) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 74fa0bc1..4786426b 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -1504,6 +1504,43 @@ export default function Home() { } }; + const handleImportEmailFromContextMenu = (mailboxId: string) => { + if (!client) return; + const mailbox = mailboxes.find(mb => mb.id === mailboxId); + if (!mailbox) return; + const targetMailboxId = mailbox.originalId || mailbox.id; + + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.eml,message/rfc822'; + input.multiple = true; + input.onchange = async (e) => { + const files = Array.from((e.target as HTMLInputElement).files ?? []); + if (files.length === 0) return; + + let imported = 0; + let failed = 0; + for (const file of files) { + try { + const blob = new Blob([await file.arrayBuffer()], { type: 'message/rfc822' }); + await client.importRawEmail(blob, { [targetMailboxId]: true }, { '$seen': true }); + imported++; + } catch { + failed++; + } + } + + if (imported > 0) { + toast.success(t('notifications.import_email_success')); + if (selectedMailbox) await fetchEmails(client, selectedMailbox); + } + if (failed > 0) { + toast.error(t('notifications.import_email_error')); + } + }; + input.click(); + }; + const handleRefreshMailboxes = async () => { if (!client) return; try { @@ -1894,6 +1931,7 @@ export default function Home() { onCreateFolder={handleCreateFolderFromContextMenu} onRenameFolder={handleRenameFolderFromContextMenu} onDeleteFolder={handleDeleteFolderFromContextMenu} + onImportEmail={handleImportEmailFromContextMenu} onRefreshMailboxes={handleRefreshMailboxes} onCompose={() => { setComposerMode('compose'); diff --git a/components/layout/mailbox-context-menu.tsx b/components/layout/mailbox-context-menu.tsx index 0f497d96..5807d085 100644 --- a/components/layout/mailbox-context-menu.tsx +++ b/components/layout/mailbox-context-menu.tsx @@ -20,6 +20,7 @@ import { Pencil, FolderX, RefreshCw, + Upload, } from "lucide-react"; interface Position { @@ -84,6 +85,7 @@ interface MailboxContextMenuProps { onCreateFolder?: () => void; onRenameFolder?: (mailboxId: string) => void; onDeleteFolder?: (mailboxId: string) => void; + onImportEmail?: (mailboxId: string) => void; onRefresh?: () => void; } @@ -102,6 +104,7 @@ export function MailboxContextMenu({ onCreateFolder, onRenameFolder, onDeleteFolder, + onImportEmail, onRefresh, }: MailboxContextMenuProps) { const t = useTranslations("mailbox_context_menu"); @@ -149,6 +152,7 @@ export function MailboxContextMenu({ const canCreateChild = mailbox.myRights?.mayCreateChild !== false; const canSetSeen = mailbox.myRights?.maySetSeen !== false; const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false; + const canAddItems = mailbox.myRights?.mayAddItems !== false; const fullPath = getMailboxPath(mailbox, mailboxes); @@ -190,6 +194,15 @@ export function MailboxContextMenu({ + handleAction(() => onImportEmail?.(mailbox.id))} + disabled={!onImportEmail || !canAddItems} + /> + + + void; onRenameFolder?: (mailboxId: string) => void; onDeleteFolder?: (mailboxId: string) => void; + onImportEmail?: (mailboxId: string) => void; onRefreshMailboxes?: () => void; className?: string; } @@ -636,6 +637,7 @@ export function Sidebar({ onCreateFolder, onRenameFolder, onDeleteFolder, + onImportEmail, onRefreshMailboxes, className, }: SidebarProps) { @@ -1037,6 +1039,7 @@ export function Sidebar({ onCreateFolder={onCreateFolder} onRenameFolder={onRenameFolder} onDeleteFolder={onDeleteFolder} + onImportEmail={onImportEmail} onRefresh={onRefreshMailboxes} /> diff --git a/locales/en/common.json b/locales/en/common.json index 8d6cd951..2aeef7dd 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1684,6 +1684,7 @@ "new_subfolder": "New subfolder...", "new_folder": "New folder...", "rename": "Rename...", + "import_email": "Import .eml...", "empty_folder": "Empty folder", "empty_folder_generic": "Empty folder", "delete_folder": "Delete folder", From 92fb0c63e97de2eb906ee87f70df3ad1e0c4efb6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 8 May 2026 20:19:25 +0200 Subject: [PATCH 006/133] fix: trim leading whitespace from email list preview --- components/email/email-list-item.tsx | 5 +++-- components/email/thread-list-item.tsx | 10 ++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 5f782fe4..07de2c88 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -51,7 +51,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; const isFocusedMailLayout = mailLayout === 'focus'; const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk; - const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; + const trimmedPreview = email.preview?.replace(/^\s+/, '') ?? ''; + const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; // Resolve color tags using keyword definitions from settings; unknown tags fall back to gray const colorTagIds = getEmailColorTags(email.keywords); @@ -295,7 +296,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl ? "text-muted-foreground" : "text-muted-foreground/80" )}> - {email.preview || "No preview available"} + {trimmedPreview || "No preview available"}

)} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 3dfa42a2..9213ff16 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -71,7 +71,8 @@ const SingleEmailItem = React.forwardRef( const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined; const isChecked = selectedEmailIds.has(email.id); const isFocusedMailLayout = mailLayout === 'focus'; - const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; + const trimmedPreview = email.preview?.replace(/^\s+/, '') ?? ''; + const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; // Resolve color tags using keyword definitions; unknown tags fall back to gray const tagIds = getEmailColorTags(email.keywords); @@ -316,7 +317,7 @@ const SingleEmailItem = React.forwardRef( ? "text-muted-foreground" : "text-muted-foreground/80" )}> - {email.preview || "No preview available"} + {trimmedPreview || "No preview available"}

)} @@ -366,7 +367,8 @@ export const ThreadListItem = React.forwardRef state.isMobile); const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; const isFocusedMailLayout = mailLayout === 'focus'; - const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : ''; + const trimmedPreview = latestEmail.preview?.replace(/^\s+/, '') ?? ''; + const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore(); const getAccountById = useAccountStore((state) => state.getAccountById); @@ -722,7 +724,7 @@ export const ThreadListItem = React.forwardRef - {latestEmail.preview || "No preview available"} + {trimmedPreview || "No preview available"}

)} From 48f72be20961916b70a1b146abafec4f503c7dfd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 8 May 2026 20:26:17 +0200 Subject: [PATCH 007/133] fix: preserve emoji colors in dark mode email viewer --- lib/utils.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/utils.ts b/lib/utils.ts index 4c9624ad..44193b1d 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -78,6 +78,16 @@ export function formatDateTime( return d.toLocaleString(undefined, localeOptions); } +// Marketing emails often pad the preheader with invisible Unicode (combining +// grapheme joiners, soft hyphens, zero-width chars) alongside whitespace, to +// push real content out of the preview window. \s catches normal whitespace +// including figure space U+2007; we also strip the common invisible formatters. +const LEADING_INVISIBLE_RE = /^[\s\u00AD\u034F\u200B-\u200F\u2060-\u2064\uFEFF]+/; + +export function stripInvisibleLeading(text: string): string { + return text.replace(LEADING_INVISIBLE_RE, ''); +} + export function truncateText(text: string, maxLength: number): string { if (text.length <= maxLength) return text; return text.substring(0, maxLength).trim() + "..."; From 9c8739c4bbe0d653d91a9fd91a597754734a711e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 8 May 2026 20:27:03 +0200 Subject: [PATCH 008/133] fix: preserve emoji colors in dark mode email viewer --- components/email/email-viewer.tsx | 69 +++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index ae949616..1caac5ee 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -2884,6 +2884,75 @@ export function EmailViewer({ } } }); + + // Re-invert emoji glyphs so they keep their original colors. The + // body's invert filter flips colored emoji (yellow smiley → blue, + // red heart → cyan, etc.). Wrap each emoji run in a span that + // re-inverts. Only act when the ancestor invert depth is odd — + // emojis inside a double-inverted bgcolor container already render + // at their original colors. + let emojiRe: RegExp; + try { + emojiRe = new RegExp('\\p{RGI_Emoji}', 'gv'); + } catch { + emojiRe = /\p{Extended_Pictographic}(?:\uFE0F)?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F)?)*/gu; + } + const emojiTestRe = /\p{Extended_Pictographic}/u; + const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'IFRAME']); + + const isOddInvertDepth = (start: Element | null) => { + let count = 0; + let n: Element | null = start; + while (n) { + if (n === doc.body) { count++; break; } + const cs = win.getComputedStyle(n); + if (cs.filter && cs.filter.includes('invert')) count++; + n = n.parentElement; + } + return count % 2 === 1; + }; + + const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + let p = node.parentElement; + while (p) { + if (SKIP_TAGS.has(p.tagName)) return NodeFilter.FILTER_REJECT; + p = p.parentElement; + } + return emojiTestRe.test(node.nodeValue || '') + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + }); + + const emojiTextNodes: Text[] = []; + let cur: Node | null; + while ((cur = walker.nextNode())) emojiTextNodes.push(cur as Text); + + emojiTextNodes.forEach((textNode) => { + const parent = textNode.parentElement; + if (!parent || !isOddInvertDepth(parent)) return; + const text = textNode.nodeValue || ''; + emojiRe.lastIndex = 0; + const frag = doc.createDocumentFragment(); + let lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = emojiRe.exec(text)) !== null) { + if (m.index > lastIndex) { + frag.appendChild(doc.createTextNode(text.slice(lastIndex, m.index))); + } + const span = doc.createElement('span'); + span.style.cssText = 'filter:invert(1) hue-rotate(180deg)'; + span.textContent = m[0]; + frag.appendChild(span); + lastIndex = m.index + m[0].length; + } + if (lastIndex === 0) return; + if (lastIndex < text.length) { + frag.appendChild(doc.createTextNode(text.slice(lastIndex))); + } + parent.replaceChild(frag, textNode); + }); } } } From 65aabb943c9e5e72a8ee465a5212d3d6f06dc3a9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 8 May 2026 20:29:16 +0200 Subject: [PATCH 009/133] fix: fall back when only truncation indicator remains in email preview --- components/email/email-list-item.tsx | 4 ++-- components/email/thread-list-item.tsx | 6 +++--- lib/utils.ts | 17 +++++++++++------ 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 07de2c88..1fff8b43 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -2,7 +2,7 @@ import { useTranslations } from "next-intl"; import { useCallback } from "react"; -import { formatDate } from "@/lib/utils"; +import { formatDate, stripInvisibleLeading } from "@/lib/utils"; import { Email } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { Avatar } from "@/components/ui/avatar"; @@ -51,7 +51,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; const isFocusedMailLayout = mailLayout === 'focus'; const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk; - const trimmedPreview = email.preview?.replace(/^\s+/, '') ?? ''; + const trimmedPreview = stripInvisibleLeading(email.preview ?? ''); const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; // Resolve color tags using keyword definitions from settings; unknown tags fall back to gray diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 9213ff16..bd73081a 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useCallback } from "react"; -import { formatDate } from "@/lib/utils"; +import { formatDate, stripInvisibleLeading } from "@/lib/utils"; import { Email, ThreadGroup } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { Avatar } from "@/components/ui/avatar"; @@ -71,7 +71,7 @@ const SingleEmailItem = React.forwardRef( const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined; const isChecked = selectedEmailIds.has(email.id); const isFocusedMailLayout = mailLayout === 'focus'; - const trimmedPreview = email.preview?.replace(/^\s+/, '') ?? ''; + const trimmedPreview = stripInvisibleLeading(email.preview ?? ''); const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; // Resolve color tags using keyword definitions; unknown tags fall back to gray @@ -367,7 +367,7 @@ export const ThreadListItem = React.forwardRef state.isMobile); const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; const isFocusedMailLayout = mailLayout === 'focus'; - const trimmedPreview = latestEmail.preview?.replace(/^\s+/, '') ?? ''; + const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? ''); const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore(); diff --git a/lib/utils.ts b/lib/utils.ts index 44193b1d..331e5f2b 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -78,14 +78,19 @@ export function formatDateTime( return d.toLocaleString(undefined, localeOptions); } -// Marketing emails often pad the preheader with invisible Unicode (combining -// grapheme joiners, soft hyphens, zero-width chars) alongside whitespace, to -// push real content out of the preview window. \s catches normal whitespace -// including figure space U+2007; we also strip the common invisible formatters. -const LEADING_INVISIBLE_RE = /^[\s\u00AD\u034F\u200B-\u200F\u2060-\u2064\uFEFF]+/; +// Marketing emails pad the preheader with whitespace, format chars (soft +// hyphens, zero-width chars, BOM, directional marks) and combining marks +// (e.g. U+034F) to push real content past the preview window. Strip them all. +// \p{Cf} = Format, \p{Mn} = combining marks; \s covers figure space, NBSP, etc. +const LEADING_INVISIBLE_RE = /^[\s\p{Cf}\p{Mn}]+/u; +// After stripping, a server-side truncation indicator like "..." may be all +// that's left. Treat that as no preview so callers can fall back. +const ONLY_PUNCTUATION_RE = /^[.\u2026\s]+$/; export function stripInvisibleLeading(text: string): string { - return text.replace(LEADING_INVISIBLE_RE, ''); + const stripped = text.replace(LEADING_INVISIBLE_RE, ''); + if (ONLY_PUNCTUATION_RE.test(stripped)) return ''; + return stripped; } export function truncateText(text: string, maxLength: number): string { From c31a58af1a9604c9a9cca4b2bf474d7bf97936b7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 8 May 2026 21:07:14 +0200 Subject: [PATCH 010/133] i18n: add missing translation keys across 15 locales --- locales/cs/common.json | 1 + locales/de/common.json | 1 + locales/es/common.json | 1 + locales/fr/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/lv/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt/common.json | 1 + locales/ru/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/zh/common.json | 1 + 15 files changed, 15 insertions(+) diff --git a/locales/cs/common.json b/locales/cs/common.json index 7a5dae00..53aa546f 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1684,6 +1684,7 @@ "new_subfolder": "Nová podsložka...", "new_folder": "Nová složka...", "rename": "Přejmenovat...", + "import_email": "Importovat .eml...", "empty_folder": "Vyprázdnit složku", "empty_folder_generic": "Vyprázdnit složku", "delete_folder": "Smazat složku", diff --git a/locales/de/common.json b/locales/de/common.json index b9e86006..ecfdf1a0 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1684,6 +1684,7 @@ "new_subfolder": "Neuer Unterordner...", "new_folder": "Neuer Ordner...", "rename": "Umbenennen...", + "import_email": ".eml importieren...", "empty_folder": "Ordner leeren", "empty_folder_generic": "Ordner leeren", "delete_folder": "Ordner löschen", diff --git a/locales/es/common.json b/locales/es/common.json index d5d12efb..b12de85d 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1684,6 +1684,7 @@ "new_subfolder": "Nueva subcarpeta...", "new_folder": "Nueva carpeta...", "rename": "Renombrar...", + "import_email": "Importar .eml...", "empty_folder": "Vaciar carpeta", "empty_folder_generic": "Vaciar carpeta", "delete_folder": "Eliminar carpeta", diff --git a/locales/fr/common.json b/locales/fr/common.json index 3cf052f6..ab6504e6 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1684,6 +1684,7 @@ "new_subfolder": "Nouveau sous-dossier...", "new_folder": "Nouveau dossier...", "rename": "Renommer...", + "import_email": "Importer un .eml...", "empty_folder": "Vider le dossier", "empty_folder_generic": "Vider le dossier", "delete_folder": "Supprimer le dossier", diff --git a/locales/it/common.json b/locales/it/common.json index 8dbada45..8a55b533 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1684,6 +1684,7 @@ "new_subfolder": "Nuova sottocartella...", "new_folder": "Nuova cartella...", "rename": "Rinomina...", + "import_email": "Importa .eml...", "empty_folder": "Svuota cartella", "empty_folder_generic": "Svuota cartella", "delete_folder": "Elimina cartella", diff --git a/locales/ja/common.json b/locales/ja/common.json index d957d652..748a9f4c 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1684,6 +1684,7 @@ "new_subfolder": "新しいサブフォルダー...", "new_folder": "新しいフォルダー...", "rename": "名前を変更...", + "import_email": ".eml をインポート...", "empty_folder": "フォルダーを空にする", "empty_folder_generic": "フォルダーを空にする", "delete_folder": "フォルダーを削除", diff --git a/locales/ko/common.json b/locales/ko/common.json index 59d17a85..89d3bdd8 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1684,6 +1684,7 @@ "new_subfolder": "새 하위 폴더...", "new_folder": "새 폴더...", "rename": "이름 바꾸기...", + "import_email": ".eml 가져오기...", "empty_folder": "폴더 비우기", "empty_folder_generic": "폴더 비우기", "delete_folder": "폴더 삭제", diff --git a/locales/lv/common.json b/locales/lv/common.json index 2f37631f..db45b286 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1684,6 +1684,7 @@ "new_subfolder": "Jauna apakšmape...", "new_folder": "Jauna mape...", "rename": "Pārsaukt...", + "import_email": "Importēt .eml...", "empty_folder": "Iztukšot mapi", "empty_folder_generic": "Iztukšot mapi", "delete_folder": "Dzēst mapi", diff --git a/locales/nl/common.json b/locales/nl/common.json index 7bdb3f52..865ecb53 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1684,6 +1684,7 @@ "new_subfolder": "Nieuwe submap...", "new_folder": "Nieuwe map...", "rename": "Hernoemen...", + "import_email": ".eml importeren...", "empty_folder": "Map leegmaken", "empty_folder_generic": "Map leegmaken", "delete_folder": "Map verwijderen", diff --git a/locales/pl/common.json b/locales/pl/common.json index 12371139..81b531e5 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1684,6 +1684,7 @@ "new_subfolder": "Nowy podfolder...", "new_folder": "Nowy folder...", "rename": "Zmień nazwę...", + "import_email": "Importuj .eml...", "empty_folder": "Opróżnij folder", "empty_folder_generic": "Opróżnij folder", "delete_folder": "Usuń folder", diff --git a/locales/pt/common.json b/locales/pt/common.json index ed6d8f5a..56de530e 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1684,6 +1684,7 @@ "new_subfolder": "Nova subpasta...", "new_folder": "Nova pasta...", "rename": "Renomear...", + "import_email": "Importar .eml...", "empty_folder": "Esvaziar pasta", "empty_folder_generic": "Esvaziar pasta", "delete_folder": "Excluir pasta", diff --git a/locales/ru/common.json b/locales/ru/common.json index bec91970..2fc2bb7e 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1684,6 +1684,7 @@ "new_subfolder": "Новая вложенная папка...", "new_folder": "Новая папка...", "rename": "Переименовать...", + "import_email": "Импортировать .eml...", "empty_folder": "Очистить папку", "empty_folder_generic": "Очистить папку", "delete_folder": "Удалить папку", diff --git a/locales/tr/common.json b/locales/tr/common.json index 86e038eb..ecb2b453 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1684,6 +1684,7 @@ "new_subfolder": "Yeni alt klasör...", "new_folder": "Yeni klasör...", "rename": "Yeniden adlandır...", + "import_email": ".eml içe aktar...", "empty_folder": "Klasörü boşalt", "empty_folder_generic": "Klasörü boşalt", "delete_folder": "Klasörü sil", diff --git a/locales/uk/common.json b/locales/uk/common.json index 92b070df..29825189 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1684,6 +1684,7 @@ "new_subfolder": "Нова вкладена папка...", "new_folder": "Нова папка...", "rename": "Перейменувати...", + "import_email": "Імпортувати .eml...", "empty_folder": "Очистити папку", "empty_folder_generic": "Очистити папку", "delete_folder": "Видалити папку", diff --git a/locales/zh/common.json b/locales/zh/common.json index 70512110..c11a1b1f 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1684,6 +1684,7 @@ "new_subfolder": "新建子文件夹...", "new_folder": "新建文件夹...", "rename": "重命名...", + "import_email": "导入 .eml...", "empty_folder": "清空文件夹", "empty_folder_generic": "清空文件夹", "delete_folder": "删除文件夹", From 090399a30895248b275225e01f202f90412cde3d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 8 May 2026 21:10:21 +0200 Subject: [PATCH 011/133] chore: update version to 1.6.3 --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ VERSION | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4579685d..d954cd75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 1.6.3 (2026-05-08) + +### Features + +- **Mail**: Lift 5-account cap on HTTP/2 +- **Mail**: Import `.eml` files via folder right-click menu + +### Fixes + +- **Mail**: Trim leading whitespace from email list preview +- **Mail**: Fall back when only the truncation indicator remains in email preview +- **Mail**: Hide files/contacts nav items when JMAP server lacks support +- **Viewer**: Preserve emoji colors in dark mode +- **Viewer**: Prevent white-on-white in dark mode for nested `bgcolor` containers +- **Viewer**: Render plain-text-only emails as text, not HTML +- **Viewer**: Render HTML-only emails and redesign external content prompt +- **Viewer**: Pad Word/Outlook HTML email rendering +- **Compose**: Redesign quick reply to match sender/banner layout +- **Compose**: Disable StarterKit's bundled link/underline to avoid duplicate extensions +- **Sharing**: Request `shareWith` explicitly so calendar/address book shares survive a re-login (#257) +- **UI**: Strip leading punctuation when computing avatar initials +- **Mobile**: Hide email hover actions + +### i18n + +- Add missing translation keys across 15 locales + ## 1.6.2 (2026-05-06) ### Features diff --git a/VERSION b/VERSION index 308b6faa..f5d2a585 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.2 \ No newline at end of file +1.6.3 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 39c5d20f..af394484 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bulwark-webmail", - "version": "1.6.2", + "version": "1.6.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bulwark-webmail", - "version": "1.6.2", + "version": "1.6.3", "license": "AGPL-3.0-only", "dependencies": { "@tanstack/react-virtual": "^3.13.24", diff --git a/package.json b/package.json index 2c2ea2d9..c13ab0e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bulwark-webmail", - "version": "1.6.2", + "version": "1.6.3", "description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server", "author": "Bulwark Webmail ", "license": "AGPL-3.0-only", From d09df7e8a3af3d30723fd0b5961943d2e23bd2b2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 9 May 2026 13:13:08 +0200 Subject: [PATCH 012/133] fix: remove benchmark directory from .gitignore --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index 0b311b8f..db1d86e7 100644 --- a/.gitignore +++ b/.gitignore @@ -50,5 +50,3 @@ next-env.d.ts # Sibling repos /repos/ -# benchmark -benchmark/ From 7fa65796f085b98524b1f2ad509a6822784a2586 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 9 May 2026 13:21:13 +0200 Subject: [PATCH 013/133] fix: show account identity in switcher header instead of sending alias --- components/layout/account-switcher.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index 095c4640..48df8979 100644 --- a/components/layout/account-switcher.tsx +++ b/components/layout/account-switcher.tsx @@ -49,7 +49,6 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher const switchAccount = useAuthStore((s) => s.switchAccount); const logout = useAuthStore((s) => s.logout); const logoutAll = useAuthStore((s) => s.logoutAll); - const primaryIdentity = useAuthStore((s) => s.primaryIdentity); const updatePosition = useCallback(() => { if (!buttonRef.current) return; @@ -115,9 +114,11 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher setDefaultAccount(accountId); }; - // Display name for the active account - const displayName = primaryIdentity?.name || activeAccount?.displayName || activeAccount?.label || ""; - const displayEmail = primaryIdentity?.email || activeAccount?.email || activeAccount?.username || ""; + // Show the account's own identity, not the preferred sending identity — + // primaryIdentity can be an alias (e.g. info@korazo.net) that differs from + // the actually logged-in account (info@linusrath.de). + const displayName = activeAccount?.displayName || activeAccount?.label || ""; + const displayEmail = activeAccount?.email || activeAccount?.username || ""; return ( <> From c44a9ce6e073f445dd076fb497f0b9f464d33775 Mon Sep 17 00:00:00 2001 From: Chance Date: Sat, 9 May 2026 08:11:28 -0400 Subject: [PATCH 014/133] fix: fall back to primary identity signature on reply When auto-select picks an alias identity matching the original recipient, the alias often has no signature configured. The composer was using the alias's empty signature for both the visual preview and the appended signature on send, so neither showed up. New mail worked because no auto-select runs. Add a signatureIdentity that falls back to the primary when the current identity has no signature. From address, identity ID, S/MIME, and draft saves still use currentIdentity so mail goes out from the right address. --- components/email/email-composer.tsx | 30 +++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 1bb8a74c..24fda38b 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -281,6 +281,12 @@ export function EmailComposer({ const currentIdentity = selectedIdentityId ? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity : primaryIdentity; + // Alias identities often lack a configured signature — fall back to the primary + // identity's signature so replies (which auto-select a matching alias) still + // populate the user's signature. + const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature) + ? currentIdentity + : primaryIdentity; useEffect(() => { if (!autoSelectReplyIdentity) return; if (selectedIdentityId || initialData?.selectedIdentityId) return; @@ -322,10 +328,10 @@ export function EmailComposer({ selectedIdentityId, ]); - const composerSignatureHtml = currentIdentity?.htmlSignature - ? `
${sanitizeEmailHtml(currentIdentity.htmlSignature)}
` - : currentIdentity?.textSignature - ? `
${getPlainTextSignature(currentIdentity).replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}
` + const composerSignatureHtml = signatureIdentity?.htmlSignature + ? `
${sanitizeEmailHtml(signatureIdentity.htmlSignature)}
` + : signatureIdentity?.textSignature + ? `
${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}
` : ''; const getAutocomplete = useContactStore((s) => s.getAutocomplete); const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook); @@ -954,11 +960,11 @@ export function EmailComposer({ // Body is already HTML from the rich text editor (or plain text in plain text mode). // Build HTML signature block (used only in rich text mode) const buildSignatureHtml = (): string => { - if (currentIdentity?.htmlSignature) { - return `

--
${sanitizeEmailHtml(currentIdentity.htmlSignature)}`; + if (signatureIdentity?.htmlSignature) { + return `

--
${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`; } - if (currentIdentity?.textSignature) { - return `

--
${currentIdentity.textSignature.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}`; + if (signatureIdentity?.textSignature) { + return `

--
${signatureIdentity.textSignature.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}`; } return ''; }; @@ -970,8 +976,8 @@ export function EmailComposer({ // In plain text mode, send text/plain only (no HTML body) const finalBody = plainTextMode - ? appendPlainTextSignature(body, currentIdentity) - : appendPlainTextSignature(htmlToPlainText(body), currentIdentity); + ? appendPlainTextSignature(body, signatureIdentity) + : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity); const rewritten = plainTextMode ? null : rewriteInlineImages(body); const finalHtmlBody = plainTextMode @@ -1500,9 +1506,9 @@ export function EmailComposer({ )} {plainTextMode ? ( - getPlainTextSignature(currentIdentity) ? ( + getPlainTextSignature(signatureIdentity) ? (
- {'-- \n'}{getPlainTextSignature(currentIdentity)} + {'-- \n'}{getPlainTextSignature(signatureIdentity)}
) : null ) : composerSignatureHtml ? ( From 51745ea03dea23e69b7aefa18afd66a6456cfc23 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 9 May 2026 17:37:41 +0200 Subject: [PATCH 015/133] feat: web setup wizard + admin config/state dir split (#226) --- .env.example | 25 +- Dockerfile | 2 +- app/admin/login/page.tsx | 12 +- app/api/auth/sso/start/route.ts | 4 +- app/api/config/route.ts | 6 +- app/api/settings/route.ts | 7 +- app/api/setup/finish/route.ts | 107 ++ app/api/setup/status/route.ts | 52 + app/api/setup/step/route.ts | 118 ++ app/api/setup/test-jmap/route.ts | 104 ++ app/api/setup/token/route.ts | 49 + app/setup/layout.tsx | 5 + app/setup/page.tsx | 1288 +++++++++++++++++ components/email/email-composer.tsx | 2 +- components/email/email-viewer.tsx | 6 +- components/email/thread-conversation-view.tsx | 2 +- components/layout/account-switcher.tsx | 2 +- docker-compose.yml | 8 +- instrumentation.node.ts | 34 +- lib/account-utils.ts | 4 +- lib/admin/audit.ts | 21 +- lib/admin/config-manager.ts | 50 +- lib/admin/migrate.ts | 196 +++ lib/admin/password.ts | 199 +-- lib/admin/paths.ts | 126 ++ lib/admin/plugin-config.ts | 10 +- lib/admin/plugin-registry.ts | 15 +- lib/admin/session.ts | 4 +- lib/admin/types.ts | 20 +- lib/auth/crypto.ts | 4 +- lib/auth/session-secret.ts | 31 + lib/settings-sync.ts | 4 +- lib/setup/session.ts | 33 + lib/setup/state.ts | 53 + lib/setup/token.ts | 111 ++ proxy.ts | 62 +- 36 files changed, 2612 insertions(+), 164 deletions(-) create mode 100644 app/api/setup/finish/route.ts create mode 100644 app/api/setup/status/route.ts create mode 100644 app/api/setup/step/route.ts create mode 100644 app/api/setup/test-jmap/route.ts create mode 100644 app/api/setup/token/route.ts create mode 100644 app/setup/layout.tsx create mode 100644 app/setup/page.tsx create mode 100644 lib/admin/migrate.ts create mode 100644 lib/admin/paths.ts create mode 100644 lib/auth/session-secret.ts create mode 100644 lib/setup/session.ts create mode 100644 lib/setup/state.ts create mode 100644 lib/setup/token.ts diff --git a/.env.example b/.env.example index c1733ba8..c2f2b40c 100644 --- a/.env.example +++ b/.env.example @@ -78,10 +78,27 @@ JMAP_SERVER_URL=https://your-jmap-server.com # Admin Dashboard Data # ============================================================================= -# Directory for admin dashboard state: config overrides, admin password hash, -# installed plugins/themes, and audit logs (default: ./data/admin). -# For Docker, the default resolves to /app/data/admin - mount a persistent -# volume there (see docker-compose.yml). +# Admin data is split across two directories so the config volume can be +# mounted read-only after the setup wizard completes (see issue #226). +# +# Config dir - operator-authored state. Holds config.json, policy.json, +# admin.json (passwordHash only), plugin-config/, plugins/, themes/, and +# branding uploads. Safe to mount read-only after setup. +# Default: ./data/admin (or ADMIN_DATA_DIR if that legacy variable is set) +# ADMIN_CONFIG_DIR=./data/admin +# +# State dir - runtime mutations. Holds admin-state.json (login timestamps), +# audit.log, and the bootstrap setup token. Always read-write. +# Default: ./data/admin-state (or ADMIN_DATA_DIR/state when ADMIN_DATA_DIR +# is set, for back-compat with single-volume installs) +# ADMIN_STATE_DIR=./data/admin-state +# +# Set to "true" to enforce read-only mode at the application layer (cleaner +# error than a mid-request EROFS). Pair with `:ro` on the config-volume mount. +# ADMIN_CONFIG_READONLY=true +# +# Legacy: a single dir containing both config and state. Honoured if neither +# of the split variables is set. New installs should use the split vars. # ADMIN_DATA_DIR=./data/admin # ============================================================================= diff --git a/Dockerfile b/Dockerfile index 20084be2..8e3a3e68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,7 +34,7 @@ RUN apk upgrade --no-cache && \ COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static -RUN mkdir -p /app/data/settings /app/data/admin /app/data/telemetry && chown -R nextjs:nodejs /app/data +RUN mkdir -p /app/data/settings /app/data/admin /app/data/admin-state /app/data/telemetry && chown -R nextjs:nodejs /app/data USER nextjs EXPOSE 3000 ENV PORT=3000 diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx index c1e1e752..9f57f3b3 100644 --- a/app/admin/login/page.tsx +++ b/app/admin/login/page.tsx @@ -47,13 +47,13 @@ export default function AdminLoginPage() {
-
- {logoUrl ? ( - - ) : ( + {logoUrl ? ( + + ) : ( +
- )} -
+
+ )}

Admin Dashboard

Enter your admin password to continue

diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts index 766e2110..dca051d1 100644 --- a/app/api/auth/sso/start/route.ts +++ b/app/api/auth/sso/start/route.ts @@ -7,14 +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'; +import { hasSessionSecret } from '@/lib/auth/session-secret'; 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 && !readFileEnv(process.env.SESSION_SECRET_FILE)) { + if (!hasSessionSecret()) { return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 }); } diff --git a/app/api/config/route.ts b/app/api/config/route.ts index c6f9f4f0..2640c2be 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { configManager } from '@/lib/admin/config-manager'; -import { readFileEnv } from '@/lib/read-file-env'; import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers'; +import { hasSessionSecret } from '@/lib/auth/session-secret'; /** * Runtime configuration endpoint @@ -35,8 +35,8 @@ export async function GET() { oauthOnly, oauthClientId: configManager.get('oauthClientId', ''), oauthIssuerUrl: configManager.get('oauthIssuerUrl', ''), - 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)), + rememberMeEnabled: hasSessionSecret(), + settingsSyncEnabled: configManager.get('settingsSyncEnabled', false) && hasSessionSecret(), 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 a6f6da6b..8d853a74 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -6,7 +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'; +import { hasSessionSecret } from '@/lib/auth/session-secret'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; function classifyError(error: unknown): { message: string; status: number } { @@ -50,7 +50,10 @@ function classifyError(error: unknown): { message: string; status: number } { } function isEnabled(): boolean { - return process.env.SETTINGS_SYNC_ENABLED === 'true' && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)); + const flagOn = + process.env.SETTINGS_SYNC_ENABLED === 'true' || + configManager.get('settingsSyncEnabled', false); + return flagOn && hasSessionSecret(); } /** Strip trailing slashes so differently-formatted URLs still match. */ diff --git a/app/api/setup/finish/route.ts b/app/api/setup/finish/route.ts new file mode 100644 index 00000000..4c0d11df --- /dev/null +++ b/app/api/setup/finish/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { writeFile } from 'node:fs/promises'; +import { detectSetupState } from '@/lib/setup/state'; +import { authenticateWizardRequest, SETUP_COOKIE } from '@/lib/setup/session'; +import { configManager } from '@/lib/admin/config-manager'; +import { setInitialAdminPassword } from '@/lib/admin/password'; +import { clearSetupToken } from '@/lib/setup/token'; +import { ensureConfigDir, getConfigPath } from '@/lib/admin/paths'; +import { auditLog } from '@/lib/admin/audit'; +import { logger } from '@/lib/logger'; + +export const dynamic = 'force-dynamic'; + +/** + * POST /api/setup/finish + * + * Final wizard step. Validates that required config is in place, hashes the + * admin password, marks setup complete, deletes the setup token (which + * invalidates the wizard cookie), and optionally drops a `.config-locked` + * marker so the operator remembers they intended to mount :ro. + * + * Body: { adminPassword: string, lockConfig?: boolean } + */ +export async function POST(request: NextRequest) { + if (detectSetupState() !== 'bootstrap') { + return NextResponse.json({ error: 'Setup is not active' }, { status: 404 }); + } + if (!(await authenticateWizardRequest())) { + return NextResponse.json({ error: 'Wizard session required' }, { status: 401 }); + } + + let body: { adminPassword?: unknown; lockConfig?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const adminPassword = + typeof body?.adminPassword === 'string' ? body.adminPassword : ''; + if (adminPassword.length < 8) { + return NextResponse.json( + { error: 'Admin password must be at least 8 characters' }, + { status: 400 }, + ); + } + + const lockConfig = body?.lockConfig === true; + + // Validate required config is present. + await configManager.ensureLoaded(); + const jmapUrl = configManager.get('jmapServerUrl', ''); + if (!jmapUrl || typeof jmapUrl !== 'string') { + return NextResponse.json( + { error: 'JMAP server URL is required (run the Server step first)' }, + { status: 400 }, + ); + } + + try { + // 1. Provision the admin account. Aborts cleanly if one already exists + // (defence in depth - should be impossible in bootstrap state). + const created = await setInitialAdminPassword(adminPassword); + if (!created) { + return NextResponse.json( + { error: 'Admin account already exists; cannot finish setup again' }, + { status: 409 }, + ); + } + + // 2. Persist setupComplete flag. After this, detectSetupState() flips + // to 'configured' and middleware starts 404'ing /setup paths. + await configManager.markSetupComplete(); + + // 3. Optional advisory lock marker. + if (lockConfig) { + await ensureConfigDir(); + await writeFile( + getConfigPath('.config-locked'), + new Date().toISOString(), + 'utf-8', + ); + } + + // 4. Destroy the setup token. Any other browser holding the cookie is + // now unauthenticated. + await clearSetupToken(); + + await auditLog( + 'setup.finish', + { lockConfig, jmapServerUrl: jmapUrl }, + request.headers.get('x-forwarded-for') ?? 'unknown', + ); + + const response = NextResponse.json({ ok: true, lockConfig }); + response.cookies.delete(SETUP_COOKIE); + return response; + } catch (error) { + logger.error('Wizard finish failed', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + return NextResponse.json( + { error: 'Failed to finish setup', detail: error instanceof Error ? error.message : 'Unknown' }, + { status: 500 }, + ); + } +} diff --git a/app/api/setup/status/route.ts b/app/api/setup/status/route.ts new file mode 100644 index 00000000..aa2f77f9 --- /dev/null +++ b/app/api/setup/status/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; +import { detectSetupState } from '@/lib/setup/state'; +import { authenticateWizardRequest } from '@/lib/setup/session'; +import { configManager } from '@/lib/admin/config-manager'; +import { isConfigReadOnly } from '@/lib/admin/paths'; +import { SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types'; + +export const dynamic = 'force-dynamic'; + +/** + * GET /api/setup/status - public endpoint that returns the wizard state + * and (if authenticated) the partial config saved by previous steps. The + * wizard polls this on load so a refresh resumes with prior values. + * + * Sensitive values (OAuth client secret, session secret) are NEVER sent + * back to the client - only a `HasValue` boolean. Re-entering them + * after refresh is the price of not exposing them. + */ +export async function GET() { + await configManager.ensureLoaded(); + const state = detectSetupState(); + const authenticated = state === 'bootstrap' ? await authenticateWizardRequest() : false; + + let partialConfig: Record | null = null; + if (state === 'bootstrap' && authenticated) { + // Only echo back values the operator has actually saved during the + // wizard (admin overrides). System defaults must not flow back here, + // because the wizard has its own opinionated defaults (e.g. settings + // sync on by default) that we'd otherwise stomp. + const sources = configManager.getAllWithSources(); + const safe: Record = {}; + for (const [key, info] of Object.entries(sources)) { + if (info.source !== 'admin') continue; + if (SENSITIVE_CONFIG_KEYS.has(key)) { + safe[`${key}HasValue`] = typeof info.value === 'string' && info.value.length > 0; + } else { + safe[key] = info.value; + } + } + partialConfig = safe; + } + + return NextResponse.json( + { + state, + authenticated, + readOnly: isConfigReadOnly(), + partialConfig, + }, + { headers: { 'Cache-Control': 'no-store' } }, + ); +} diff --git a/app/api/setup/step/route.ts b/app/api/setup/step/route.ts new file mode 100644 index 00000000..2dc08f67 --- /dev/null +++ b/app/api/setup/step/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { detectSetupState } from '@/lib/setup/state'; +import { authenticateWizardRequest } from '@/lib/setup/session'; +import { configManager } from '@/lib/admin/config-manager'; +import { CONFIG_ENV_MAP } from '@/lib/admin/types'; +import { parseJmapServers } from '@/lib/admin/jmap-servers'; +import { logger } from '@/lib/logger'; + +export const dynamic = 'force-dynamic'; + +/** + * Mapping of wizard-friendly step keys to the config keys they update. Each + * step's PATCH validates against this allowlist so a compromised wizard + * client can't slip in arbitrary config keys. + */ +const STEP_KEYS: Record = { + server: [ + 'appName', + 'jmapServerUrl', + 'stalwartFeaturesEnabled', + 'jmapServers', + 'jmapServerAutoPickByDomain', + ], + auth: [ + 'oauthEnabled', + 'oauthOnly', + 'oauthClientId', + 'oauthClientSecret', + 'oauthIssuerUrl', + ], + security: ['sessionSecret', 'settingsSyncEnabled'], + logging: ['logFormat', 'logLevel'], + branding: [ + 'faviconUrl', + 'appLogoLightUrl', + 'appLogoDarkUrl', + 'loginLogoLightUrl', + 'loginLogoDarkUrl', + 'loginCompanyName', + 'loginImprintUrl', + 'loginPrivacyPolicyUrl', + 'loginWebsiteUrl', + ], +}; + +/** + * POST /api/setup/step + * Body: { step: 'server' | 'auth' | ..., values: Record } + * + * Persists partial config under the admin override (config.json). Each + * step's allowed keys are restricted by STEP_KEYS so the client can only + * touch what the corresponding screen owns. + */ +export async function POST(request: NextRequest) { + if (detectSetupState() !== 'bootstrap') { + return NextResponse.json({ error: 'Setup is not active' }, { status: 404 }); + } + if (!(await authenticateWizardRequest())) { + return NextResponse.json({ error: 'Wizard session required' }, { status: 401 }); + } + + let body: { step?: unknown; values?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const step = typeof body?.step === 'string' ? body.step : ''; + const values = body?.values; + const allowedKeys = STEP_KEYS[step]; + if (!allowedKeys) { + return NextResponse.json({ error: `Unknown step: ${step}` }, { status: 400 }); + } + if (!values || typeof values !== 'object' || Array.isArray(values)) { + return NextResponse.json({ error: 'values must be an object' }, { status: 400 }); + } + + const updates: Record = {}; + for (const [key, value] of Object.entries(values as Record)) { + if (!allowedKeys.includes(key)) { + return NextResponse.json({ error: `Key not allowed in step ${step}: ${key}` }, { status: 400 }); + } + if (!(key in CONFIG_ENV_MAP)) { + return NextResponse.json({ error: `Unknown config key: ${key}` }, { status: 400 }); + } + if (key === 'jmapServers') { + // Sanitize: drop entries with bad ids, dup ids, or non-HTTP URLs + // before they're persisted. Mirrors the admin config PATCH route. + if (value != null && !Array.isArray(value)) { + return NextResponse.json({ error: 'jmapServers must be an array' }, { status: 400 }); + } + const sanitized = parseJmapServers(value); + const incomingCount = Array.isArray(value) ? value.length : 0; + if (sanitized.length !== incomingCount) { + return NextResponse.json( + { error: `One or more jmapServers entries were invalid (kept ${sanitized.length}/${incomingCount})` }, + { status: 400 }, + ); + } + updates[key] = sanitized; + continue; + } + updates[key] = value; + } + + try { + await configManager.ensureLoaded(); + await configManager.setAdminConfig(updates); + return NextResponse.json({ ok: true }); + } catch (error) { + logger.error('Wizard step save failed', { + step, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return NextResponse.json({ error: 'Failed to save step' }, { status: 500 }); + } +} diff --git a/app/api/setup/test-jmap/route.ts b/app/api/setup/test-jmap/route.ts new file mode 100644 index 00000000..76262c8f --- /dev/null +++ b/app/api/setup/test-jmap/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { detectSetupState } from '@/lib/setup/state'; +import { authenticateWizardRequest } from '@/lib/setup/session'; + +export const dynamic = 'force-dynamic'; + +const JMAP_ENDPOINTS = ['/.well-known/jmap', '/jmap/session', '/jmap']; +const FETCH_TIMEOUT_MS = 5000; + +/** + * POST /api/setup/test-jmap - server-side probe of a JMAP server. Mirrors + * the check_jmap_server() helper in setup.sh: we hit a few common session + * endpoints and look for capability strings to confirm the URL is actually + * a JMAP server (vs. a generic HTTP 200 page). + * + * Body: { url: string } + */ +export async function POST(request: NextRequest) { + if (detectSetupState() !== 'bootstrap') { + return NextResponse.json({ error: 'Setup is not active' }, { status: 404 }); + } + if (!(await authenticateWizardRequest())) { + return NextResponse.json({ error: 'Wizard session required' }, { status: 401 }); + } + + let body: { url?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const raw = typeof body?.url === 'string' ? body.url.trim() : ''; + if (!raw) { + return NextResponse.json({ error: 'url required' }, { status: 400 }); + } + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return NextResponse.json({ status: 'invalid_url', message: 'URL is not well-formed' }); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return NextResponse.json({ status: 'invalid_url', message: 'URL must use http or https' }); + } + + const base = raw.replace(/\/+$/, ''); + + for (const endpoint of JMAP_ENDPOINTS) { + const target = base + endpoint; + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + const res = await fetch(target, { + method: 'GET', + redirect: 'follow', + signal: controller.signal, + }); + clearTimeout(timer); + + if (!res.ok) continue; + const text = await res.text(); + if (looksLikeJmapSession(text)) { + return NextResponse.json({ + status: 'jmap_detected', + endpoint, + httpStatus: res.status, + }); + } + } catch { + // Try the next endpoint; we'll fall through to a final reachability + // check below if none match. + } + } + + // No JMAP session found. Was the server even reachable? + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + const res = await fetch(base, { + method: 'HEAD', + redirect: 'follow', + signal: controller.signal, + }); + clearTimeout(timer); + return NextResponse.json({ + status: 'reachable_no_jmap', + httpStatus: res.status, + message: + 'Server responded but no JMAP session was found at standard paths. ' + + 'This is OK if a reverse proxy routes JMAP separately.', + }); + } catch (error) { + return NextResponse.json({ + status: 'unreachable', + message: error instanceof Error ? error.message : 'Connection failed', + }); + } +} + +function looksLikeJmapSession(body: string): boolean { + return /"capabilities"|"apiUrl"|"downloadUrl"|"urn:ietf:params:jmap/i.test(body); +} diff --git a/app/api/setup/token/route.ts b/app/api/setup/token/route.ts new file mode 100644 index 00000000..4b22b794 --- /dev/null +++ b/app/api/setup/token/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { detectSetupState } from '@/lib/setup/state'; +import { verifySetupToken } from '@/lib/setup/token'; +import { buildSessionCookieAttributes } from '@/lib/setup/session'; + +export const dynamic = 'force-dynamic'; + +/** + * POST /api/setup/token - exchange the bootstrap token (printed to logs at + * startup) for a wizard session cookie. After this, subsequent step calls + * authenticate via the cookie instead of pasting the token every time. + * + * Body: { token: string } + */ +export async function POST(request: NextRequest) { + if (detectSetupState() !== 'bootstrap') { + return NextResponse.json({ error: 'Setup is not active' }, { status: 404 }); + } + + let body: { token?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const submitted = typeof body?.token === 'string' ? body.token.trim() : ''; + if (!submitted) { + return NextResponse.json({ error: 'Token required' }, { status: 400 }); + } + + const ok = await verifySetupToken(submitted); + if (!ok) { + // Don't differentiate between "wrong token" and "no token issued" - the + // operator either has it from the logs or they don't. + return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 }); + } + + const response = NextResponse.json({ ok: true }); + const attrs = buildSessionCookieAttributes(); + response.cookies.set(attrs.name, submitted, { + httpOnly: attrs.httpOnly, + sameSite: attrs.sameSite, + secure: attrs.secure, + path: attrs.path, + maxAge: attrs.maxAge, + }); + return response; +} diff --git a/app/setup/layout.tsx b/app/setup/layout.tsx new file mode 100644 index 00000000..61e6465b --- /dev/null +++ b/app/setup/layout.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from 'react'; + +export default function SetupLayout({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/app/setup/page.tsx b/app/setup/page.tsx new file mode 100644 index 00000000..52952cab --- /dev/null +++ b/app/setup/page.tsx @@ -0,0 +1,1288 @@ +'use client'; + +import { useEffect, useState, type FormEvent, type ReactNode } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { apiFetch } from '@/lib/browser-navigation'; + +type State = 'bootstrap' | 'configured' | 'env-managed'; + +interface StatusResponse { + state: State; + authenticated: boolean; + readOnly: boolean; + partialConfig: Record | null; +} + +interface JmapServerRow { + id: string; + label: string; + url: string; + /** comma-separated, parsed before save */ + domains: string; +} + +interface WizardConfig { + // Server + appName: string; + jmapServerUrl: string; + stalwartFeaturesEnabled: boolean; + jmapServers: JmapServerRow[]; + jmapServerAutoPickByDomain: boolean; + // Auth + oauthEnabled: boolean; + oauthOnly: boolean; + oauthClientId: string; + oauthClientSecret: string; + oauthIssuerUrl: string; + // Security + sessionSecret: string; + settingsSyncEnabled: boolean; + // Logging + logFormat: 'text' | 'json'; + logLevel: 'error' | 'warn' | 'info' | 'debug'; + // Branding + faviconUrl: string; + appLogoLightUrl: string; + appLogoDarkUrl: string; + loginLogoLightUrl: string; + loginLogoDarkUrl: string; + loginCompanyName: string; + loginImprintUrl: string; + loginPrivacyPolicyUrl: string; + loginWebsiteUrl: string; +} + +const EMPTY_CONFIG: WizardConfig = { + appName: 'Bulwark Webmail', + jmapServerUrl: '', + stalwartFeaturesEnabled: true, + jmapServers: [], + jmapServerAutoPickByDomain: false, + oauthEnabled: false, + oauthOnly: false, + oauthClientId: '', + oauthClientSecret: '', + oauthIssuerUrl: '', + sessionSecret: '', + settingsSyncEnabled: true, + logFormat: 'text', + logLevel: 'info', + faviconUrl: '', + appLogoLightUrl: '', + appLogoDarkUrl: '', + loginLogoLightUrl: '', + loginLogoDarkUrl: '', + loginCompanyName: '', + loginImprintUrl: '', + loginPrivacyPolicyUrl: '', + loginWebsiteUrl: '', +}; + +const STEPS = [ + { id: 'welcome', label: 'Welcome' }, + { id: 'server', label: 'Server' }, + { id: 'auth', label: 'Auth' }, + { id: 'security', label: 'Security' }, + { id: 'logging', label: 'Logging' }, + { id: 'branding', label: 'Branding' }, + { id: 'review', label: 'Review' }, +] as const; + +export default function SetupWizardPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const [bootstrapping, setBootstrapping] = useState(true); + const [error, setError] = useState(null); + const [state, setState] = useState('bootstrap'); + const [authenticated, setAuthenticated] = useState(false); + const [readOnly, setReadOnly] = useState(false); + const [config, setConfig] = useState(EMPTY_CONFIG); + const [stepIndex, setStepIndex] = useState(0); + const [completed, setCompleted] = useState(false); + + // ─── Initial status load ──────────────────────────────────────────────── + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await apiFetch('/api/setup/status', { cache: 'no-store' }); + const data = (await res.json()) as StatusResponse; + if (cancelled) return; + + setState(data.state); + setReadOnly(data.readOnly); + + if (data.state === 'configured' || data.state === 'env-managed') { + // Wizard not active - bounce to login. Middleware will 404 us + // before we get here in practice, but defensive. + router.replace('/'); + return; + } + + setAuthenticated(data.authenticated); + if (data.partialConfig) { + setConfig((prev) => mergePartial(prev, data.partialConfig!)); + // If auth is OK and we already have a JMAP URL persisted, jump + // ahead to the next unfilled step. + if (data.authenticated && data.partialConfig.jmapServerUrl) { + setStepIndex(2); + } else if (data.authenticated) { + setStepIndex(1); + } + } + } catch (e) { + if (!cancelled) setError(humanError(e)); + } finally { + if (!cancelled) setBootstrapping(false); + } + })(); + return () => { + cancelled = true; + }; + }, [router]); + + // ─── Token submit (welcome step) ──────────────────────────────────────── + async function submitToken(token: string) { + setError(null); + const res = await apiFetch('/api/setup/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error ?? `Token rejected (HTTP ${res.status})`); + } + setAuthenticated(true); + setStepIndex(1); + } + + // ─── Step persistence ─────────────────────────────────────────────────── + async function saveStep(step: string, values: Record) { + const res = await apiFetch('/api/setup/step', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ step, values }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error ?? `Step save failed (HTTP ${res.status})`); + } + } + + // ─── Render shell ─────────────────────────────────────────────────────── + if (bootstrapping) { + return

Loading…

; + } + + if (completed) { + return ; + } + + if (state !== 'bootstrap') { + return ; + } + + if (readOnly) { + return ( + +

Configuration is read-only

+

+ The config volume is mounted read-only or ADMIN_CONFIG_READONLY is set. + Remount it read-write or unset that variable, then restart the container. +

+
+ ); + } + + return ( +
+
+
+ + +
+ {error && setError(null)} />} + + {!authenticated ? ( + { + try { + await submitToken(t); + } catch (e) { + setError(humanError(e)); + } + }} + /> + ) : ( + { + try { + await saveStep(step, values); + setStepIndex((i) => Math.min(i + 1, STEPS.length - 1)); + } catch (e) { + setError(humanError(e)); + } + }} + onBack={() => setStepIndex((i) => Math.max(i - 1, 1))} + onFinish={() => { + setCompleted(true); + // Hard navigation after a beat — gives the user a moment + // to see the success screen and works around any router + // edge cases that swallow client-side replaces after the + // setupComplete flag flips. + setTimeout(() => { + window.location.assign('/admin/login'); + }, 1500); + }} + /> + )} +
+
+
+ ); +} + +// ─── Layout helpers ─────────────────────────────────────────────────────── + +function Header() { + return ( +
+

Bulwark Webmail Setup

+

+ Configure your webmail instance from the browser. +

+
+ ); +} + +function ProgressBar({ stepIndex }: { stepIndex: number }) { + return ( +
+
+ {STEPS.map((step, i) => ( +
+
+
+ {step.label} +
+
+ ))} +
+
+ ); +} + +function CompletedScreen() { + return ( + +
+
+ + + +
+

You're all set!

+

+ Bulwark Webmail is configured and ready to use. +

+
+ +

+ Taking you to the admin dashboard… +

+
+ ); +} + +function AlreadyConfiguredScreen() { + return ( + +
+
+ + + +
+

Setup is already complete

+

+ Bulwark Webmail is configured. Sign in to continue. +

+
+ +
+ ); +} + +function CenteredCard({ children }: { children: ReactNode }) { + return ( +
+
+ {children} +
+
+ ); +} + +function ErrorBanner({ error, onDismiss }: { error: string; onDismiss: () => void }) { + return ( +
+ {error} + +
+ ); +} + +// ─── Welcome / token step ──────────────────────────────────────────────── + +function WelcomeStep({ tokenFromUrl, onSubmit }: { tokenFromUrl: string; onSubmit: (t: string) => Promise }) { + const [token, setToken] = useState(tokenFromUrl); + const [submitting, setSubmitting] = useState(false); + + // Auto-submit if token came in via URL. + useEffect(() => { + if (tokenFromUrl && !submitting) { + setSubmitting(true); + onSubmit(tokenFromUrl).finally(() => setSubmitting(false)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tokenFromUrl]); + + async function handle(e: FormEvent) { + e.preventDefault(); + setSubmitting(true); + try { + await onSubmit(token.trim()); + } finally { + setSubmitting(false); + } + } + + return ( +
+

Welcome

+

+ Paste the setup token printed in the container logs to continue. The token expires after 1 hour. +

+ + setToken(v)} autoFocus required placeholder="32-byte hex token" /> + + + {submitting ? 'Verifying…' : 'Continue'} + +
+ ); +} + +// ─── Step router ───────────────────────────────────────────────────────── + +interface StepProps { + stepIndex: number; + config: WizardConfig; + setConfig: React.Dispatch>; + onNext: (step: string, values: Record) => Promise; + onBack: () => void; + onFinish: () => void; +} + +function StepContent({ stepIndex, config, setConfig, onNext, onBack, onFinish }: StepProps) { + switch (stepIndex) { + case 1: + return ; + case 2: + return ; + case 3: + return ; + case 4: + return ; + case 5: + return ; + case 6: + return ; + default: + return

Loading…

; + } +} + +// ─── Server step ───────────────────────────────────────────────────────── + +function ServerStep({ config, setConfig, onNext }: Pick) { + const [submitting, setSubmitting] = useState(false); + const [probe, setProbe] = useState(null); + const [probing, setProbing] = useState(false); + + async function testJmap() { + setProbe(null); + setProbing(true); + try { + const res = await apiFetch('/api/setup/test-jmap', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: config.jmapServerUrl }), + }); + const data = await res.json(); + if (data.status === 'jmap_detected') { + setProbe(`JMAP server confirmed at ${data.endpoint}`); + } else if (data.status === 'reachable_no_jmap') { + setProbe(`Server reachable (HTTP ${data.httpStatus}) but no JMAP session found at standard paths.`); + } else { + setProbe(data.message ?? 'Could not reach server.'); + } + } catch (e) { + setProbe(humanError(e)); + } finally { + setProbing(false); + } + } + + const [showAdditional, setShowAdditional] = useState(config.jmapServers.length > 0); + + function updateRow(index: number, patch: Partial) { + setConfig({ + ...config, + jmapServers: config.jmapServers.map((row, i) => (i === index ? { ...row, ...patch } : row)), + }); + } + + function addRow() { + setConfig({ + ...config, + jmapServers: [...config.jmapServers, { id: '', label: '', url: '', domains: '' }], + }); + setShowAdditional(true); + } + + function removeRow(index: number) { + setConfig({ + ...config, + jmapServers: config.jmapServers.filter((_, i) => i !== index), + }); + } + + // Validate the multi-server rows: each must have a unique id matching the + // schema, a usable URL, and no collision with the primary server. + const rowErrors: string[] = []; + const seenIds = new Set(); + for (let i = 0; i < config.jmapServers.length; i++) { + const r = config.jmapServers[i]; + const id = r.id.trim(); + if (!id) { + rowErrors.push(`Server #${i + 1}: id is required`); + } else if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(id)) { + rowErrors.push(`Server #${i + 1}: id must be alphanumeric (with - or _), starting with a letter or digit`); + } else if (seenIds.has(id)) { + rowErrors.push(`Server #${i + 1}: id "${id}" is duplicated`); + } else { + seenIds.add(id); + } + const url = r.url.trim(); + if (!url) { + rowErrors.push(`Server #${i + 1}: url is required`); + } else if (!/^https?:\/\//i.test(url)) { + rowErrors.push(`Server #${i + 1}: url must start with http:// or https://`); + } + } + const hasRowErrors = rowErrors.length > 0; + + async function handle(e: FormEvent) { + e.preventDefault(); + if (hasRowErrors) return; + setSubmitting(true); + try { + await onNext('server', { + appName: config.appName, + jmapServerUrl: config.jmapServerUrl, + stalwartFeaturesEnabled: config.stalwartFeaturesEnabled, + // The API route runs parseJmapServers on this; we pre-canonicalize + // here so the round-trip is clean. + jmapServers: rowsToCanonical(config.jmapServers), + jmapServerAutoPickByDomain: config.jmapServerAutoPickByDomain, + }); + } finally { + setSubmitting(false); + } + } + + return ( +
+ + + setConfig({ ...config, appName: v })} required /> + + +
+ setConfig({ ...config, jmapServerUrl: v })} + required + placeholder="https://" + type="url" + /> + +
+ {probe &&

{probe}

} +
+ + {/* Additional servers (optional) */} +
+
+
+
Additional JMAP servers
+

+ Optional. Surface multiple servers in the login dropdown - useful for hosts running several Stalwart instances. +

+
+ +
+ + {showAdditional && ( +
+ {config.jmapServers.map((row, i) => ( +
+
+ Server #{i + 1} + +
+
+ + updateRow(i, { id: v })} placeholder="eu-1" required /> + + + updateRow(i, { label: v })} placeholder="Europe (primary)" /> + +
+ + updateRow(i, { url: v })} placeholder="https://" type="url" required /> + + + updateRow(i, { domains: v })} placeholder="example.com, mail.example.com" /> + +
+ ))} + + + + {config.jmapServers.length > 0 && ( + setConfig({ ...config, jmapServerAutoPickByDomain: v })} + label="Auto-pick server by email domain" + hint="When a user types their email, automatically select the matching server from the list above." + /> + )} + + {hasRowErrors && ( +
    + {rowErrors.map((err, i) => ( +
  • {err}
  • + ))} +
+ )} +
+ )} +
+ + setConfig({ ...config, stalwartFeaturesEnabled: v })} + label="Enable Stalwart-specific features" + hint="Adds password change and Sieve filter management. Safe to enable on non-Stalwart servers." + /> + +
+ + {submitting ? 'Saving…' : 'Next'} + +
+ + ); +} + +// ─── Auth step ─────────────────────────────────────────────────────────── + +function AuthStep({ config, setConfig, onNext, onBack }: Pick) { + const [submitting, setSubmitting] = useState(false); + + async function handle(e: FormEvent) { + e.preventDefault(); + setSubmitting(true); + try { + const values: Partial = { oauthEnabled: config.oauthEnabled }; + if (config.oauthEnabled) { + values.oauthOnly = config.oauthOnly; + values.oauthClientId = config.oauthClientId; + values.oauthIssuerUrl = config.oauthIssuerUrl; + if (config.oauthClientSecret) { + values.oauthClientSecret = config.oauthClientSecret; + } + } + await onNext('auth', values); + } finally { + setSubmitting(false); + } + } + + return ( +
+ + setConfig({ ...config, oauthEnabled: v })} + label="Enable OAuth2 / OpenID Connect" + /> + {config.oauthEnabled && ( + <> + setConfig({ ...config, oauthOnly: v })} + label="OAuth-only mode (hide password form)" + /> + + setConfig({ ...config, oauthClientId: v })} required /> + + + setConfig({ ...config, oauthClientSecret: v })} + type="password" + placeholder="paste secret" + /> + + + setConfig({ ...config, oauthIssuerUrl: v })} type="url" /> + + + )} +
+ Back + + {submitting ? 'Saving…' : 'Next'} + +
+ + ); +} + +// ─── Security step ─────────────────────────────────────────────────────── + +function generateSessionSecret(): string { + // 32 random bytes, base64-encoded - same shape as `openssl rand -base64 32`. + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin); +} + +function SecurityStep({ config, setConfig, onNext, onBack }: Pick) { + const [submitting, setSubmitting] = useState(false); + const [reveal, setReveal] = useState(false); + const [customize, setCustomize] = useState(false); + + // Auto-generate on first render so the operator doesn't have to click a + // button for the recommended path. They can still regenerate or paste + // their own via the "Customize" toggle. + useEffect(() => { + if (!config.sessionSecret) { + setConfig((prev) => ({ ...prev, sessionSecret: generateSessionSecret() })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function handle(e: FormEvent) { + e.preventDefault(); + setSubmitting(true); + try { + const values: Partial = { + settingsSyncEnabled: config.settingsSyncEnabled, + }; + if (config.sessionSecret) values.sessionSecret = config.sessionSecret; + await onNext('security', values); + } finally { + setSubmitting(false); + } + } + + return ( +
+ + +
+
+ Session secret generated + +
+

+ A 32-byte secret was created for you. You only need to change this if you have a specific reason. +

+
+ + {customize && ( + +
+ setConfig({ ...config, sessionSecret: v })} + type={reveal ? 'text' : 'password'} + /> + + +
+
+ )} + + setConfig({ ...config, settingsSyncEnabled: v })} + label="Sync user settings across devices" + hint="Stores user preferences server-side, encrypted with the session secret." + disabled={!config.sessionSecret} + /> +
+ Back + + {submitting ? 'Saving…' : 'Next'} + +
+ + ); +} + +// ─── Logging step ──────────────────────────────────────────────────────── + +function LoggingStep({ config, setConfig, onNext, onBack }: Pick) { + const [submitting, setSubmitting] = useState(false); + async function handle(e: FormEvent) { + e.preventDefault(); + setSubmitting(true); + try { + await onNext('logging', { logFormat: config.logFormat, logLevel: config.logLevel }); + } finally { + setSubmitting(false); + } + } + return ( +
+ + + setConfig({ ...config, logLevel: v as WizardConfig['logLevel'] })} + options={[ + { value: 'error', label: 'error' }, + { value: 'warn', label: 'warn' }, + { value: 'info', label: 'info (recommended)' }, + { value: 'debug', label: 'debug' }, + ]} + /> + +
+ Back + + {submitting ? 'Saving…' : 'Next'} + +
+ + ); +} + +// ─── Branding step ─────────────────────────────────────────────────────── + +function BrandingStep({ config, setConfig, onNext, onBack }: Pick) { + const [submitting, setSubmitting] = useState(false); + async function handle(e: FormEvent) { + e.preventDefault(); + setSubmitting(true); + try { + // Only send fields the operator actually filled in. Saving an empty + // string would create an admin override that shadows the system + // default — a blank "Login logo" field would suppress the default + // Bulwark logo on the login page, which is never what we want from + // the wizard. + const allFields = { + faviconUrl: config.faviconUrl, + appLogoLightUrl: config.appLogoLightUrl, + appLogoDarkUrl: config.appLogoDarkUrl, + loginLogoLightUrl: config.loginLogoLightUrl, + loginLogoDarkUrl: config.loginLogoDarkUrl, + loginCompanyName: config.loginCompanyName, + loginImprintUrl: config.loginImprintUrl, + loginPrivacyPolicyUrl: config.loginPrivacyPolicyUrl, + loginWebsiteUrl: config.loginWebsiteUrl, + }; + const values: Record = {}; + for (const [k, v] of Object.entries(allFields)) { + if (v.trim() !== '') values[k] = v.trim(); + } + await onNext('branding', values); + } finally { + setSubmitting(false); + } + } + return ( +
+ + + setConfig({ ...config, loginCompanyName: v })} /> + + + setConfig({ ...config, faviconUrl: v })} /> + +
+ + setConfig({ ...config, loginLogoLightUrl: v })} /> + + + setConfig({ ...config, loginLogoDarkUrl: v })} /> + + + setConfig({ ...config, appLogoLightUrl: v })} /> + + + setConfig({ ...config, appLogoDarkUrl: v })} /> + +
+ + setConfig({ ...config, loginWebsiteUrl: v })} type="url" /> + + + setConfig({ ...config, loginImprintUrl: v })} type="url" /> + + + setConfig({ ...config, loginPrivacyPolicyUrl: v })} type="url" /> + +
+ Back + + {submitting ? 'Saving…' : 'Next'} + +
+ + ); +} + +// ─── Review / finish step ───────────────────────────────────────────────── + +function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack: () => void; onFinish: () => void }) { + const [adminPassword, setAdminPassword] = useState(''); + const [adminConfirm, setAdminConfirm] = useState(''); + const [lockConfig, setLockConfig] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [localError, setLocalError] = useState(null); + + async function handle(e: FormEvent) { + e.preventDefault(); + setLocalError(null); + if (adminPassword.length < 8) { + setLocalError('Admin password must be at least 8 characters.'); + return; + } + if (adminPassword !== adminConfirm) { + setLocalError('Passwords do not match.'); + return; + } + setSubmitting(true); + try { + const res = await apiFetch('/api/setup/finish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ adminPassword, lockConfig }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setLocalError(data.error ?? `Finish failed (HTTP ${res.status})`); + return; + } + onFinish(); + } catch (e) { + setLocalError(humanError(e)); + } finally { + setSubmitting(false); + } + } + + return ( +
+ +
+ + + {config.jmapServers.length > 0 && ( + s.id).join(', ') + + (config.jmapServerAutoPickByDomain ? ' (auto-pick by domain)' : '') + } + /> + )} + + + + + + +
+ + + + + + + + + + + {localError && ( +

{localError}

+ )} + +
+ Back + + {submitting ? 'Applying…' : 'Apply & Finish'} + +
+ + ); +} + +// ─── Atoms ──────────────────────────────────────────────────────────────── + +function StepHeader({ title, subtitle }: { title: string; subtitle?: string }) { + return ( +
+

{title}

+ {subtitle &&

{subtitle}

} +
+ ); +} + +function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ); +} + +function Input({ + value, + onChange, + type = 'text', + placeholder, + required, + autoFocus, +}: { + value: string; + onChange: (v: string) => void; + type?: string; + placeholder?: string; + required?: boolean; + autoFocus?: boolean; +}) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + required={required} + autoFocus={autoFocus} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + ); +} + +function Select({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: { value: string; label: string }[] }) { + return ( + + ); +} + +function Toggle({ + checked, + onChange, + label, + hint, + disabled, +}: { + checked: boolean; + onChange: (v: boolean) => void; + label: string; + hint?: string; + disabled?: boolean; +}) { + return ( + + ); +} + +function Footer({ children }: { children: ReactNode }) { + return
{children}
; +} + +function PrimaryButton({ children, ...rest }: React.ButtonHTMLAttributes) { + return ( + + ); +} + +function SecondaryButton({ children, onClick, disabled }: { children: ReactNode; onClick: () => void; disabled?: boolean }) { + return ( + + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +// ─── Helpers ────────────────────────────────────────────────────────────── + +function mergePartial(prev: WizardConfig, partial: Record): WizardConfig { + const next: WizardConfig = { ...prev }; + for (const key of Object.keys(prev) as (keyof WizardConfig)[]) { + const incoming = partial[key]; + if (incoming === undefined) continue; + if (key === 'jmapServers') { + // Server stores canonical shape; wizard form uses csv domains string. + next.jmapServers = canonicalToRows(incoming); + continue; + } + if (typeof incoming === typeof prev[key] || prev[key] === '' || prev[key] === false) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (next as any)[key] = incoming; + } + } + return next; +} + +function canonicalToRows(value: unknown): JmapServerRow[] { + if (!Array.isArray(value)) return []; + return value + .map((item): JmapServerRow | null => { + if (!item || typeof item !== 'object') return null; + const e = item as Record; + const id = typeof e.id === 'string' ? e.id : ''; + const label = typeof e.label === 'string' ? e.label : ''; + const url = typeof e.url === 'string' ? e.url : ''; + const domains = Array.isArray(e.domains) + ? (e.domains as unknown[]) + .filter((d): d is string => typeof d === 'string') + .join(', ') + : ''; + if (!id || !url) return null; + return { id, label, url, domains }; + }) + .filter((r): r is JmapServerRow => r !== null); +} + +function rowsToCanonical(rows: JmapServerRow[]) { + return rows + .map((r) => { + const id = r.id.trim(); + const url = r.url.trim(); + if (!id || !url) return null; + const domains = r.domains + .split(',') + .map((d) => d.trim()) + .filter(Boolean); + return { + id, + label: r.label.trim() || id, + url, + ...(domains.length > 0 ? { domains } : {}), + }; + }) + .filter((e): e is { id: string; label: string; url: string; domains?: string[] } => e !== null); +} + +function hasAnyBranding(c: WizardConfig): boolean { + return Boolean( + c.loginCompanyName || + c.faviconUrl || + c.appLogoLightUrl || + c.appLogoDarkUrl || + c.loginLogoLightUrl || + c.loginLogoDarkUrl || + c.loginWebsiteUrl || + c.loginImprintUrl || + c.loginPrivacyPolicyUrl, + ); +} + +function humanError(e: unknown): string { + if (e instanceof Error) return e.message; + if (typeof e === 'string') return e; + return 'Unknown error'; +} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 24fda38b..be57fd10 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -281,7 +281,7 @@ export function EmailComposer({ const currentIdentity = selectedIdentityId ? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity : primaryIdentity; - // Alias identities often lack a configured signature — fall back to the primary + // Alias identities often lack a configured signature - fall back to the primary // identity's signature so replies (which auto-select a matching alias) still // populate the user's signature. const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 1caac5ee..5f6fd253 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -2296,7 +2296,7 @@ export function EmailViewer({ htmlContent = email.bodyValues[email.htmlBody[0].partId].value; // Per RFC 8621 § 4.1.4, when a message has only one alternative the server // exposes the same part in both htmlBody and textBody. The shared part may - // actually be text/plain (plain-text-only mail) — rendering that as HTML + // actually be text/plain (plain-text-only mail) - rendering that as HTML // collapses newlines and skips linkification, so route by the part's type. const htmlPart = email.htmlBody[0]; if (htmlPart.type && htmlPart.type.toLowerCase() !== 'text/html') { @@ -2691,7 +2691,7 @@ export function EmailViewer({ // double re-inverting images nested inside those containers. // Nested bgcolor containers must NOT add another invert layer: each filter // toggles the inversion, so an odd number of stacked filters (e.g. body + - // outer bgcolor table + inner bgcolor table) produces an inverted result — + // outer bgcolor table + inner bgcolor table) produces an inverted result - // i.e. light-on-light. The second rule disables filter on bgcolor-like // elements that are descendants of another bgcolor-like element. const darkModeCSS = isDark && !emailHasNativeDarkMode ? ` @@ -2888,7 +2888,7 @@ export function EmailViewer({ // Re-invert emoji glyphs so they keep their original colors. The // body's invert filter flips colored emoji (yellow smiley → blue, // red heart → cyan, etc.). Wrap each emoji run in a span that - // re-inverts. Only act when the ancestor invert depth is odd — + // re-inverts. Only act when the ancestor invert depth is odd - // emojis inside a double-inverted bgcolor container already render // at their original colors. let emojiRe: RegExp; diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 1d0843f0..61577091 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -331,7 +331,7 @@ function EmailCard({ htmlContent = email.bodyValues[email.htmlBody[0].partId].value; // Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting). // Server-generated HTML from text/plain emails often lacks
tags, collapsing newlines. - // Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody — + // Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody - // in that case there is no real plain-text alternative, so always render the HTML. const textPartId = email.textBody?.[0]?.partId; const htmlPartId = email.htmlBody[0].partId; diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index 48df8979..b0764e98 100644 --- a/components/layout/account-switcher.tsx +++ b/components/layout/account-switcher.tsx @@ -114,7 +114,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher setDefaultAccount(accountId); }; - // Show the account's own identity, not the preferred sending identity — + // Show the account's own identity, not the preferred sending identity - // primaryIdentity can be an alias (e.g. info@korazo.net) that differs from // the actually logged-in account (info@linusrath.de). const displayName = activeAccount?.displayName || activeAccount?.label || ""; diff --git a/docker-compose.yml b/docker-compose.yml index ddf1a798..928b41b1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,8 +11,13 @@ services: volumes: # Encrypted user settings (SETTINGS_DATA_DIR). - bulwark-settings:/app/data/settings - # Admin dashboard state: config, password hash, plugins, audit logs (ADMIN_DATA_DIR). + # Admin configuration: config.json, policy.json, admin.json (passwordHash), + # plugins, themes, branding uploads (ADMIN_CONFIG_DIR). Can be mounted + # read-only after running the setup wizard - append `:ro` to lock it. - bulwark-admin:/app/data/admin + # Admin runtime state: admin-state.json (login timestamps), audit.log, + # setup token (ADMIN_STATE_DIR). Always read-write. + - bulwark-admin-state:/app/data/admin-state # Anonymous telemetry: instance id, consent state, login HMACs (TELEMETRY_DATA_DIR). # Persisting this preserves the admin's consent choice and stable instance id across upgrades. - bulwark-telemetry:/app/data/telemetry @@ -35,4 +40,5 @@ services: volumes: bulwark-settings: bulwark-admin: + bulwark-admin-state: bulwark-telemetry: diff --git a/instrumentation.node.ts b/instrumentation.node.ts index d4e1525e..13438a52 100644 --- a/instrumentation.node.ts +++ b/instrumentation.node.ts @@ -1,6 +1,9 @@ import { readFileSync } from "fs"; import { configManager } from "./lib/admin/config-manager"; import { initAdminPassword } from "./lib/admin/password"; +import { migrateLegacyAdminLayout } from "./lib/admin/migrate"; +import { detectSetupState } from "./lib/setup/state"; +import { ensureSetupToken } from "./lib/setup/token"; const pkg = JSON.parse( readFileSync(`${process.cwd()}/package.json`, "utf-8") @@ -8,11 +11,36 @@ const pkg = JSON.parse( const current: string = pkg.version ?? "0.0.0"; console.info(`Bulwark Webmail v${current}`); -// Initialize admin config and password bootstrap -configManager.load() +// Initialize admin config and password bootstrap. Migration runs first so +// existing v1 layouts are split before anything reads admin.json. +migrateLegacyAdminLayout() + .then(() => configManager.load()) .then(() => initAdminPassword()) - .then(() => { + .then(async () => { console.info("Admin dashboard initialized"); + // If we're in bootstrap state (no JMAP_SERVER_URL env and no + // setupComplete in config.json), generate/refresh the setup token and + // print it to the logs so the operator can complete the web wizard + // without execing into the container. + if (detectSetupState() === "bootstrap") { + try { + const token = await ensureSetupToken(); + const port = process.env.PORT || "3000"; + console.info(""); + console.info("=============================================================="); + console.info(" SETUP REQUIRED"); + console.info(` Token: ${token}`); + console.info(` Open: http://:${port}/setup?token=${token}`); + console.info(" Token expires in 1 hour. Restart the container to reissue."); + console.info("=============================================================="); + console.info(""); + } catch (err) { + console.warn( + "Failed to issue setup token:", + err instanceof Error ? err.message : err, + ); + } + } }) .then(async () => { // Anonymous telemetry - on by default. Admins can disable via the diff --git a/lib/account-utils.ts b/lib/account-utils.ts index b9e95cf6..ea63020f 100644 --- a/lib/account-utils.ts +++ b/lib/account-utils.ts @@ -65,7 +65,7 @@ export function getAccountScopedKey(baseKey: string, accountId: string): string /** * Hard upper bound on cookie slots. Each slot can hold up to ~3 cookies * (session, refresh token, server id, auth context), so 50 slots ≈ 125 - * cookies on average — within Firefox's per-domain limit of 150. + * cookies on average - within Firefox's per-domain limit of 150. */ export const MAX_ACCOUNT_SLOTS = 50; @@ -83,7 +83,7 @@ export const MAX_ACCOUNTS_HTTP1 = 5; * We walk recent resource-timing entries and treat a single h2/h3 sighting * as a positive signal. Cross-origin entries may report an empty * `nextHopProtocol` without `Timing-Allow-Origin`, in which case we - * under-detect and fall back to the conservative cap — that's safe. + * under-detect and fall back to the conservative cap - that's safe. */ export function isHttp2Available(): boolean { if (typeof performance === 'undefined') return false; diff --git a/lib/admin/audit.ts b/lib/admin/audit.ts index 9b3a6ded..0c0bb73e 100644 --- a/lib/admin/audit.ts +++ b/lib/admin/audit.ts @@ -1,28 +1,23 @@ -import { appendFile, stat, rename, mkdir } from 'node:fs/promises'; +import { appendFile, stat, rename, readFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; -import path from 'node:path'; import { logger } from '@/lib/logger'; +import { ensureStateDir, getStatePath } from './paths'; import type { AuditEntry } from './types'; const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB const MAX_ROTATIONS = 3; - -function getAdminDir(): string { - return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin'); -} +const AUDIT_LOG_FILE = 'audit.log'; function getAuditLogPath(): string { - return path.join(getAdminDir(), 'audit.log'); + return getStatePath(AUDIT_LOG_FILE); } /** - * Append an audit entry to the admin audit log. + * Append an audit entry to the admin audit log. Stored under the state dir + * so it remains writable when the config dir is mounted read-only. */ export async function auditLog(action: string, detail: Record, ip: string): Promise { - const dir = getAdminDir(); - if (!existsSync(dir)) { - await mkdir(dir, { recursive: true }); - } + await ensureStateDir(); const entry: AuditEntry = { ts: new Date().toISOString(), @@ -64,7 +59,6 @@ async function rotateIfNeeded(logPath: string): Promise { export async function readAuditLog(page: number = 1, limit: number = 50, actionFilter?: string): Promise<{ entries: AuditEntry[]; total: number }> { const logPath = getAuditLogPath(); try { - const { readFile } = await import('node:fs/promises'); const content = await readFile(logPath, 'utf-8'); const lines = content.trim().split('\n').filter(Boolean); @@ -77,7 +71,6 @@ export async function readAuditLog(page: number = 1, limit: number = 50, actionF } const total = entries.length; - // Return newest first entries.reverse(); const start = (page - 1) * limit; return { entries: entries.slice(start, start + limit), total }; diff --git a/lib/admin/config-manager.ts b/lib/admin/config-manager.ts index fa7df72c..1e3e278d 100644 --- a/lib/admin/config-manager.ts +++ b/lib/admin/config-manager.ts @@ -1,13 +1,8 @@ -import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; +import { readFile, writeFile, rename } from 'node:fs/promises'; import { logger } from '@/lib/logger'; import { readFileEnv } from '@/lib/read-file-env'; import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types'; - -function getAdminDir(): string { - return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin'); -} +import { ensureConfigDir, getConfigPath, assertWritable } from './paths'; function parseEnvValue(value: string, type: string): unknown { switch (type) { @@ -127,6 +122,7 @@ class ConfigManager { * Update admin config overrides. Writes to disk. */ async setAdminConfig(updates: Record): Promise { + assertWritable('update admin config'); Object.assign(this.adminConfig, updates); await this.writeJsonFile('config.json', this.adminConfig); } @@ -135,10 +131,29 @@ class ConfigManager { * Remove an admin override, reverting to env/default. */ async removeAdminOverride(key: string): Promise { + assertWritable('remove admin override'); delete this.adminConfig[key]; await this.writeJsonFile('config.json', this.adminConfig); } + /** + * Whether the setup wizard has completed. Used by middleware to gate the + * /setup routes and the rest of the app. + */ + isSetupComplete(): boolean { + return this.adminConfig.setupComplete === true; + } + + /** + * Mark setup wizard as complete. Called by the wizard's finish endpoint + * after all other config has been written. Refuses in read-only mode. + */ + async markSetupComplete(): Promise { + assertWritable('mark setup complete'); + this.adminConfig.setupComplete = true; + await this.writeJsonFile('config.json', this.adminConfig); + } + /** * Get the current settings policy. */ @@ -150,6 +165,7 @@ class ConfigManager { * Update the settings policy. Writes to disk. */ async setPolicy(policy: SettingsPolicy): Promise { + assertWritable('update settings policy'); this.policyCache = { ...DEFAULT_POLICY, ...policy, @@ -167,7 +183,7 @@ class ConfigManager { } private async readJsonFile(filename: string): Promise | null> { - const filePath = path.join(getAdminDir(), filename); + const filePath = getConfigPath(filename); try { const raw = await readFile(filePath, 'utf-8'); return JSON.parse(raw); @@ -179,15 +195,21 @@ class ConfigManager { } private async writeJsonFile(filename: string, data: Record): Promise { - const dir = getAdminDir(); - if (!existsSync(dir)) { - await mkdir(dir, { recursive: true }); - } - const targetPath = path.join(dir, filename); + await ensureConfigDir(); + const targetPath = getConfigPath(filename); const tmpPath = targetPath + '.tmp'; await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8'); await rename(tmpPath, targetPath); } } -export const configManager = new ConfigManager(); +// Stash the singleton on globalThis so HMR / multiple module-evaluation +// boundaries (middleware vs route handlers in dev with turbopack) all share +// the same in-memory state. Without this, marking setupComplete=true in a +// route handler is invisible to the next middleware run, and the wizard +// redirect after finish never fires. +const SINGLETON_KEY = Symbol.for('bulwark.admin.configManager'); +type GlobalWithConfig = typeof globalThis & { [SINGLETON_KEY]?: ConfigManager }; +const g = globalThis as GlobalWithConfig; +export const configManager: ConfigManager = + g[SINGLETON_KEY] ?? (g[SINGLETON_KEY] = new ConfigManager()); diff --git a/lib/admin/migrate.ts b/lib/admin/migrate.ts new file mode 100644 index 00000000..39d7b2be --- /dev/null +++ b/lib/admin/migrate.ts @@ -0,0 +1,196 @@ +import { readFile, writeFile, rename, stat, unlink } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { logger } from '@/lib/logger'; +import { + ensureConfigDir, + ensureStateDir, + getConfigPath, + getStatePath, + isConfigReadOnly, +} from './paths'; +import type { AdminConfigData, AdminStateData } from './types'; + +const MIGRATION_MARKER = '.migrated-v2'; + +interface LegacyAdminData { + passwordHash: string; + createdAt?: string; + lastLogin?: string | null; + passwordChangedAt?: string; +} + +/** + * One-shot migration from the v1 layout (everything mixed in `data/admin/`) + * to the v2 layout (config + state split, see lib/admin/paths.ts). + * + * Idempotent: writes a `.migrated-v2` marker into the config dir on success. + * + * Migrations performed: + * 1. admin.json with timestamps → admin.json (passwordHash only) + + * admin-state.json (createdAt, lastLogin, passwordChangedAt) + * 2. audit.log moved from config dir to state dir (by rename if same FS, + * else copy + delete). + * + * Skipped silently when the config dir is read-only - operators who already + * locked their config volume must do the migration manually before mounting + * :ro. + */ +export async function migrateLegacyAdminLayout(): Promise { + if (isConfigReadOnly()) return; + + const markerPath = getConfigPath(MIGRATION_MARKER); + if (existsSync(markerPath)) return; + + let didWork = false; + + try { + didWork = (await migrateAdminJson()) || didWork; + didWork = (await migrateAuditLog()) || didWork; + + await ensureConfigDir(); + await writeFile(markerPath, new Date().toISOString(), 'utf-8'); + if (didWork) { + logger.info('Admin layout migrated to v2 (config/state split)'); + } + } catch (error) { + logger.warn('Admin layout migration failed; will retry on next boot', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + } +} + +/** + * If the existing admin.json carries timestamp fields (legacy mixed layout), + * split them into admin-state.json and rewrite admin.json without them. + * Returns true if a migration was performed. + */ +async function migrateAdminJson(): Promise { + const adminJsonPath = getConfigPath('admin.json'); + if (!existsSync(adminJsonPath)) return false; + + let raw: string; + try { + raw = await readFile(adminJsonPath, 'utf-8'); + } catch { + return false; + } + + let data: LegacyAdminData; + try { + data = JSON.parse(raw) as LegacyAdminData; + } catch { + logger.warn('admin.json is not valid JSON; skipping migration'); + return false; + } + + const hasLegacyFields = + 'createdAt' in data || 'lastLogin' in data || 'passwordChangedAt' in data; + if (!hasLegacyFields) return false; // already in v2 shape + + if (!data.passwordHash || typeof data.passwordHash !== 'string') { + logger.warn('admin.json missing passwordHash; skipping migration'); + return false; + } + + const now = new Date().toISOString(); + const stateData: AdminStateData = { + createdAt: data.createdAt ?? now, + lastLogin: data.lastLogin ?? null, + passwordChangedAt: data.passwordChangedAt ?? now, + }; + const configData: AdminConfigData = { passwordHash: data.passwordHash }; + + await ensureStateDir(); + const statePath = getStatePath('admin-state.json'); + + // If admin-state.json already exists, prefer its values: a previous + // migration may have succeeded and recorded fresh login timestamps that + // we'd otherwise stomp. The legacy admin.json data is older by definition. + if (!existsSync(statePath)) { + const stateTmp = statePath + '.tmp'; + await writeFile(stateTmp, JSON.stringify(stateData, null, 2), 'utf-8'); + await rename(stateTmp, statePath); + } + + const configTmp = adminJsonPath + '.tmp'; + await writeFile(configTmp, JSON.stringify(configData, null, 2), 'utf-8'); + await rename(configTmp, adminJsonPath); + + logger.info('Migrated admin.json: split timestamps into admin-state.json'); + return true; +} + +/** + * Move audit.log from the config dir to the state dir if present. Returns + * true if a migration was performed. Also moves rotated copies (audit.log.1 + * through .3). + */ +async function migrateAuditLog(): Promise { + const sources = [ + 'audit.log', + 'audit.log.1', + 'audit.log.2', + 'audit.log.3', + ]; + + let moved = false; + for (const name of sources) { + const src = getConfigPath(name); + if (!existsSync(src)) continue; + + await ensureStateDir(); + const dst = getStatePath(name); + + try { + // Same-FS rename is atomic. Falls through to copy if cross-device. + await rename(src, dst); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EXDEV') { + // Cross-device: copy bytes, then delete source. + const data = await readFile(src); + await writeFile(dst, data); + await unlink(src); + } else { + throw error; + } + } + moved = true; + } + + if (moved) { + logger.info('Migrated audit.log to state dir'); + } + return moved; +} + +/** + * Returns approximate size of legacy data still mixed in the config dir + * (for diagnostics / admin UI). Always returns 0 once migration has run. + */ +export async function getLegacyDataInfo(): Promise<{ adminJsonHasTimestamps: boolean; auditLogInConfigDir: boolean }> { + let adminJsonHasTimestamps = false; + const adminJsonPath = getConfigPath('admin.json'); + if (existsSync(adminJsonPath)) { + try { + const raw = await readFile(adminJsonPath, 'utf-8'); + const parsed = JSON.parse(raw); + adminJsonHasTimestamps = + 'createdAt' in parsed || + 'lastLogin' in parsed || + 'passwordChangedAt' in parsed; + } catch { + /* ignore */ + } + } + + let auditLogInConfigDir = false; + try { + await stat(getConfigPath('audit.log')); + auditLogInConfigDir = true; + } catch { + /* not present - good */ + } + + return { adminJsonHasTimestamps, auditLogInConfigDir }; +} diff --git a/lib/admin/password.ts b/lib/admin/password.ts index d1043df4..955d81fb 100644 --- a/lib/admin/password.ts +++ b/lib/admin/password.ts @@ -1,9 +1,14 @@ import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto'; -import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; +import { readFile, writeFile, rename } from 'node:fs/promises'; import { logger } from '@/lib/logger'; -import type { AdminData } from './types'; +import { + ensureConfigDir, + ensureStateDir, + getConfigPath, + getStatePath, + assertWritable, +} from './paths'; +import type { AdminConfigData, AdminStateData } from './types'; const SCRYPT_KEYLEN = 64; const SCRYPT_COST = 16384; // 2^14 @@ -11,13 +16,8 @@ const SCRYPT_BLOCK_SIZE = 8; const SCRYPT_PARALLELIZATION = 1; const SALT_LENGTH = 32; -function getAdminDir(): string { - return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin'); -} - -function getAdminJsonPath(): string { - return path.join(getAdminDir(), 'admin.json'); -} +const ADMIN_CONFIG_FILE = 'admin.json'; +const ADMIN_STATE_FILE = 'admin-state.json'; function hashPassword(password: string): Promise { return new Promise((resolve, reject) => { @@ -33,10 +33,8 @@ function hashPassword(password: string): Promise { function verifyPassword(password: string, stored: string): Promise { return new Promise((resolve, reject) => { - // Support both scrypt format and bcrypt-prefixed values if (stored.startsWith('$scrypt$')) { const parts = stored.split('$'); - // $scrypt$N=...,r=...,p=...$salt$hash if (parts.length !== 5) return resolve(false); const paramStr = parts[2]; const salt = Buffer.from(parts[3], 'base64'); @@ -53,7 +51,6 @@ function verifyPassword(password: string, stored: string): Promise { resolve(timingSafeEqual(derivedKey, storedHash)); }); } else { - // Unknown format resolve(false); } }); @@ -63,50 +60,84 @@ function isHashed(value: string): boolean { return value.startsWith('$scrypt$') || value.startsWith('$2a$') || value.startsWith('$2b$'); } -async function readAdminData(): Promise { - const filePath = getAdminJsonPath(); +// ─── Disk I/O ─────────────────────────────────────────────────────────────── + +async function readJson(filePath: string): Promise { try { const raw = await readFile(filePath, 'utf-8'); - return JSON.parse(raw) as AdminData; + return JSON.parse(raw) as T; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; - logger.warn('Failed to read admin.json', { error: error instanceof Error ? error.message : 'Unknown error' }); + logger.warn('Failed to read admin file', { + filePath, + error: error instanceof Error ? error.message : 'Unknown error', + }); return null; } } -async function writeAdminData(data: AdminData): Promise { - const dir = getAdminDir(); - if (!existsSync(dir)) { - await mkdir(dir, { recursive: true }); - } - const targetPath = getAdminJsonPath(); - const tmpPath = targetPath + '.tmp'; - await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8'); - await rename(tmpPath, targetPath); +async function readConfigData(): Promise { + return readJson(getConfigPath(ADMIN_CONFIG_FILE)); } -let cachedAdminData: AdminData | null = null; +async function readStateData(): Promise { + return readJson(getStatePath(ADMIN_STATE_FILE)); +} + +async function writeConfigData(data: AdminConfigData): Promise { + assertWritable('save admin password'); + await ensureConfigDir(); + const target = getConfigPath(ADMIN_CONFIG_FILE); + const tmp = target + '.tmp'; + await writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8'); + await rename(tmp, target); +} + +async function writeStateData(data: AdminStateData): Promise { + await ensureStateDir(); + const target = getStatePath(ADMIN_STATE_FILE); + const tmp = target + '.tmp'; + await writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8'); + await rename(tmp, target); +} + +// ─── Cache & init ─────────────────────────────────────────────────────────── + +let cachedConfig: AdminConfigData | null = null; +let cachedState: AdminStateData | null = null; let initialized = false; +function freshState(): AdminStateData { + const now = new Date().toISOString(); + return { createdAt: now, lastLogin: null, passwordChangedAt: now }; +} + /** * Initialize admin password on startup. - * If ADMIN_PASSWORD is cleartext, hash it and write to admin.json. - * Returns true if admin is enabled. + * - If admin.json exists, use it (state file may or may not exist; created on first need). + * - Otherwise, if ADMIN_PASSWORD env var is set, hash and persist it. + * - Otherwise, admin dashboard stays disabled. */ export async function initAdminPassword(): Promise { - if (initialized) return cachedAdminData !== null; + if (initialized) return cachedConfig !== null; - // Check persistent file first - const existing = await readAdminData(); - if (existing) { - cachedAdminData = existing; + const existingConfig = await readConfigData(); + if (existingConfig) { + cachedConfig = existingConfig; + cachedState = (await readStateData()) ?? freshState(); + if (!(await readStateData())) { + // No state file yet (fresh install or migration); create it. + try { + await writeStateData(cachedState); + } catch { + /* state dir may not be writable yet during early boot probes */ + } + } initialized = true; logger.info('Admin dashboard enabled (password loaded from admin.json)'); return true; } - // Check env var const envPassword = process.env.ADMIN_PASSWORD; if (!envPassword) { initialized = true; @@ -114,33 +145,17 @@ export async function initAdminPassword(): Promise { return false; } - if (isHashed(envPassword)) { - // Already hashed in env - save to file - const data: AdminData = { - passwordHash: envPassword, - createdAt: new Date().toISOString(), - lastLogin: null, - passwordChangedAt: new Date().toISOString(), - }; - await writeAdminData(data); - cachedAdminData = data; - initialized = true; - logger.info('Admin password hash saved to admin.json from environment variable'); - return true; - } - - // Cleartext - hash it - const hash = await hashPassword(envPassword); - const data: AdminData = { - passwordHash: hash, - createdAt: new Date().toISOString(), - lastLogin: null, - passwordChangedAt: new Date().toISOString(), - }; - await writeAdminData(data); - cachedAdminData = data; + const hash = isHashed(envPassword) ? envPassword : await hashPassword(envPassword); + cachedConfig = { passwordHash: hash }; + cachedState = freshState(); + await writeConfigData(cachedConfig); + await writeStateData(cachedState); initialized = true; - logger.warn('Admin password hashed and saved to admin.json. You may now remove ADMIN_PASSWORD from .env'); + if (isHashed(envPassword)) { + logger.info('Admin password hash saved to admin.json from environment variable'); + } else { + logger.warn('Admin password hashed and saved to admin.json. You may now remove ADMIN_PASSWORD from .env'); + } return true; } @@ -148,11 +163,9 @@ export async function initAdminPassword(): Promise { * Verify a password against the stored admin hash. */ export async function verifyAdminPassword(password: string): Promise { - if (!cachedAdminData) { - cachedAdminData = await readAdminData(); - } - if (!cachedAdminData) return false; - return verifyPassword(password, cachedAdminData.passwordHash); + if (!cachedConfig) cachedConfig = await readConfigData(); + if (!cachedConfig) return false; + return verifyPassword(password, cachedConfig.passwordHash); } /** @@ -163,14 +176,30 @@ export async function changeAdminPassword(currentPassword: string, newPassword: if (!valid) return false; const hash = await hashPassword(newPassword); - if (!cachedAdminData) return false; + cachedConfig = { passwordHash: hash }; + await writeConfigData(cachedConfig); - cachedAdminData = { - ...cachedAdminData, - passwordHash: hash, + cachedState = { + ...(cachedState ?? freshState()), passwordChangedAt: new Date().toISOString(), }; - await writeAdminData(cachedAdminData); + await writeStateData(cachedState); + return true; +} + +/** + * Set the admin password without verifying a current one. Used by the setup + * wizard during initial bootstrap. Refuses to overwrite an existing password. + */ +export async function setInitialAdminPassword(newPassword: string): Promise { + const existing = await readConfigData(); + if (existing) return false; + const hash = await hashPassword(newPassword); + cachedConfig = { passwordHash: hash }; + cachedState = freshState(); + await writeConfigData(cachedConfig); + await writeStateData(cachedState); + initialized = true; return true; } @@ -178,29 +207,31 @@ export async function changeAdminPassword(currentPassword: string, newPassword: * Update the last login timestamp. */ export async function updateLastLogin(): Promise { - if (!cachedAdminData) return; - cachedAdminData = { - ...cachedAdminData, + if (!cachedConfig) return; + cachedState = { + ...(cachedState ?? freshState()), lastLogin: new Date().toISOString(), }; - await writeAdminData(cachedAdminData); + try { + await writeStateData(cachedState); + } catch (error) { + logger.warn('Failed to update admin last-login state', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + } } /** * Check if admin dashboard is enabled (has a password configured). */ export function isAdminEnabled(): boolean { - return cachedAdminData !== null; + return cachedConfig !== null; } /** * Get admin metadata (without the hash). */ -export function getAdminMeta(): { createdAt: string; lastLogin: string | null; passwordChangedAt: string } | null { - if (!cachedAdminData) return null; - return { - createdAt: cachedAdminData.createdAt, - lastLogin: cachedAdminData.lastLogin, - passwordChangedAt: cachedAdminData.passwordChangedAt, - }; +export function getAdminMeta(): AdminStateData | null { + if (!cachedConfig) return null; + return cachedState ?? freshState(); } diff --git a/lib/admin/paths.ts b/lib/admin/paths.ts new file mode 100644 index 00000000..b791ecaa --- /dev/null +++ b/lib/admin/paths.ts @@ -0,0 +1,126 @@ +import { existsSync } from 'node:fs'; +import { mkdir, writeFile, unlink } from 'node:fs/promises'; +import path from 'node:path'; +import { logger } from '@/lib/logger'; + +/** + * Admin data directories. + * + * Two dirs intentionally split (issue #226): + * - CONFIG: holds operator-authored state (config.json, policy.json, + * admin.json passwordHash, plugins, themes, branding uploads). Can be + * mounted read-only after initial setup. + * - STATE: holds runtime mutations (admin-state.json with login timestamps, + * audit.log, .setup-token). Always read-write. + * + * Resolution order: + * getConfigDir() + * 1. ADMIN_CONFIG_DIR + * 2. ADMIN_DATA_DIR (legacy) + * 3. /data/admin + * + * getStateDir() + * 1. ADMIN_STATE_DIR + * 2. /state - if config dir was set explicitly + * 3. /state - back-compat: stays on the legacy volume + * 4. /data/admin-state - fresh-install default; matches the + * sibling mount in docker-compose.yml + * + * The legacy ADMIN_DATA_DIR keeps existing single-volume mounts working + * unchanged: everything ends up under it, with state in a `state/` subdir. + * Fresh installs and the docker-compose default keep state in a separate + * sibling dir so the config dir can be mounted :ro after setup. + */ + +export function getConfigDir(): string { + return ( + process.env.ADMIN_CONFIG_DIR || + process.env.ADMIN_DATA_DIR || + path.join(process.cwd(), 'data', 'admin') + ); +} + +export function getStateDir(): string { + if (process.env.ADMIN_STATE_DIR) return process.env.ADMIN_STATE_DIR; + if (process.env.ADMIN_CONFIG_DIR) { + return path.join(process.env.ADMIN_CONFIG_DIR, 'state'); + } + if (process.env.ADMIN_DATA_DIR) { + return path.join(process.env.ADMIN_DATA_DIR, 'state'); + } + return path.join(process.cwd(), 'data', 'admin-state'); +} + +export function getConfigPath(filename: string): string { + return path.join(getConfigDir(), filename); +} + +export function getStatePath(filename: string): string { + return path.join(getStateDir(), filename); +} + +export async function ensureConfigDir(): Promise { + const dir = getConfigDir(); + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } +} + +export async function ensureStateDir(): Promise { + const dir = getStateDir(); + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } +} + +// ─── Read-only mode ───────────────────────────────────────────────────────── + +let cachedReadOnly: boolean | null = null; + +/** + * Whether the config dir is locked. Operators set ADMIN_CONFIG_READONLY=true + * after running the setup wizard and remounting the volume :ro. + * + * When true, all writes to the config dir are refused at the application + * layer (cleaner error than a mid-request EROFS). + */ +export function isConfigReadOnly(): boolean { + if (cachedReadOnly !== null) return cachedReadOnly; + const v = (process.env.ADMIN_CONFIG_READONLY || '').toLowerCase(); + cachedReadOnly = v === 'true' || v === '1' || v === 'yes'; + return cachedReadOnly; +} + +/** + * Probe the config dir by writing a temp file. Used to auto-detect RO mounts + * when ADMIN_CONFIG_READONLY is not set explicitly. Run once at startup; + * cheap on local FS, can be slow on networked FS, hence opt-in. + */ +export async function probeConfigReadOnly(): Promise { + if (process.env.ADMIN_CONFIG_READONLY) return isConfigReadOnly(); + try { + const probe = path.join(getConfigDir(), '.rw-probe'); + await writeFile(probe, ''); + await unlink(probe); + cachedReadOnly = false; + return false; + } catch { + cachedReadOnly = true; + logger.info('Config dir is read-only (auto-detected)'); + return true; + } +} + +export class ConfigReadOnlyError extends Error { + constructor(operation: string) { + super( + `Cannot ${operation}: configuration is read-only. ` + + `Remount the config volume read-write or unset ADMIN_CONFIG_READONLY.` + ); + this.name = 'ConfigReadOnlyError'; + } +} + +export function assertWritable(operation: string): void { + if (isConfigReadOnly()) throw new ConfigReadOnlyError(operation); +} diff --git a/lib/admin/plugin-config.ts b/lib/admin/plugin-config.ts index bd2d8cfc..68c7e68d 100644 --- a/lib/admin/plugin-config.ts +++ b/lib/admin/plugin-config.ts @@ -2,13 +2,10 @@ import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import path from 'node:path'; import { logger } from '@/lib/logger'; - -function getAdminDir(): string { - return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin'); -} +import { getConfigDir, assertWritable } from './paths'; function getPluginConfigDir(): string { - return path.join(getAdminDir(), 'plugin-config'); + return path.join(getConfigDir(), 'plugin-config'); } function configPath(pluginId: string): string { @@ -41,6 +38,7 @@ export async function getPluginConfig(pluginId: string): Promise { + assertWritable('update plugin config'); const dir = getPluginConfigDir(); await ensureDir(dir); @@ -57,6 +55,7 @@ export async function setPluginConfig(pluginId: string, key: string, value: unkn * Delete a single config key for a plugin. */ export async function deletePluginConfigKey(pluginId: string, key: string): Promise { + assertWritable('delete plugin config key'); const config = await getPluginConfig(pluginId); delete config[key]; @@ -77,5 +76,6 @@ export async function deletePluginConfigKey(pluginId: string, key: string): Prom * Delete all config for a plugin (used when uninstalling). */ export async function deleteAllPluginConfig(pluginId: string): Promise { + assertWritable('delete plugin config'); try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ } } diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts index e700eece..e09ca97a 100644 --- a/lib/admin/plugin-registry.ts +++ b/lib/admin/plugin-registry.ts @@ -3,17 +3,14 @@ import { existsSync } from 'node:fs'; import { createHash } from 'node:crypto'; import path from 'node:path'; import { logger } from '@/lib/logger'; - -function getAdminDir(): string { - return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin'); -} +import { getConfigDir, assertWritable } from './paths'; function getPluginsDir(): string { - return path.join(getAdminDir(), 'plugins'); + return path.join(getConfigDir(), 'plugins'); } function getThemesDir(): string { - return path.join(getAdminDir(), 'themes'); + return path.join(getConfigDir(), 'themes'); } // ─── Types ─────────────────────────────────────────────────── @@ -141,6 +138,7 @@ export async function savePlugin( plugin: ServerPlugin, code: string, ): Promise { + assertWritable('install plugin'); const dir = getPluginsDir(); await ensureDir(dir); @@ -171,6 +169,7 @@ export async function savePlugin( } export async function updatePluginMeta(id: string, updates: Partial>): Promise { + assertWritable('update plugin metadata'); const registry = await getPluginRegistry(); const idx = registry.plugins.findIndex(p => p.id === id); if (idx < 0) return null; @@ -181,6 +180,7 @@ export async function updatePluginMeta(id: string, updates: Partial { + assertWritable('delete plugin'); const registry = await getPluginRegistry(); const idx = registry.plugins.findIndex(p => p.id === id); if (idx < 0) return false; @@ -221,6 +221,7 @@ export async function saveTheme( theme: ServerTheme, css: string, ): Promise { + assertWritable('install theme'); const dir = getThemesDir(); await ensureDir(dir); @@ -240,6 +241,7 @@ export async function saveTheme( } export async function updateThemeMeta(id: string, updates: Partial>): Promise { + assertWritable('update theme metadata'); const registry = await getThemeRegistry(); const idx = registry.themes.findIndex(t => t.id === id); if (idx < 0) return null; @@ -250,6 +252,7 @@ export async function updateThemeMeta(id: string, updates: Partial { + assertWritable('delete theme'); const registry = await getThemeRegistry(); const idx = registry.themes.findIndex(t => t.id === id); if (idx < 0) return false; diff --git a/lib/admin/session.ts b/lib/admin/session.ts index ecde855e..fb08468f 100644 --- a/lib/admin/session.ts +++ b/lib/admin/session.ts @@ -1,7 +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 { getSessionSecret } from '@/lib/auth/session-secret'; import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types'; import type { AdminSessionPayload } from './types'; @@ -12,7 +12,7 @@ const TAG_LENGTH = 16; const MIN_SECRET_LENGTH = 32; function getKey(): Buffer { - const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE); + const secret = getSessionSecret(); if (!secret) throw new Error('SESSION_SECRET not configured'); if (secret.length < MIN_SECRET_LENGTH) { throw new Error( diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 76fd247e..66ecf08d 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -1,12 +1,30 @@ // Admin dashboard types -export interface AdminData { +/** + * Operator-authored admin record. Lives in admin.json under the config dir + * and can be mounted read-only after setup. Only the password hash itself + * is config; mutable timestamps live in AdminStateData. + */ +export interface AdminConfigData { passwordHash: string; +} + +/** + * Runtime-mutable admin record. Lives in admin-state.json under the state + * dir. Updated on every login and password change, so it must stay writable. + */ +export interface AdminStateData { createdAt: string; lastLogin: string | null; passwordChangedAt: string; } +/** + * Combined view used by getAdminMeta() and tests. Constructed by merging + * admin.json + admin-state.json at read time. + */ +export interface AdminData extends AdminConfigData, AdminStateData {} + export interface AdminSessionPayload { role: 'admin'; iat: number; diff --git a/lib/auth/crypto.ts b/lib/auth/crypto.ts index 670bcc68..38eb24a4 100644 --- a/lib/auth/crypto.ts +++ b/lib/auth/crypto.ts @@ -1,6 +1,6 @@ import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'; import { logger } from '@/lib/logger'; -import { readFileEnv } from '@/lib/read-file-env'; +import { getSessionSecret } from '@/lib/auth/session-secret'; const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 12; @@ -9,7 +9,7 @@ const TAG_LENGTH = 16; const MIN_SECRET_LENGTH = 32; function getKey(): Buffer { - const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE); + const secret = getSessionSecret(); if (!secret) throw new Error('SESSION_SECRET not configured'); if (secret.length < MIN_SECRET_LENGTH) { throw new Error( diff --git a/lib/auth/session-secret.ts b/lib/auth/session-secret.ts new file mode 100644 index 00000000..aceff84f --- /dev/null +++ b/lib/auth/session-secret.ts @@ -0,0 +1,31 @@ +import { configManager } from '@/lib/admin/config-manager'; +import { readFileEnv } from '@/lib/read-file-env'; + +/** + * Resolve the session secret from any of the supported sources, in priority + * order: + * 1. SESSION_SECRET env var + * 2. SESSION_SECRET_FILE-pointed file + * 3. Admin override in config.json (set by the setup wizard) + * + * Returns an empty string when nothing is configured. Callers must treat + * empty as "feature disabled" rather than crashing. + * + * The configManager fallback exists so the web installer can persist the + * secret without touching .env files. It only takes effect if the env vars + * aren't set, so existing deployments aren't affected. + */ +export function getSessionSecret(): string { + const fromEnv = process.env.SESSION_SECRET; + if (fromEnv) return fromEnv; + + const fromFile = readFileEnv(process.env.SESSION_SECRET_FILE); + if (fromFile) return fromFile; + + const fromAdmin = configManager.get('sessionSecret', ''); + return fromAdmin || ''; +} + +export function hasSessionSecret(): boolean { + return getSessionSecret().length > 0; +} diff --git a/lib/settings-sync.ts b/lib/settings-sync.ts index 297baa29..6a5fb1be 100644 --- a/lib/settings-sync.ts +++ b/lib/settings-sync.ts @@ -3,14 +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'; +import { getSessionSecret } from '@/lib/auth/session-secret'; const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 12; const TAG_LENGTH = 16; function getKey(): Buffer { - const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE); + const secret = getSessionSecret(); if (!secret) throw new Error('SESSION_SECRET not configured'); return createHash('sha256').update(secret).digest(); } diff --git a/lib/setup/session.ts b/lib/setup/session.ts new file mode 100644 index 00000000..e4922e4e --- /dev/null +++ b/lib/setup/session.ts @@ -0,0 +1,33 @@ +import { cookies } from 'next/headers'; +import { verifySetupToken } from './token'; + +export const SETUP_COOKIE = 'bulwark_setup_token'; +const COOKIE_MAX_AGE = 60 * 60; // 1 hour, matches token TTL + +/** + * The wizard "session" is just the setup token itself, set as an HttpOnly + * cookie after the operator pastes it into step 1. Subsequent step calls + * re-verify the cookie value against the .setup-token file. When the wizard + * finishes, the token file is deleted and any cookies become useless. + * + * No JWT, no separate signing key, no rotating session id. The lifecycle of + * the wizard maps 1:1 to the lifecycle of the token file. + */ + +export async function authenticateWizardRequest(): Promise { + const jar = await cookies(); + const token = jar.get(SETUP_COOKIE)?.value; + if (!token) return false; + return verifySetupToken(token); +} + +export function buildSessionCookieAttributes() { + return { + name: SETUP_COOKIE, + httpOnly: true, + sameSite: 'lax' as const, + secure: process.env.NODE_ENV === 'production', + path: '/', + maxAge: COOKIE_MAX_AGE, + }; +} diff --git a/lib/setup/state.ts b/lib/setup/state.ts new file mode 100644 index 00000000..ee23f9b2 --- /dev/null +++ b/lib/setup/state.ts @@ -0,0 +1,53 @@ +import { existsSync } from 'node:fs'; +import { configManager } from '@/lib/admin/config-manager'; +import { getConfigPath, isConfigReadOnly } from '@/lib/admin/paths'; + +/** + * The three lifecycle states for the running container. + * + * bootstrap - no config persisted yet and no JMAP_SERVER_URL env. The + * setup wizard is served at /setup; everything else 302s + * there. + * configured - setup wizard finished (admin override config.json carries + * setupComplete=true). Normal app; /setup returns 404. + * env-managed - JMAP_SERVER_URL is set in the environment, so the + * operator is configuring via .env (legacy / CI path). The + * wizard stays disabled. + */ +export type SetupState = 'bootstrap' | 'configured' | 'env-managed'; + +/** + * Cheap to call on every request. configManager keeps `setupComplete` in + * memory after the initial load, so this is just env reads + an in-memory + * boolean check. + */ +export function detectSetupState(): SetupState { + if (configManager.isSetupComplete()) return 'configured'; + if (process.env.JMAP_SERVER_URL && process.env.JMAP_SERVER_URL.trim() !== '') { + return 'env-managed'; + } + // Read-only config dir + no setupComplete flag means the volume was + // mounted :ro before the wizard ran. Fall through to bootstrap so the + // failure (write attempt during wizard) surfaces with a clear error + // rather than silently 404'ing /setup. + if (isConfigReadOnly()) return 'bootstrap'; + return 'bootstrap'; +} + +/** + * Whether the wizard's UI and APIs should be reachable. + */ +export function isSetupActive(): boolean { + return detectSetupState() === 'bootstrap'; +} + +/** + * The persisted `.config-locked` marker the wizard drops when the operator + * checks "lock configuration after setup" on the review screen. Purely + * advisory - the actual locking is the operator's `:ro` mount or the + * ADMIN_CONFIG_READONLY env var. This file is what the admin UI uses to + * remind the operator that they intended to lock. + */ +export function lockMarkerExists(): boolean { + return existsSync(getConfigPath('.config-locked')); +} diff --git a/lib/setup/token.ts b/lib/setup/token.ts new file mode 100644 index 00000000..58fd4de8 --- /dev/null +++ b/lib/setup/token.ts @@ -0,0 +1,111 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { readFile, writeFile, unlink, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { logger } from '@/lib/logger'; +import { ensureStateDir, getStatePath } from '@/lib/admin/paths'; + +const TOKEN_FILE = '.setup-token'; +const TOKEN_BYTES = 32; +const DEFAULT_TTL_SECONDS = 60 * 60; // 1 hour + +interface TokenPayload { + token: string; + issuedAt: number; + ttlSeconds: number; +} + +/** + * Read the current token if one exists and hasn't expired. Stale tokens + * are deleted lazily - first stale read removes the file. + */ +async function readToken(): Promise { + const path = getStatePath(TOKEN_FILE); + if (!existsSync(path)) return null; + try { + const raw = await readFile(path, 'utf-8'); + const payload = JSON.parse(raw) as TokenPayload; + if (Date.now() / 1000 - payload.issuedAt > payload.ttlSeconds) { + try { await unlink(path); } catch { /* ok */ } + return null; + } + return payload; + } catch (error) { + logger.warn('Failed to read setup token', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + return null; + } +} + +/** + * Generate (or refresh) the setup token. Called at startup when the app + * detects bootstrap state. Idempotent: returns the existing token if it's + * still valid, otherwise issues a fresh one. + * + * The token lands in a file in ADMIN_STATE_DIR (always writable, never + * read-only) and is also printed to the container logs so the operator + * can copy it without execing into the container. + */ +export async function ensureSetupToken(ttlSeconds: number = DEFAULT_TTL_SECONDS): Promise { + const existing = await readToken(); + if (existing) return existing.token; + + await ensureStateDir(); + const token = randomBytes(TOKEN_BYTES).toString('hex'); + const payload: TokenPayload = { + token, + issuedAt: Math.floor(Date.now() / 1000), + ttlSeconds, + }; + const path = getStatePath(TOKEN_FILE); + await writeFile(path, JSON.stringify(payload, null, 2), 'utf-8'); + return token; +} + +/** + * Verify a token submitted by the wizard. Constant-time comparison; never + * leak the stored token via timing. + */ +export async function verifySetupToken(submitted: string): Promise { + if (!submitted || typeof submitted !== 'string') return false; + const stored = await readToken(); + if (!stored) return false; + + const a = Buffer.from(submitted); + const b = Buffer.from(stored.token); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +/** + * Delete the token file. Called by the wizard's finish endpoint after + * setupComplete=true is persisted. + */ +export async function clearSetupToken(): Promise { + const path = getStatePath(TOKEN_FILE); + try { + await unlink(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + logger.warn('Failed to clear setup token', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + } +} + +/** + * For diagnostics / startup logging. + */ +export async function getTokenInfo(): Promise<{ exists: boolean; expiresInSeconds: number | null }> { + const path = getStatePath(TOKEN_FILE); + if (!existsSync(path)) return { exists: false, expiresInSeconds: null }; + try { + await stat(path); + const payload = await readToken(); + if (!payload) return { exists: false, expiresInSeconds: null }; + const elapsed = Date.now() / 1000 - payload.issuedAt; + return { exists: true, expiresInSeconds: Math.max(0, Math.floor(payload.ttlSeconds - elapsed)) }; + } catch { + return { exists: false, expiresInSeconds: null }; + } +} diff --git a/proxy.ts b/proxy.ts index fed44af2..f4b69f68 100644 --- a/proxy.ts +++ b/proxy.ts @@ -2,6 +2,8 @@ import { type NextRequest, NextResponse } from "next/server"; import createIntlMiddleware from "next-intl/middleware"; import { routing } from "./i18n/routing"; import { getEnabledPluginFrameOrigins } from "./lib/admin/csp-frame-origins"; +import { configManager } from "./lib/admin/config-manager"; +import { detectSetupState } from "./lib/setup/state"; const intlMiddleware = createIntlMiddleware(routing); @@ -11,8 +13,59 @@ const intlMiddleware = createIntlMiddleware(routing); // requests for API routes, Next internals and static assets. const PROXY_SKIP_PATTERN = /^\/(?:api|_next)(?:\/|$)|\.[^/]+$/; +function isSetupPath(pathname: string): boolean { + return ( + pathname === "/setup" || + pathname.startsWith("/setup/") || + pathname.startsWith("/api/setup") + ); +} + export async function proxy(request: NextRequest) { - if (PROXY_SKIP_PATTERN.test(request.nextUrl.pathname)) { + // Resolve setup state before deciding what to skip. The first call after + // boot triggers the config load; subsequent calls are in-memory. + await configManager.ensureLoaded(); + const setupState = detectSetupState(); + const pathname = request.nextUrl.pathname; + + if (setupState === "bootstrap") { + // Wizard active. Redirect HTML pages to /setup; let asset/internal + // requests through so the wizard UI can render. Block non-setup APIs + // with a 503 so cached SPA code doesn't silently call them. + const allowed = + isSetupPath(pathname) || + pathname === "/api/health" || + pathname.startsWith("/_next/") || + pathname.startsWith("/branding/") || + /\.[^/]+$/.test(pathname); + + if (!allowed) { + if (pathname.startsWith("/api/")) { + return new NextResponse( + JSON.stringify({ error: "setup_required", message: "Initial setup has not completed." }), + { status: 503, headers: { "content-type": "application/json" } }, + ); + } + const url = request.nextUrl.clone(); + url.pathname = "/setup"; + url.search = request.nextUrl.search; + return NextResponse.redirect(url); + } + } else if (isSetupPath(pathname)) { + // Configured / env-managed: wizard is no longer reachable. + // - HTML /setup pages → redirect to admin login so users who reload + // the URL after setup don't see a dead "Not Found" page. + // - /api/setup/* → 404 (no reason to expose these endpoints). + if (pathname.startsWith("/api/setup")) { + return new NextResponse("Not Found", { status: 404 }); + } + const url = request.nextUrl.clone(); + url.pathname = "/admin/login"; + url.search = ""; + return NextResponse.redirect(url); + } + + if (PROXY_SKIP_PATTERN.test(pathname)) { return NextResponse.next(); } @@ -50,9 +103,10 @@ export async function proxy(request: NextRequest) { `media-src 'self' blob:`, ].join("; "); - // Skip intl middleware for /admin routes - they have their own layout - const pathname = request.nextUrl.pathname; + // Skip intl middleware for /admin and /setup routes - they have their + // own layout outside the [locale] tree. const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/'); + const isSetupRoute = pathname === '/setup' || pathname.startsWith('/setup/'); // When localePrefix is 'always', paths that already have a locale prefix // (e.g. /en/settings) should not be re-processed by the intl middleware - @@ -63,7 +117,7 @@ export async function proxy(request: NextRequest) { ); let intlResponse: ReturnType | null = null; - if (!isAdminRoute && !hasLocalePrefix) { + if (!isAdminRoute && !isSetupRoute && !hasLocalePrefix) { try { intlResponse = intlMiddleware(request); } catch (error) { From 76d78ae756b0656a9b3b7d4bd7d6994cfedb4dcf Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 9 May 2026 17:40:53 +0200 Subject: [PATCH 016/133] fix: drop redundant first-login banner about removing ADMIN_PASSWORD #222 --- app/admin/_tabs/dashboard.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/app/admin/_tabs/dashboard.tsx b/app/admin/_tabs/dashboard.tsx index 558d8414..617866b1 100644 --- a/app/admin/_tabs/dashboard.tsx +++ b/app/admin/_tabs/dashboard.tsx @@ -119,18 +119,6 @@ export function DashboardTab() {
))} - {status && !status.lastLogin && ( -
- -
-

First login detected

-

- Remember to remove ADMIN_PASSWORD from your .env file now that the hash is stored securely. -

-
-
- )} - {config?.appName || '-'} From 01302a775c5113ecff38767affabae5fc9648779 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 9 May 2026 17:52:44 +0200 Subject: [PATCH 017/133] feat: require explicit confirmation when JMAP probe finds no session --- app/setup/page.tsx | 98 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 87 insertions(+), 11 deletions(-) diff --git a/app/setup/page.tsx b/app/setup/page.tsx index 52952cab..7d7938a3 100644 --- a/app/setup/page.tsx +++ b/app/setup/page.tsx @@ -473,14 +473,21 @@ function StepContent({ stepIndex, config, setConfig, onNext, onBack, onFinish }: // ─── Server step ───────────────────────────────────────────────────────── +type ProbeStatus = 'jmap_detected' | 'reachable_no_jmap' | 'unreachable' | 'invalid_url'; + function ServerStep({ config, setConfig, onNext }: Pick) { const [submitting, setSubmitting] = useState(false); - const [probe, setProbe] = useState(null); + const [probe, setProbe] = useState<{ status: ProbeStatus; message: string; url: string } | null>(null); const [probing, setProbing] = useState(false); + // When the server is reachable but isn't a JMAP endpoint, the wizard + // shows a "looks wrong, are you sure?" inline confirmation. The flag + // resets every time the URL changes. + const [confirmedNonJmap, setConfirmedNonJmap] = useState(false); - async function testJmap() { + async function testJmap(): Promise<{ status: ProbeStatus; message: string; url: string } | null> { setProbe(null); setProbing(true); + setConfirmedNonJmap(false); try { const res = await apiFetch('/api/setup/test-jmap', { method: 'POST', @@ -488,15 +495,22 @@ function ServerStep({ config, setConfig, onNext }: Pick setConfig({ ...config, jmapServerUrl: v })} + onChange={(v) => { + setConfig({ ...config, jmapServerUrl: v }); + // Any URL change invalidates the previous probe result. + if (probe && probe.url !== v) { + setProbe(null); + setConfirmedNonJmap(false); + } + }} required placeholder="https://" type="url" />
- {probe &&

{probe}

} + {probe && probe.url === config.jmapServerUrl && ( + probe.status === 'reachable_no_jmap' ? ( +
+

{probe.message}

+

+ This is OK if a reverse proxy routes JMAP traffic separately (e.g. webmail and mail server share a domain), but more often it means the URL is wrong. +

+ +
+ ) : probe.status === 'jmap_detected' ? ( +

✓ {probe.message}

+ ) : ( +

{probe.message}

+ ) + )} {/* Additional servers (optional) */} @@ -682,8 +745,21 @@ function ServerStep({ config, setConfig, onNext }: Pick
- - {submitting ? 'Saving…' : 'Next'} + + {submitting ? 'Saving…' : probing ? 'Testing…' : 'Next'}
From 1dcdeeae861e1faad9f33b8de7956cb702414005 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 9 May 2026 18:12:43 +0200 Subject: [PATCH 018/133] style: consistent notice cards for server probe results --- app/setup/page.tsx | 53 +++++++++++++++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/app/setup/page.tsx b/app/setup/page.tsx index 7d7938a3..77c7da5c 100644 --- a/app/setup/page.tsx +++ b/app/setup/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, type FormEvent, type ReactNode } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; +import { CheckCircle2, AlertTriangle, AlertCircle } from 'lucide-react'; import { apiFetch } from '@/lib/browser-navigation'; type State = 'bootstrap' | 'configured' | 'env-managed'; @@ -497,13 +498,13 @@ function ServerStep({ config, setConfig, onNext }: Pick
{probe && probe.url === config.jmapServerUrl && ( - probe.status === 'reachable_no_jmap' ? ( -
-

{probe.message}

-

- This is OK if a reverse proxy routes JMAP traffic separately (e.g. webmail and mail server share a domain), but more often it means the URL is wrong. -

-
diff --git a/hooks/use-attachment-drag.ts b/hooks/use-attachment-drag.ts new file mode 100644 index 00000000..ea564192 --- /dev/null +++ b/hooks/use-attachment-drag.ts @@ -0,0 +1,122 @@ +"use client"; + +import { useCallback, useEffect, useRef, DragEvent } from "react"; + +// Chromium ships the `DownloadURL` DataTransfer entry, which the OS reads on +// drop to materialize a real file. Firefox and Safari ignore it, so we only +// enable drag-out where it actually works. +export function isDragOutSupported(): boolean { + if (typeof navigator === "undefined") return false; + const uaData = (navigator as { userAgentData?: { brands?: { brand: string }[] } }).userAgentData; + if (uaData?.brands?.length) { + return uaData.brands.some((b) => /Chromium|Google Chrome|Microsoft Edge|Brave|Opera/i.test(b.brand)); + } + const ua = navigator.userAgent || ""; + if (/Firefox|FxiOS/.test(ua)) return false; + if (/^((?!chrome|android).)*safari/i.test(ua)) return false; + return /Chrome|Chromium|Edg\//.test(ua); +} + +export interface AttachmentDragSource { + name: string; + type: string; + getBlobUrl: () => Promise; +} + +export interface UseAttachmentDragResult { + draggable: boolean; + onPointerEnter: () => void; + onDragStart: (e: DragEvent) => void; + onDragEnd: (e: DragEvent) => void; +} + +const NOOP_HANDLERS: UseAttachmentDragResult = { + draggable: false, + onPointerEnter: () => {}, + onDragStart: () => {}, + onDragEnd: () => {}, +}; + +export function useAttachmentDrag( + source: AttachmentDragSource, + enabled: boolean, +): UseAttachmentDragResult { + const urlRef = useRef(null); + const ownedRef = useRef(false); + const inFlightRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (urlRef.current && ownedRef.current) { + URL.revokeObjectURL(urlRef.current); + } + urlRef.current = null; + ownedRef.current = false; + inFlightRef.current = null; + }; + }, []); + + const prefetch = useCallback(() => { + if (!enabled) return; + if (urlRef.current || inFlightRef.current) return; + inFlightRef.current = source + .getBlobUrl() + .then((url) => { + if (url && !urlRef.current) { + urlRef.current = url; + // Mark as owned so we revoke on unmount. Callers that hand back a + // shared URL (e.g. a cached thumbnail blob URL) can return the same + // string each time — we still revoke once on unmount. + ownedRef.current = true; + } + return url; + }) + .catch(() => null) + .finally(() => { + inFlightRef.current = null; + }); + }, [enabled, source]); + + const handleDragStart = useCallback( + (e: DragEvent) => { + const url = urlRef.current; + const name = source.name || "download"; + const type = source.type || "application/octet-stream"; + + if (!url) { + // Blob isn't materialized yet. Kick off the fetch so the next attempt + // works, but cancel this drag so the user doesn't get a silent failure + // where the OS receives no file. + prefetch(); + e.preventDefault(); + return; + } + + // `DownloadURL` format: ::. Chromium reads this on + // drop and writes a real file at the destination. + e.dataTransfer.setData("DownloadURL", `${type}:${encodeURIComponent(name)}:${url}`); + e.dataTransfer.effectAllowed = "copyMove"; + }, + [source.name, source.type, prefetch], + ); + + const handleDragEnd = useCallback(() => { + // Keep the blob URL around briefly — Chromium asynchronously fetches the + // blob: URL after dragend fires, so revoking immediately races the OS. + if (urlRef.current && ownedRef.current) { + const url = urlRef.current; + urlRef.current = null; + ownedRef.current = false; + setTimeout(() => URL.revokeObjectURL(url), 60_000); + } + }, []); + + if (!enabled) return NOOP_HANDLERS; + + return { + draggable: true, + onPointerEnter: prefetch, + onDragStart: handleDragStart, + onDragEnd: handleDragEnd, + }; +} From 9571f2e185d5c36764da834a3b750f5c6cb25bfc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 11 May 2026 19:22:37 +0200 Subject: [PATCH 031/133] fix: skip upstream JMAP reverify for trusted URLs #237 --- app/api/auth/session/route.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index a21f526f..5711df57 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -4,7 +4,12 @@ import { logger } from '@/lib/logger'; import { encryptSession, decryptSession } from '@/lib/auth/crypto'; import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; -import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth'; +import { + JmapAuthVerificationError, + normalizeJmapServerUrl, + validateProxyAuthHeader, + verifyJmapAuth, +} from '@/lib/auth/verify-jmap-auth'; import { clearStalwartAuthContextInStore, setStalwartAuthContextInStore, @@ -74,7 +79,13 @@ export async function POST(request: NextRequest) { const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request); const cookieName = sessionCookieName(slot); const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; - const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted }); + // Trusted (admin-configured) URLs skip the upstream re-fetch: the cookie + // we write here is only ever consumed for requests on behalf of this same + // user, so bogus credentials would just yield 401s downstream rather than + // privilege escalation. Untrusted custom endpoints still verify upstream. + const normalizedServerUrl = upstreamTrusted + ? (validateProxyAuthHeader(authHeader), normalizeJmapServerUrl(upstreamUrl)) + : await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false }); const token = encryptSession(normalizedServerUrl, username, password); const cookieStore = await cookies(); cookieStore.set(cookieName, token, COOKIE_OPTIONS); From a2f76037a154d946219c40a7d936ae7f09aa35af Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 11 May 2026 19:24:32 +0200 Subject: [PATCH 032/133] feat: update README and FEATURES.md --- FEATURES.md | 35 +++++++++++++++++++++++------------ README.md | 34 ++++++++++++++++++++++------------ 2 files changed, 45 insertions(+), 24 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index aac5e389..37638ba2 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -2,19 +2,23 @@ ## Mail -- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding) +- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables) - Gmail-style threading with inline expansion and an optional conversation toggle - Unified mailbox view across all connected accounts -- Draft auto-save with identity preservation -- Attachment upload, download, and inline preview; forgotten-attachment warning +- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom +- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies +- Attachment upload, download, drag-out to local file system, and inline preview; image thumbnails and forgotten-attachment warning - Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries - Batch operations – multi-select, archive, delete, move, tag - Archive modes – direct, by year, or by month - Multi-tag support with color labels, reordering, and drag-and-drop assignment - Star/unstar with configurable mark-as-read delay -- Virtual scrolling for large mailboxes +- Virtual scrolling for large mailboxes plus prefetching of initial email data on login - Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers - Plain-text composer mode and Reply-To support +- Configurable signature position (above or below quoted text) per identity +- From-header override in the composer with optional catch-all auto-reply: replies to an alias on a domain you own auto-fill the alias as the sender even when it isn't a configured identity +- `.eml` file import via folder right-click menu - TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping - Folder management with icon picker, subfolders, and sidebar counts - Print directly from the viewer @@ -79,7 +83,7 @@ ## Interface -- Three-pane layout with resizable columns +- Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns - Dark and light themes with intelligent email color transformation - Responsive desktop, tablet, and mobile layouts - Full keyboard navigation @@ -100,25 +104,32 @@ Automatic browser detection with persistent preference. Configurable locale URL ## Identity & Multi-Account -- Up to 5 simultaneous accounts with instant switching and per-account session persistence +- Multiple simultaneous accounts with instant switching and per-account session persistence; the 5-account cap is lifted on HTTP/2 servers (limited by browser connection pooling on HTTP/1.1) - Account switcher with connection status and default account selection - Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list -- Sub-addressing (`user+tag@domain.com`) with contextual tag suggestions +- Configurable signature position (above or below quoted text) +- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions - Shared folders across accounts +- Multiple JMAP servers per deployment with optional auto-pick by email domain - Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`) ## Admin & Extensibility -- Stalwart admin dashboard with dedicated policy sections -- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event slots, and managed policy enforcement +- Web setup wizard for first launch – guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required +- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page +- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps) +- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs, and managed policy enforcement +- Plugin hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins` - Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles -- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`) +- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard - Bundled plugins including Jitsi Meet calendar integration ## Operations -- Progressive Web App with service worker, install prompt, and dynamic manifest -- Automatic update check with server-side logging of new releases +- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, and dynamic manifest +- Automatic update check with server-side logging of new releases and a non-dismissible update notice - Structured logging (`text` or `json`) with category-based levels +- Anonymous instance telemetry (opt-out via admin UI or `BULWARK_TELEMETRY=off`) – version, platform, bucketed account counts, feature toggles only - Release (`main`) and development (`dev`) Docker images on GHCR +- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy - Demo mode with fixture data – no mail server required diff --git a/README.md b/README.md index eb830e07..e8851626 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar [![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.6.1-green.svg?logo=git&logoColor=white)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.6.3-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) [![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/) @@ -63,7 +63,7 @@ Bulwark is a full webmail suite, not just an inbox. It bundles the four apps mos - **Contacts** – multiple address books, groups, vCard import/export - **Files** – Stalwart's JMAP FileNode storage with previews and folder upload -Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a admin dashboard. +Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard. Full feature list: **[FEATURES.md](FEATURES.md)**. @@ -74,28 +74,25 @@ Full feature list: **[FEATURES.md](FEATURES.md)**. ### Docker ```bash -docker run -d -p 3000:3000 \ - -e JMAP_SERVER_URL=https://mail.example.com \ - ghcr.io/bulwarkmail/webmail:latest +docker run -d -p 3000:3000 ghcr.io/bulwarkmail/webmail:latest ``` Or with Docker Compose: ```bash -cp .env.example .env.local -# Edit .env.local – set JMAP_SERVER_URL docker compose up -d ``` +On first launch, open `http://localhost:3000` – the **web setup wizard** walks you through JMAP server, OAuth, branding, and the admin password. No `.env.local` editing required. Existing installs that already define `JMAP_SERVER_URL` in their environment skip the wizard and keep the env-managed flow described under [Configuration](#configuration). + ### From Source ```bash git clone https://github.com/bulwarkmail/webmail.git cd webmail npm install -cp .env.example .env.local -# Edit .env.local – set JMAP_SERVER_URL npm run build && npm start +# Then open http://localhost:3000 to run the setup wizard ``` ### Development @@ -108,13 +105,13 @@ npm run lint ## Configuration +Most deployments are configured through the **setup wizard** (on first launch) and the **admin dashboard** thereafter; values are written to the admin config directory rather than `.env.local`. Environment variables remain supported for operators who prefer file-driven configuration or read-only / immutable infrastructure. When an environment variable is set, it takes precedence over the corresponding admin-managed value, so setting `JMAP_SERVER_URL` will hide that field from the wizard and lock it in the admin UI. + All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`: ```env -# Required +# Optional – overrides whatever the wizard writes JMAP_SERVER_URL=https://mail.example.com - -# Optional APP_NAME=My Webmail ``` @@ -218,6 +215,19 @@ LOG_LEVEL=info # error | warn | info | debug +
+Admin data directories + +```env +ADMIN_CONFIG_DIR=./data/admin # operator-authored: config.json, policy.json, plugins/, themes/ +ADMIN_STATE_DIR=./data/admin-state # runtime: audit log, login timestamps, setup token +ADMIN_CONFIG_READONLY=true # enforce read-only mode at the app layer +``` + +The split lets you mount the config volume read-only after the setup wizard completes. Legacy installs that pre-date the split keep working through `ADMIN_DATA_DIR`. + +
+
Subpath / reverse proxy mount From 23bc31c66186645591462c7b60bf5ea7c3e1d26a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 11 May 2026 19:30:52 +0200 Subject: [PATCH 033/133] i18n: add missing translation keys across 15 locales --- locales/cs/common.json | 19 ++++++++++++++++++- locales/de/common.json | 19 ++++++++++++++++++- locales/es/common.json | 19 ++++++++++++++++++- locales/fr/common.json | 19 ++++++++++++++++++- locales/it/common.json | 19 ++++++++++++++++++- locales/ja/common.json | 19 ++++++++++++++++++- locales/ko/common.json | 19 ++++++++++++++++++- locales/lv/common.json | 19 ++++++++++++++++++- locales/nl/common.json | 19 ++++++++++++++++++- locales/pl/common.json | 19 ++++++++++++++++++- locales/pt/common.json | 19 ++++++++++++++++++- locales/ru/common.json | 19 ++++++++++++++++++- locales/tr/common.json | 19 ++++++++++++++++++- locales/uk/common.json | 19 ++++++++++++++++++- locales/zh/common.json | 19 ++++++++++++++++++- 15 files changed, 270 insertions(+), 15 deletions(-) diff --git a/locales/cs/common.json b/locales/cs/common.json index 53aa546f..fa475c16 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -528,6 +528,15 @@ "to": "Komu: {recipients}" }, "remove_sub_address": "Odebrat subadresu", + "from_override": { + "toggle_off": "Přepsat", + "toggle_on": "Zrušit přepsání", + "toggle_tooltip": "Volně upravujte jméno a adresu odesílatele. Pošta se stále odesílá přes vaši identitu — mění se pouze viditelné záhlaví Od.", + "name_label": "Jméno odesílatele", + "name_placeholder": "Jméno", + "email_label": "E-mailová adresa odesílatele", + "email_placeholder": "alias@example.com" + }, "use_template": "Šablona", "save_as_template": "Uložit jako šablonu", "validation": { @@ -941,7 +950,9 @@ "split": "Rozdělené zobrazení", "split_description": "Seznam zpráv a panel pro čtení zůstávají viditelné vedle sebe.", "focus": "Soustředěný seznam", - "focus_description": "Zobrazit jeden řádek na zprávu a otevřít poštu na plnou šířku s viditelným panelem složek." + "focus_description": "Zobrazit jeden řádek na zprávu a otevřít poštu na plnou šířku s viditelným panelem složek.", + "horizontal": "Čtecí panel dole", + "horizontal_description": "Zobrazit seznam zpráv nahoře a otevřít vybranou zprávu ve čtecím panelu pod ním." }, "show_preview": { "label": "Zobrazit náhledový text", @@ -960,6 +971,12 @@ "label": "Automaticky vybírat adresu pro odpověď", "description": "Při odpovídání automaticky přepnout adresu odesílatele na identitu, která původně obdržela zprávu" }, + "signature_position": { + "label": "Pozice podpisu", + "description": "Kam vložit podpis v odpovědích a přeposláních. Nad citovaným textem působí přirozeně jako zakončení odpovědi; pod ním zachovává původní zprávu vcelku.", + "above_quote": "Před citovaným textem", + "below_quote": "Za citovaným textem" + }, "sub_address_delimiter": { "label": "Oddělovač sub-adresy", "description": "Znak oddělující uživatelské jméno od sub-adresy. Zvolte oddělovač používaný vaším poštovním serverem (např. uzivatel{delimiter}stitek@domena.cz).", diff --git a/locales/de/common.json b/locales/de/common.json index ecfdf1a0..8bf3462e 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -528,6 +528,15 @@ "to": "An: {recipients}" }, "remove_sub_address": "Sub-Adresse entfernen", + "from_override": { + "toggle_off": "Überschreiben", + "toggle_on": "Überschreibung aufheben", + "toggle_tooltip": "Bearbeiten Sie Absendername und -adresse frei. Die E-Mail wird weiterhin über Ihre Identität gesendet — nur die sichtbare Absenderkopfzeile ändert sich.", + "name_label": "Absendername", + "name_placeholder": "Name", + "email_label": "Absender-E-Mail-Adresse", + "email_placeholder": "alias@example.com" + }, "use_template": "Vorlage", "save_as_template": "Als Vorlage speichern", "validation": { @@ -941,7 +950,9 @@ "split": "Geteilter Bereich", "split_description": "Nachrichtenliste und Lesebereich nebeneinander sichtbar halten.", "focus": "Fokussierte Liste", - "focus_description": "Eine Zeile pro Nachricht anzeigen und E-Mails in voller Breite öffnen, während die Ordner-Seitenleiste sichtbar bleibt." + "focus_description": "Eine Zeile pro Nachricht anzeigen und E-Mails in voller Breite öffnen, während die Ordner-Seitenleiste sichtbar bleibt.", + "horizontal": "Lesebereich unten", + "horizontal_description": "Nachrichtenliste oben anzeigen und ausgewählte Nachricht in einem darunter liegenden Lesebereich öffnen." }, "show_preview": { "label": "Vorschautext anzeigen", @@ -960,6 +971,12 @@ "label": "Antwortadresse automatisch wählen", "description": "Beim Antworten die Absenderadresse automatisch auf die Identität umstellen, die die ursprüngliche Nachricht erhalten hat" }, + "signature_position": { + "label": "Signaturposition", + "description": "Wo Ihre Signatur in Antworten und Weiterleitungen eingefügt wird. Über dem zitierten Text liest sie sich natürlich als Abschluss der Antwort; darunter bleibt die ursprüngliche Nachricht zusammenhängend.", + "above_quote": "Vor zitiertem Text", + "below_quote": "Nach zitiertem Text" + }, "sub_address_delimiter": { "label": "Sub-Adress-Trennzeichen", "description": "Zeichen, das Ihren Benutzernamen vom Sub-Adress-Tag trennt. Verwenden Sie das von Ihrem Mailserver verwendete Trennzeichen (z. B. benutzer{delimiter}tag@domain.de).", diff --git a/locales/es/common.json b/locales/es/common.json index b12de85d..d2d2584d 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -528,6 +528,15 @@ "to": "Para: {recipients}" }, "remove_sub_address": "Eliminar sub-dirección", + "from_override": { + "toggle_off": "Anular", + "toggle_on": "Cancelar anulación", + "toggle_tooltip": "Edita libremente el nombre y la dirección del remitente. El correo aún se envía a través de tu identidad — solo cambia el encabezado De visible.", + "name_label": "Nombre del remitente", + "name_placeholder": "Nombre", + "email_label": "Dirección de correo del remitente", + "email_placeholder": "alias@example.com" + }, "use_template": "Plantilla", "save_as_template": "Guardar como plantilla", "validation": { @@ -941,7 +950,9 @@ "split": "Panel dividido", "split_description": "Mantener la lista de mensajes y el panel de lectura visibles uno al lado del otro.", "focus": "Lista enfocada", - "focus_description": "Mostrar una línea por mensaje y abrir el correo a ancho completo manteniendo visible la barra lateral de carpetas." + "focus_description": "Mostrar una línea por mensaje y abrir el correo a ancho completo manteniendo visible la barra lateral de carpetas.", + "horizontal": "Panel de lectura abajo", + "horizontal_description": "Muestra la lista de mensajes arriba y abre el mensaje seleccionado en un panel de lectura debajo." }, "disable_threading": { "label": "Desactivar agrupación de conversaciones", @@ -955,6 +966,12 @@ "label": "Seleccionar dirección de respuesta automáticamente", "description": "Al responder, cambia automáticamente la dirección del remitente a la identidad que recibió el mensaje original" }, + "signature_position": { + "label": "Posición de la firma", + "description": "Dónde insertar tu firma en respuestas y reenvíos. Encima del texto citado se lee de forma natural como cierre de la respuesta; debajo mantiene el mensaje original contiguo.", + "above_quote": "Antes del texto citado", + "below_quote": "Después del texto citado" + }, "sub_address_delimiter": { "label": "Delimitador de sub-dirección", "description": "Carácter que separa tu nombre de usuario de la etiqueta de sub-dirección. Usa el delimitador que utilice tu servidor de correo (por ejemplo, usuario{delimiter}etiqueta@dominio.com).", diff --git a/locales/fr/common.json b/locales/fr/common.json index ab6504e6..39b61289 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -528,6 +528,15 @@ "to": "À : {recipients}" }, "remove_sub_address": "Retirer le sous-adressage", + "from_override": { + "toggle_off": "Remplacer", + "toggle_on": "Annuler le remplacement", + "toggle_tooltip": "Modifiez librement le nom et l'adresse d'expéditeur. Le courrier est toujours envoyé via votre identité — seul l'en-tête De visible change.", + "name_label": "Nom de l'expéditeur", + "name_placeholder": "Nom", + "email_label": "Adresse e-mail de l'expéditeur", + "email_placeholder": "alias@example.com" + }, "use_template": "Modèle", "save_as_template": "Enregistrer comme modèle", "validation": { @@ -941,7 +950,9 @@ "split": "Volet divisé", "split_description": "Garder la liste des messages et le volet de lecture visibles côte à côte.", "focus": "Liste focalisée", - "focus_description": "Afficher une ligne par message et ouvrir le courrier en pleine largeur tout en gardant la barre latérale des dossiers visible." + "focus_description": "Afficher une ligne par message et ouvrir le courrier en pleine largeur tout en gardant la barre latérale des dossiers visible.", + "horizontal": "Volet de lecture en bas", + "horizontal_description": "Afficher la liste des messages en haut et ouvrir le message sélectionné dans un volet de lecture en dessous." }, "disable_threading": { "label": "Désactiver le regroupement par conversation", @@ -955,6 +966,12 @@ "label": "Sélection automatique de l'adresse de réponse", "description": "Lors d'une réponse, bascule automatiquement l'adresse d'expédition vers l'identité qui a reçu le message d'origine" }, + "signature_position": { + "label": "Position de la signature", + "description": "Où insérer votre signature dans les réponses et les transferts. Au-dessus du texte cité, elle se lit naturellement comme la conclusion de la réponse ; en dessous, elle garde le message d'origine contigu.", + "above_quote": "Avant le texte cité", + "below_quote": "Après le texte cité" + }, "sub_address_delimiter": { "label": "Délimiteur de sous-adresse", "description": "Caractère séparant votre nom d'utilisateur de l'étiquette de sous-adresse. Utilisez le délimiteur configuré sur votre serveur de messagerie (par ex. utilisateur{delimiter}tag@domaine.com).", diff --git a/locales/it/common.json b/locales/it/common.json index 8a55b533..5a0320d0 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -528,6 +528,15 @@ "to": "A: {recipients}" }, "remove_sub_address": "Rimuovi sotto-indirizzo", + "from_override": { + "toggle_off": "Sovrascrivi", + "toggle_on": "Annulla sovrascrittura", + "toggle_tooltip": "Modifica liberamente nome e indirizzo del mittente. La posta viene comunque inviata tramite la tua identità — cambia solo l'intestazione Da visibile.", + "name_label": "Nome mittente", + "name_placeholder": "Nome", + "email_label": "Indirizzo email del mittente", + "email_placeholder": "alias@example.com" + }, "use_template": "Modello", "save_as_template": "Salva come modello", "validation": { @@ -941,7 +950,9 @@ "split": "Pannello diviso", "split_description": "Mantieni la lista dei messaggi e il pannello di lettura visibili affiancati.", "focus": "Lista focalizzata", - "focus_description": "Mostra una riga per messaggio e apri la posta a larghezza piena mantenendo visibile la barra laterale delle cartelle." + "focus_description": "Mostra una riga per messaggio e apri la posta a larghezza piena mantenendo visibile la barra laterale delle cartelle.", + "horizontal": "Riquadro di lettura in basso", + "horizontal_description": "Mostra l'elenco dei messaggi in alto e apri il messaggio selezionato in un riquadro di lettura sotto." }, "disable_threading": { "label": "Disabilita raggruppamento conversazioni", @@ -955,6 +966,12 @@ "label": "Seleziona automaticamente l'indirizzo di risposta", "description": "Quando rispondi, passa automaticamente l'indirizzo mittente all'identità che ha ricevuto il messaggio originale" }, + "signature_position": { + "label": "Posizione della firma", + "description": "Dove inserire la tua firma nelle risposte e negli inoltri. Sopra il testo citato si legge in modo naturale come chiusura della risposta; sotto mantiene il messaggio originale contiguo.", + "above_quote": "Prima del testo citato", + "below_quote": "Dopo il testo citato" + }, "sub_address_delimiter": { "label": "Delimitatore sub-indirizzo", "description": "Carattere che separa il tuo nome utente dall'etichetta del sub-indirizzo. Usa il delimitatore configurato sul tuo server di posta (es. utente{delimiter}tag@dominio.com).", diff --git a/locales/ja/common.json b/locales/ja/common.json index 748a9f4c..737d1cec 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -528,6 +528,15 @@ "to": "宛先: {recipients}" }, "remove_sub_address": "サブアドレスを削除", + "from_override": { + "toggle_off": "上書き", + "toggle_on": "上書きを取り消す", + "toggle_tooltip": "差出人名とアドレスを自由に編集できます。メールは引き続きあなたのアイデンティティ経由で送信されます — 表示される差出人ヘッダーのみが変更されます。", + "name_label": "差出人名", + "name_placeholder": "名前", + "email_label": "差出人メールアドレス", + "email_placeholder": "alias@example.com" + }, "use_template": "テンプレート", "save_as_template": "テンプレートとして保存", "validation": { @@ -941,7 +950,9 @@ "split": "分割ペイン", "split_description": "メッセージリストと読み取りペインを並べて表示します。", "focus": "集中リスト", - "focus_description": "メッセージごとに1行表示し、フォルダサイドバーを表示したままメールを全幅で開きます。" + "focus_description": "メッセージごとに1行表示し、フォルダサイドバーを表示したままメールを全幅で開きます。", + "horizontal": "閲覧ペインを下に表示", + "horizontal_description": "メッセージ一覧を上に表示し、選択したメッセージを下の閲覧ペインで開きます。" }, "disable_threading": { "label": "会話グループ化を無効にする", @@ -955,6 +966,12 @@ "label": "返信元アドレスを自動選択", "description": "返信時に、元のメッセージを受信したIDへ差出人アドレスを自動的に切り替えます" }, + "signature_position": { + "label": "署名の位置", + "description": "返信や転送で署名を挿入する位置。引用テキストの上は返信の締めとして自然に読めます。下は元のメッセージを続けて表示します。", + "above_quote": "引用テキストの前", + "below_quote": "引用テキストの後" + }, "sub_address_delimiter": { "label": "サブアドレス区切り文字", "description": "ユーザー名とサブアドレスタグを区切る文字です。お使いのメールサーバーが使用する区切り文字に合わせてください(例: user{delimiter}tag@domain.com)。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 89d3bdd8..86baec33 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -528,6 +528,15 @@ "to": "받는 사람: {recipients}" }, "remove_sub_address": "서브 어드레스 삭제", + "from_override": { + "toggle_off": "재정의", + "toggle_on": "재정의 취소", + "toggle_tooltip": "보낸 사람 이름과 주소를 자유롭게 편집하세요. 메일은 여전히 사용자의 ID를 통해 전송되며 — 표시되는 보낸 사람 헤더만 변경됩니다.", + "name_label": "보낸 사람 이름", + "name_placeholder": "이름", + "email_label": "보낸 사람 이메일 주소", + "email_placeholder": "alias@example.com" + }, "use_template": "템플릿", "save_as_template": "템플릿으로 저장", "validation": { @@ -941,7 +950,9 @@ "split": "화면 분할", "split_description": "메일 목록과 읽기 창을 나란히 보여줘요.", "focus": "집중형 목록", - "focus_description": "메일을 한 줄로 보여주고, 클릭하면 폴더 사이드바는 남겨둔 채 메일 내용을 넓게 보여줘요." + "focus_description": "메일을 한 줄로 보여주고, 클릭하면 폴더 사이드바는 남겨둔 채 메일 내용을 넓게 보여줘요.", + "horizontal": "읽기 창 하단 표시", + "horizontal_description": "메시지 목록을 상단에 표시하고 선택한 메시지를 하단 읽기 창에서 엽니다." }, "show_preview": { "label": "미리보기 텍스트 표시", @@ -960,6 +971,12 @@ "label": "답장 시 보내는 사람 자동 선택", "description": "답장할 때 메일을 받았던 주소로 보내는 사람을 자동으로 변경해요" }, + "signature_position": { + "label": "서명 위치", + "description": "답장과 전달에서 서명을 삽입할 위치. 인용된 텍스트 위에 두면 답장의 마무리처럼 자연스럽게 읽히고, 아래에 두면 원본 메시지가 이어져 보입니다.", + "above_quote": "인용 텍스트 앞", + "below_quote": "인용 텍스트 뒤" + }, "sub_address_delimiter": { "label": "서브 주소 구분자", "description": "사용자 이름과 서브 주소 태그를 나누는 문자예요. 메일 서버가 사용하는 구분자에 맞춰 주세요 (예: user{delimiter}tag@domain.com).", diff --git a/locales/lv/common.json b/locales/lv/common.json index db45b286..0826e672 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -528,6 +528,15 @@ "to": "Kam: {recipients}" }, "remove_sub_address": "Noņemt apakšadresi", + "from_override": { + "toggle_off": "Pārrakstīt", + "toggle_on": "Atcelt pārrakstīšanu", + "toggle_tooltip": "Brīvi rediģējiet sūtītāja vārdu un adresi. Pasts joprojām tiek sūtīts caur jūsu identitāti — mainās tikai redzamais No galvenes ieraksts.", + "name_label": "Sūtītāja vārds", + "name_placeholder": "Vārds", + "email_label": "Sūtītāja e-pasta adrese", + "email_placeholder": "alias@example.com" + }, "use_template": "Veidne", "save_as_template": "Saglabāt kā veidni", "validation": { @@ -941,7 +950,9 @@ "split": "Dalīts skats", "split_description": "Ziņojumu saraksts un lasīšanas rūts ir redzamas blakus.", "focus": "Fokusa saraksts", - "focus_description": "Rādīt vienu rindu katram ziņojumam un atvērt vēstuli pilnā platumā." + "focus_description": "Rādīt vienu rindu katram ziņojumam un atvērt vēstuli pilnā platumā.", + "horizontal": "Lasīšanas rūts apakšā", + "horizontal_description": "Rādīt ziņojumu sarakstu augšā un atvērt atlasīto ziņojumu lasīšanas rūtī zem tā." }, "disable_threading": { "label": "Izslēgt sarunu grupēšanu", @@ -955,6 +966,12 @@ "label": "Automātiski izvēlēties atbildes adresi", "description": "Atbildot automātiski izmantot to kontu, uz kuru vēstule tika saņemta" }, + "signature_position": { + "label": "Paraksta novietojums", + "description": "Kur ievietot jūsu parakstu atbildēs un pārsūtīšanā. Virs citētā teksta tas dabiski lasās kā atbildes noslēgums; zem tā saglabā oriģinālo ziņojumu vienkopus.", + "above_quote": "Pirms citētā teksta", + "below_quote": "Pēc citētā teksta" + }, "sub_address_delimiter": { "label": "Apakšadreses atdalītājs", "description": "Zīme, kas atdala lietotājvārdu no apakšadreses tagu. Izvēlieties atdalītāju, ko lieto jūsu pasta serveris (piem. lietotajs{delimiter}tags@domens.lv).", diff --git a/locales/nl/common.json b/locales/nl/common.json index 865ecb53..3f340d93 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -528,6 +528,15 @@ "to": "Aan: {recipients}" }, "remove_sub_address": "Sub-adres verwijderen", + "from_override": { + "toggle_off": "Overschrijven", + "toggle_on": "Overschrijven annuleren", + "toggle_tooltip": "Bewerk de naam en het adres van de afzender vrij. E-mail wordt nog steeds via je identiteit verzonden — alleen de zichtbare Van-koptekst verandert.", + "name_label": "Afzendernaam", + "name_placeholder": "Naam", + "email_label": "E-mailadres afzender", + "email_placeholder": "alias@example.com" + }, "use_template": "Sjabloon", "save_as_template": "Opslaan als sjabloon", "validation": { @@ -941,7 +950,9 @@ "split": "Gesplitst venster", "split_description": "Houd de berichtenlijst en het leesvenster naast elkaar zichtbaar.", "focus": "Gefocuste lijst", - "focus_description": "Toon één regel per bericht en open e-mail op volledige breedte terwijl de mappenzijbalk zichtbaar blijft." + "focus_description": "Toon één regel per bericht en open e-mail op volledige breedte terwijl de mappenzijbalk zichtbaar blijft.", + "horizontal": "Leesvenster onderaan", + "horizontal_description": "Toon de berichtenlijst bovenaan en open het geselecteerde bericht in een leesvenster eronder." }, "disable_threading": { "label": "Conversatiegroepering uitschakelen", @@ -955,6 +966,12 @@ "label": "Antwoordadres automatisch selecteren", "description": "Schakel bij het beantwoorden automatisch het Van-adres om naar de identiteit die het oorspronkelijke bericht ontving" }, + "signature_position": { + "label": "Positie van handtekening", + "description": "Waar je handtekening in antwoorden en doorgestuurde berichten moet worden ingevoegd. Boven de geciteerde tekst leest natuurlijk als afsluiting van het antwoord; eronder houdt het originele bericht aaneengesloten.", + "above_quote": "Voor geciteerde tekst", + "below_quote": "Na geciteerde tekst" + }, "sub_address_delimiter": { "label": "Sub-adres scheidingsteken", "description": "Teken dat je gebruikersnaam scheidt van het sub-adres-label. Gebruik het scheidingsteken dat je mailserver gebruikt (bv. gebruiker{delimiter}tag@domein.nl).", diff --git a/locales/pl/common.json b/locales/pl/common.json index 81b531e5..be6a0db4 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -528,6 +528,15 @@ "to": "Do: {recipients}" }, "remove_sub_address": "Usuń podadres", + "from_override": { + "toggle_off": "Zastąp", + "toggle_on": "Anuluj zastąpienie", + "toggle_tooltip": "Swobodnie edytuj nazwę i adres nadawcy. Poczta jest nadal wysyłana przez twoją tożsamość — zmienia się tylko widoczny nagłówek Od.", + "name_label": "Nazwa nadawcy", + "name_placeholder": "Nazwa", + "email_label": "Adres e-mail nadawcy", + "email_placeholder": "alias@example.com" + }, "use_template": "Szablon", "save_as_template": "Zapisz jako szablon", "validation": { @@ -941,7 +950,9 @@ "split": "Widok podzielony", "split_description": "Lista wiadomości i panel czytania pozostają widoczne obok siebie.", "focus": "Skupiona lista", - "focus_description": "Pokazuj jeden wiersz na wiadomość i otwieraj pocztę na pełną szerokość, pozostawiając widoczny pasek folderów." + "focus_description": "Pokazuj jeden wiersz na wiadomość i otwieraj pocztę na pełną szerokość, pozostawiając widoczny pasek folderów.", + "horizontal": "Okienko czytania na dole", + "horizontal_description": "Pokaż listę wiadomości u góry i otwórz wybraną wiadomość w okienku czytania pod nią." }, "show_preview": { "label": "Pokaż tekst podglądu", @@ -960,6 +971,12 @@ "label": "Automatycznie wybieraj adres odpowiedzi", "description": "Podczas odpowiadania automatycznie przełączaj adres nadawcy na tożsamość, która pierwotnie otrzymała wiadomość" }, + "signature_position": { + "label": "Pozycja podpisu", + "description": "Gdzie wstawić podpis w odpowiedziach i wiadomościach przekazanych dalej. Nad cytowanym tekstem brzmi naturalnie jako zakończenie odpowiedzi; pod nim zachowuje oryginalną wiadomość w całości.", + "above_quote": "Przed cytowanym tekstem", + "below_quote": "Po cytowanym tekście" + }, "sub_address_delimiter": { "label": "Separator sub-adresu", "description": "Znak oddzielający Twoją nazwę użytkownika od tagu sub-adresu. Użyj separatora zgodnego z Twoim serwerem pocztowym (np. user{delimiter}tag@domain.com).", diff --git a/locales/pt/common.json b/locales/pt/common.json index 56de530e..50df25f0 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -528,6 +528,15 @@ "to": "Para: {recipients}" }, "remove_sub_address": "Remover sub-endereço", + "from_override": { + "toggle_off": "Substituir", + "toggle_on": "Cancelar substituição", + "toggle_tooltip": "Edite livremente o nome e o endereço do remetente. O email ainda é enviado através da sua identidade — apenas o cabeçalho De visível muda.", + "name_label": "Nome do remetente", + "name_placeholder": "Nome", + "email_label": "Endereço de email do remetente", + "email_placeholder": "alias@example.com" + }, "use_template": "Modelo", "save_as_template": "Salvar como modelo", "validation": { @@ -941,7 +950,9 @@ "split": "Painel dividido", "split_description": "Manter a lista de mensagens e o painel de leitura visíveis lado a lado.", "focus": "Lista focada", - "focus_description": "Mostrar uma linha por mensagem e abrir o e-mail em largura total mantendo a barra lateral de pastas visível." + "focus_description": "Mostrar uma linha por mensagem e abrir o e-mail em largura total mantendo a barra lateral de pastas visível.", + "horizontal": "Painel de leitura em baixo", + "horizontal_description": "Mostre a lista de mensagens em cima e abra a mensagem selecionada num painel de leitura abaixo." }, "disable_threading": { "label": "Desativar agrupamento de conversas", @@ -955,6 +966,12 @@ "label": "Selecionar automaticamente o endereço de resposta", "description": "Ao responder, muda automaticamente o endereço do remetente para a identidade que recebeu a mensagem original" }, + "signature_position": { + "label": "Posição da assinatura", + "description": "Onde inserir a sua assinatura em respostas e encaminhamentos. Acima do texto citado lê-se naturalmente como fecho da resposta; abaixo mantém a mensagem original contígua.", + "above_quote": "Antes do texto citado", + "below_quote": "Depois do texto citado" + }, "sub_address_delimiter": { "label": "Delimitador de sub-endereço", "description": "Caractere que separa seu nome de usuário da tag de sub-endereço. Use o delimitador configurado no seu servidor de e-mail (ex.: usuario{delimiter}tag@dominio.com).", diff --git a/locales/ru/common.json b/locales/ru/common.json index 2fc2bb7e..45418699 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -528,6 +528,15 @@ "to": "Кому: {recipients}" }, "remove_sub_address": "Удалить суб-адрес", + "from_override": { + "toggle_off": "Переопределить", + "toggle_on": "Отменить переопределение", + "toggle_tooltip": "Свободно редактируйте имя и адрес отправителя. Письмо по-прежнему отправляется через вашу учётную запись — меняется только видимый заголовок От.", + "name_label": "Имя отправителя", + "name_placeholder": "Имя", + "email_label": "Email отправителя", + "email_placeholder": "alias@example.com" + }, "use_template": "Шаблон", "save_as_template": "Сохранить как шаблон", "validation": { @@ -941,7 +950,9 @@ "split": "Разделённая панель", "split_description": "Список сообщений и панель чтения отображаются рядом друг с другом.", "focus": "Сфокусированный список", - "focus_description": "Показывать одну строку на сообщение и открывать письма на всю ширину, сохраняя видимой боковую панель папок." + "focus_description": "Показывать одну строку на сообщение и открывать письма на всю ширину, сохраняя видимой боковую панель папок.", + "horizontal": "Область чтения снизу", + "horizontal_description": "Показывать список сообщений сверху и открывать выбранное сообщение в области чтения под ним." }, "disable_threading": { "label": "Отключить группировку по беседам", @@ -955,6 +966,12 @@ "label": "Автоматически выбирать адрес для ответа", "description": "При ответе автоматически переключать адрес отправителя на ту учетную запись, которая получила исходное сообщение" }, + "signature_position": { + "label": "Положение подписи", + "description": "Куда вставлять подпись в ответах и пересылке. Над цитируемым текстом она читается естественно как завершение ответа; под ним сохраняет целостность исходного сообщения.", + "above_quote": "Перед цитируемым текстом", + "below_quote": "После цитируемого текста" + }, "sub_address_delimiter": { "label": "Разделитель суб-адресов", "description": "Символ, отделяющий имя пользователя от тега суб-адреса. Используйте разделитель, настроенный на вашем почтовом сервере (например, user{delimiter}tag@domain.com).", diff --git a/locales/tr/common.json b/locales/tr/common.json index ecb2b453..6c218f91 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -531,6 +531,15 @@ "to": "Kime: {recipients}" }, "remove_sub_address": "Alt adresi kaldır", + "from_override": { + "toggle_off": "Geçersiz kıl", + "toggle_on": "Geçersiz kılmayı iptal et", + "toggle_tooltip": "Gönderen adını ve adresini serbestçe düzenleyin. Posta hâlâ kimliğiniz üzerinden gönderilir — yalnızca görünür Kimden başlığı değişir.", + "name_label": "Gönderen adı", + "name_placeholder": "Ad", + "email_label": "Gönderen e-posta adresi", + "email_placeholder": "alias@example.com" + }, "use_template": "Şablon", "save_as_template": "Şablon Olarak Kaydet", "validation": { @@ -941,7 +950,9 @@ "split": "Bölünmüş bölme", "split_description": "İleti listesi ve okuma bölmesini yan yana görünür tutun.", "focus": "Odaklı liste", - "focus_description": "İleti başına bir satır gösterin ve klasör kenar çubuğu görünürken postayı tam genişlikte açın." + "focus_description": "İleti başına bir satır gösterin ve klasör kenar çubuğu görünürken postayı tam genişlikte açın.", + "horizontal": "Okuma bölmesi altta", + "horizontal_description": "Mesaj listesini üstte gösterin ve seçilen mesajı altındaki okuma bölmesinde açın." }, "show_preview": { "label": "Önizleme Metnini Göster", @@ -960,6 +971,12 @@ "label": "Yanıt Adresini Otomatik Seç", "description": "Yanıtlarken, Kimden adresini iletiyi başlangıçta alan kimliğe otomatik olarak değiştir" }, + "signature_position": { + "label": "İmza konumu", + "description": "Yanıtlarda ve iletmelerde imzanızın nereye ekleneceği. Alıntılanan metnin üzerinde, yanıt için doğal bir kapanış olarak okunur; altında ise orijinal mesajı bir bütün hâlinde tutar.", + "above_quote": "Alıntılanan metinden önce", + "below_quote": "Alıntılanan metinden sonra" + }, "sub_address_delimiter": { "label": "Alt Adres Ayırıcı", "description": "Kullanıcı adını alt adres etiketinden ayıran karakter. Posta sunucunuzun kullandığı ayırıcıyı seçin (ör. kullanici{delimiter}etiket@domain.com).", diff --git a/locales/uk/common.json b/locales/uk/common.json index 29825189..25236cf5 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -528,6 +528,15 @@ "to": "Кому: {recipients}" }, "remove_sub_address": "Видалити підадресу", + "from_override": { + "toggle_off": "Замінити", + "toggle_on": "Скасувати заміну", + "toggle_tooltip": "Вільно редагуйте ім'я та адресу відправника. Пошта все ще надсилається через вашу ідентичність — змінюється лише видимий заголовок Від.", + "name_label": "Ім'я відправника", + "name_placeholder": "Ім'я", + "email_label": "Електронна адреса відправника", + "email_placeholder": "alias@example.com" + }, "use_template": "Шаблон", "save_as_template": "Зберегти як шаблон", "validation": { @@ -941,7 +950,9 @@ "split": "Розділена панель", "split_description": "Тримайте список повідомлень і панель читання видимими поруч.", "focus": "Сфокусований список", - "focus_description": "Показувати один рядок на повідомлення та відкривати пошту на всю ширину, зберігаючи бічну панель папки видимою." + "focus_description": "Показувати один рядок на повідомлення та відкривати пошту на всю ширину, зберігаючи бічну панель папки видимою.", + "horizontal": "Область читання знизу", + "horizontal_description": "Показуйте список повідомлень угорі та відкривайте вибране повідомлення в області читання під ним." }, "show_preview": { "label": "Показати попередній перегляд тексту", @@ -960,6 +971,12 @@ "label": "Автоматичний вибір адреси для відповіді", "description": "Під час відповіді автоматично змінюйте адресу відправника на особу, яка спочатку отримала повідомлення" }, + "signature_position": { + "label": "Розташування підпису", + "description": "Куди вставляти підпис у відповідях і пересиланнях. Над цитованим текстом читається природно як завершення відповіді; під ним зберігає цілісність оригінального повідомлення.", + "above_quote": "Перед цитованим текстом", + "below_quote": "Після цитованого тексту" + }, "sub_address_delimiter": { "label": "Розділювач під-адреси", "description": "Символ, який відокремлює ім'я користувача від мітки під-адреси. Використовуйте розділювач, налаштований на вашому поштовому сервері (напр. user{delimiter}tag@domain.com).", diff --git a/locales/zh/common.json b/locales/zh/common.json index c11a1b1f..94076379 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -528,6 +528,15 @@ "to": "至:{recipients}" }, "remove_sub_address": "删除子地址", + "from_override": { + "toggle_off": "覆盖", + "toggle_on": "取消覆盖", + "toggle_tooltip": "自由编辑发件人姓名和地址。邮件仍通过您的身份发送 — 仅可见的发件人标题发生变化。", + "name_label": "发件人姓名", + "name_placeholder": "姓名", + "email_label": "发件人电子邮件地址", + "email_placeholder": "alias@example.com" + }, "use_template": "模板", "save_as_template": "保存为模板", "validation": { @@ -941,7 +950,9 @@ "split": "分栏视图", "split_description": "左侧显示邮件列表,右侧显示阅读窗格。", "focus": "沉浸模式", - "focus_description": "每封邮件显示一行,打开邮件时使用全宽阅读视图,同时保留文件夹侧边栏。" + "focus_description": "每封邮件显示一行,打开邮件时使用全宽阅读视图,同时保留文件夹侧边栏。", + "horizontal": "底部阅读窗格", + "horizontal_description": "在顶部显示邮件列表,并在下方的阅读窗格中打开所选邮件。" }, "show_preview": { "label": "显示预览文本", @@ -960,6 +971,12 @@ "label": "自动选择回复地址", "description": "回复时自动将发件人地址切换为最初收到该邮件的身份" }, + "signature_position": { + "label": "签名位置", + "description": "在回复和转发中插入签名的位置。位于引用文本上方时,可作为回复的自然结尾;位于下方时,保持原始邮件连贯。", + "above_quote": "引用文本之前", + "below_quote": "引用文本之后" + }, "sub_address_delimiter": { "label": "子地址分隔符", "description": "用于分隔用户名和子地址标签的字符。请选择与您的邮件服务器一致的分隔符(例如 user{delimiter}tag@domain.com)。", From 2ad2bb1e09a30f01fa25bc953791b8a93d2f8010 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 11 May 2026 20:00:55 +0200 Subject: [PATCH 034/133] chore: update version to 1.6.4 --- CHANGELOG.md | 38 ++++++++++++++++++++++++++++++++++++++ README.md | 25 ++++++++++++++++++++++++- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d954cd75..0926adcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## 1.6.4 (2026-05-11) + +### Web Setup Wizard + +First-launch web setup wizard. New installs no longer need to hand-edit `.env.local` - point a browser at the container and the wizard probes the JMAP server(s), configures OAuth/OIDC, generates the session secret, accepts branding uploads, and provisions the initial admin password. Admin storage is now split into `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps); the legacy `ADMIN_DATA_DIR` keeps working for existing installs. + +### Features + +- **Setup**: Web setup wizard with multi-step flow: Server, Auth, Security, Logging, Branding, Review, Admin +- **Setup**: Admin config/state directory split with optional `ADMIN_CONFIG_READONLY` for immutable deployments (#226) +- **Setup**: File uploads on the wizard branding step +- **Setup**: Redesigned review step with grouped summary and an advanced toggle for the full config +- **Setup**: Require explicit confirmation when JMAP probe finds no session +- **Mail**: Drag attachments out of the viewer to the local file system (#267) +- **Mail**: Reading Pane at Bottom mail layout (#262) +- **Mail**: Configurable signature position - above or below quoted text (#266) +- **Mail**: Signature position is now searchable from the email behavior settings +- **Mail**: Show avatar in Focused list for compact density and above +- **Mail**: Align Focused list preview with other layout previews +- **Compose**: From-header override in the composer with catch-all auto-reply, replies to an alias on a domain you own pre-fill the alias as the sender even when it isn't a configured identity (#246) + +### Performance + +- **Mail**: Prefetch initial email data on login +- **Auth**: Parallelize login round-trips and drop redundant JMAP re-verify + +### Fixes + +- **Auth**: Skip upstream JMAP reverify for trusted URLs (#237) +- **Auth**: Show account identity in the switcher header instead of the sending alias +- **Compose**: Fall back to the primary identity signature on reply +- **Setup**: Drop redundant first-login banner about removing `ADMIN_PASSWORD` (#222) +- **UI**: Consistent notice cards for server probe results + +### i18n + +- Add missing translation keys across 15 locales + ## 1.6.3 (2026-05-08) ### Features diff --git a/README.md b/README.md index e8851626..5d8ae6eb 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar [![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.6.3-green.svg?logo=git&logoColor=white)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.6.4-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) [![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/) @@ -20,6 +20,29 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar --- +## Installer + +New in **1.6.4**: a web-based setup wizard runs on first launch – no `.env.local` editing, no shelling into the container. + + + + Setup wizard + + +Point a browser at the running container and the wizard guides you through: + +- **Server** – probe one or more JMAP endpoints, optional auto-pick by email domain, Stalwart feature toggle +- **Auth** – OAuth2 / OIDC discovery and validation, or basic-auth fallback +- **Security** – generate or paste a `SESSION_SECRET`, opt into settings sync +- **Logging** – text or JSON, level +- **Branding** – upload favicon, app logos, login logos, and company / legal URLs +- **Review** – grouped summary with an advanced toggle for the full config +- **Admin** – set the initial admin password and optionally drop a `.config-locked` marker so the config volume can be remounted read-only + +The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JMAP_SERVER_URL` in the environment skips the wizard and uses env-managed configuration instead. + +--- + ## Screenshots diff --git a/package-lock.json b/package-lock.json index af394484..4401e23d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bulwark-webmail", - "version": "1.6.3", + "version": "1.6.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bulwark-webmail", - "version": "1.6.3", + "version": "1.6.4", "license": "AGPL-3.0-only", "dependencies": { "@tanstack/react-virtual": "^3.13.24", diff --git a/package.json b/package.json index c13ab0e8..05e42ba4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bulwark-webmail", - "version": "1.6.3", + "version": "1.6.4", "description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server", "author": "Bulwark Webmail ", "license": "AGPL-3.0-only", From 869ee07ebc2b0d6966474c11b3caa27b4be66857 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 11 May 2026 20:11:05 +0200 Subject: [PATCH 035/133] chore: bump next to 16.2.6 for security advisories --- package-lock.json | 80 +++++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4401e23d..99fbd6ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "dompurify": "^3.4.1", "jszip": "^3.10.1", "lucide-react": "^1.8.0", - "next": "^16.2.4", + "next": "^16.2.6", "next-intl": "^4.9.1", "otpauth": "^9.5.0", "pkijs": "^3.4.0", @@ -1910,15 +1910,15 @@ } }, "node_modules/@next/env": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", - "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz", + "integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", - "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz", + "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==", "cpu": [ "arm64" ], @@ -1932,9 +1932,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", - "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz", + "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==", "cpu": [ "x64" ], @@ -1948,9 +1948,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", - "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz", + "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==", "cpu": [ "arm64" ], @@ -1964,9 +1964,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", - "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz", + "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==", "cpu": [ "arm64" ], @@ -1980,9 +1980,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", - "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz", + "integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==", "cpu": [ "x64" ], @@ -1996,9 +1996,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", - "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz", + "integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==", "cpu": [ "x64" ], @@ -2012,9 +2012,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", - "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz", + "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==", "cpu": [ "arm64" ], @@ -2028,9 +2028,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", - "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz", + "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==", "cpu": [ "x64" ], @@ -7516,12 +7516,12 @@ } }, "node_modules/next": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", - "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz", + "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==", "license": "MIT", "dependencies": { - "@next/env": "16.2.4", + "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -7535,14 +7535,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.4", - "@next/swc-darwin-x64": "16.2.4", - "@next/swc-linux-arm64-gnu": "16.2.4", - "@next/swc-linux-arm64-musl": "16.2.4", - "@next/swc-linux-x64-gnu": "16.2.4", - "@next/swc-linux-x64-musl": "16.2.4", - "@next/swc-win32-arm64-msvc": "16.2.4", - "@next/swc-win32-x64-msvc": "16.2.4", + "@next/swc-darwin-arm64": "16.2.6", + "@next/swc-darwin-x64": "16.2.6", + "@next/swc-linux-arm64-gnu": "16.2.6", + "@next/swc-linux-arm64-musl": "16.2.6", + "@next/swc-linux-x64-gnu": "16.2.6", + "@next/swc-linux-x64-musl": "16.2.6", + "@next/swc-win32-arm64-msvc": "16.2.6", + "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { diff --git a/package.json b/package.json index 05e42ba4..061d4374 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "dompurify": "^3.4.1", "jszip": "^3.10.1", "lucide-react": "^1.8.0", - "next": "^16.2.4", + "next": "^16.2.6", "next-intl": "^4.9.1", "otpauth": "^9.5.0", "pkijs": "^3.4.0", From d8e2a1080616c02bfb8f4e106b2b4d3942d94e9b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 11 May 2026 20:41:04 +0200 Subject: [PATCH 036/133] docs: update CONTRIBUTING.md --- CONTRIBUTING.md | 60 +++++++++++++++++++++---------------------------- 1 file changed, 26 insertions(+), 34 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 981fa363..dff9ba71 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,14 +10,17 @@ # Contributing to Bulwark Webmail -Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors. +We're writing the webmail we wanted in 2026 and didn't find. Modern protocol, modern tooling, modern UI. Not a SaaS. Not a startup. Not for sale. -## Join our Community -**New to the project or looking for a place to start?** You don't need to be an expert to contribute! Whether you need help setting up your environment, want to report a bug, or are interested in helping with translations, our Discord is the best place to connect. +If that resonates with you, we'd love your help. This guide covers how to get the project running, the conventions we follow, and how to land your first change. -* **Get Support:** Get real-time help with development hurdles. -* **Contribute:** Share ideas, suggest features, or help us improve documentation. -* **Collaborate:** Meet the team and other contributors working to make Bulwark better. +## Join the Community + +You don't need to be an expert to contribute. Whether you're setting up your dev environment for the first time, filing a bug, or translating a string, the Discord is the fastest way to get unstuck and meet the people working on this. + +- **Get support** - real-time help with development hurdles +- **Share ideas** - feature suggestions, design feedback, doc improvements +- **Collaborate** - meet the team and other contributors [**Join the Bulwark Discord Server**](https://discord.gg/tYCujymGrT) @@ -94,37 +97,31 @@ These checks run automatically on commit via Husky pre-commit hooks. ## Internationalization (i18n) -This project uses **next-intl** for internationalization. Please follow these guidelines: +This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 15 additional locales (cs, de, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh). -### Key Rules +### Rules -1. **Never hardcode user-facing text** - Always use translations: +1. **Never hardcode user-facing text** — always use translations: ```tsx const t = useTranslations("namespace"); return
{t("key")}
; ``` -2. **Translation file locations**: - - English: `/locales/en/common.json` - - French: `/locales/fr/common.json` +2. **Add new keys to `en/common.json` first.** Other locales can follow in the same PR or a follow-up — missing keys fall back to English. 3. **Namespace organization**: - - `login.*` - Login page strings - - `sidebar.*` - Sidebar navigation - - `email_list.*` - Email list component - - `email_viewer.*` - Email viewer component - - `email_composer.*` - Email composer - - `common.*` - Shared strings - - `notifications.*` - Toast/alert messages - - `settings.*` - Settings page + - `login.*` — login page + - `sidebar.*` — sidebar navigation + - `email_list.*` — email list + - `email_viewer.*` — email viewer + - `email_composer.*` — composer + - `settings.*` — settings page + - `notifications.*` — toasts and alerts + - `common.*` — shared strings -4. **Adding new strings**: - - Add to **both** English and French translation files - - Use descriptive, hierarchical keys - - Keep translations consistent in tone +4. **Locale-aware navigation**: -5. **Locale-aware navigation**: ```tsx router.push(`/${params.locale}/settings`); ``` @@ -203,16 +200,11 @@ webmail/ ## Security -- **Never commit sensitive data** (API keys, passwords, etc.) +- **Never commit secrets** — API keys, passwords, tokens, `.env*` files - **Sanitize user input** and email content -- **Block external content** by default for privacy -- Report security vulnerabilities privately (e.g. bulwark@rbm.systems) +- **Block external content** by default — privacy is the point +- **Report vulnerabilities privately** to bulwark@rbm.systems, not via public issues ## Questions? -If you have questions about contributing, feel free to: - -- Open an issue for discussion -- Check existing issues and pull requests - -Thank you for helping improve Bulwark Webmail! +Open an issue, search existing ones, or ask in Discord. Thanks for helping build the webmail we all wished existed. From f9f8af2f1139f79372f958226a7860a875caab2a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 12 May 2026 16:03:10 +0200 Subject: [PATCH 037/133] fix: preserve signature styling and reactivity in above-quote mode #272 --- app/[locale]/page.tsx | 4 +- components/email/email-composer.tsx | 135 +++++++++++++++++---- components/email/rich-text-editor.tsx | 64 +++++++++- components/settings/composing-settings.tsx | 8 ++ lib/signature-utils.ts | 9 +- locales/cs/common.json | 4 + locales/de/common.json | 4 + locales/en/common.json | 4 + locales/es/common.json | 4 + locales/fr/common.json | 4 + locales/it/common.json | 4 + locales/ja/common.json | 4 + locales/ko/common.json | 4 + locales/lv/common.json | 4 + locales/nl/common.json | 4 + locales/pl/common.json | 4 + locales/pt/common.json | 4 + locales/ru/common.json | 4 + locales/tr/common.json | 4 + locales/uk/common.json | 4 + locales/zh/common.json | 4 + stores/settings-store.ts | 3 + 22 files changed, 260 insertions(+), 27 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index c36f366b..072c7145 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -1659,7 +1659,9 @@ export default function Home() { // Append signature from the sending identity (fall back to primary // when the reply-from lives on the same identity but a different alias). - const finalBody = appendPlainTextSignature(body, sendingIdentity); + const finalBody = appendPlainTextSignature(body, sendingIdentity, { + separator: useSettingsStore.getState().signatureSeparatorEnabled, + }); const originalEmailId = selectedEmail.id; diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 75012ce8..cb1aa8d3 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -35,6 +35,7 @@ import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature import { resolveReplyFrom } from "@/lib/reply-identity"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { RichTextEditor } from "@/components/email/rich-text-editor"; +import type { Editor } from "@tiptap/react"; /** Strip HTML tags and decode entities to get a plain-text version */ function htmlToPlainText(html: string): string { @@ -116,6 +117,39 @@ type ComposerAttachment = { abortController?: AbortController; }; +type SignatureIdentityLike = { + htmlSignature?: string; + textSignature?: string; +} | null | undefined; + +// Render the embedded signature for "above quote" mode. Bracketed with +// `data-signature-block` marker paragraphs so we can swap the inner content +// when the user switches identity without losing the surrounding draft or +// quoted message. The markers are preserved through TipTap by the +// StyledParagraph extension. +function buildEmbeddedSignatureHtml( + identity: SignatureIdentityLike, + options: { embed: boolean; separator: boolean } +): string { + if (!options.embed) return ''; + const startMarker = options.separator + ? `

--

` + : `

`; + const endMarker = `

`; + if (identity?.htmlSignature) { + return `${startMarker}${sanitizeEmailHtml(identity.htmlSignature)}${endMarker}`; + } + if (identity?.textSignature) { + const escaped = identity.textSignature + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
'); + return `${startMarker}

${escaped}

${endMarker}`; + } + return ''; +} + export function EmailComposer({ onSend, onClose, @@ -136,6 +170,7 @@ export function EmailComposer({ const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled); const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords); const signaturePosition = useSettingsStore((state) => state.signaturePosition); + const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled); const identities = useIdentityStore((s) => s.identities); const primaryIdentity = identities[0] ?? null; @@ -206,8 +241,9 @@ export function EmailComposer({ // drafting area and the quoted content so it reads naturally as a // closing for the reply body. Send-time append is skipped — see // shouldEmbedSignatureAboveQuote. + const plainSep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n'; const signatureBlock = shouldEmbedSignatureAboveQuote - ? `\n\n-- \n${getPlainTextSignature(initialSignatureIdentity)}` + ? `${plainSep}${getPlainTextSignature(initialSignatureIdentity)}` : ''; if (mode === 'forward') { @@ -225,21 +261,10 @@ export function EmailComposer({ const from = replyTo.from?.[0]; const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); - // When "above quote" is configured, splice signature between the user's - // drafting area and the quoted content so it reads naturally as a closing - // for the reply body. Send-time append is skipped — see - // shouldEmbedSignatureAboveQuote. - const buildEmbeddedSignatureHtml = (): string => { - if (!shouldEmbedSignatureAboveQuote) return ''; - if (initialSignatureIdentity?.htmlSignature) { - return `

--
${sanitizeEmailHtml(initialSignatureIdentity.htmlSignature)}`; - } - if (initialSignatureIdentity?.textSignature) { - return `

--
${initialSignatureIdentity.textSignature.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}`; - } - return ''; - }; - const signatureBlock = buildEmbeddedSignatureHtml(); + const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, { + embed: shouldEmbedSignatureAboveQuote, + separator: signatureSeparatorEnabled, + }); // Build quoted content as HTML if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { @@ -335,6 +360,69 @@ export function EmailComposer({ const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature) ? currentIdentity : primaryIdentity; + + // Hold the TipTap editor instance so we can swap the embedded signature + // when the user switches identity in "above quote" mode without rebuilding + // the whole body (which would lose user edits to the surrounding draft). + const editorRef = useRef(null); + const prevSignatureIdentityIdRef = useRef(signatureIdentity?.id); + const prevSignatureSeparatorRef = useRef(signatureSeparatorEnabled); + + useEffect(() => { + const editor = editorRef.current; + const identityChanged = prevSignatureIdentityIdRef.current !== signatureIdentity?.id; + const separatorChanged = prevSignatureSeparatorRef.current !== signatureSeparatorEnabled; + prevSignatureIdentityIdRef.current = signatureIdentity?.id; + prevSignatureSeparatorRef.current = signatureSeparatorEnabled; + if (!editor) return; + if (!identityChanged && !separatorChanged) return; + if (plainTextMode) return; + if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return; + if (signaturePosition !== 'above_quote') return; + + const currentHtml = editor.getHTML(); + const doc = new DOMParser().parseFromString(currentHtml, 'text/html'); + const startEl = doc.querySelector('[data-signature-block="separator"], [data-signature-block="start"]'); + if (!startEl) return; + const endEl = doc.querySelector('[data-signature-block="end"]'); + + const newSignature = buildEmbeddedSignatureHtml(signatureIdentity, { + embed: true, + separator: signatureSeparatorEnabled, + }); + if (!newSignature) return; + + // Build a temporary container holding the replacement nodes so we can + // splice them in without re-serializing/parsing twice. + const replacementHost = doc.createElement('div'); + replacementHost.innerHTML = newSignature; + const replacementNodes = Array.from(replacementHost.childNodes); + + const parent = startEl.parentNode; + if (!parent) return; + + // Remove the existing signature range [startEl … endEl] inclusive, or + // from startEl to the next blockquote if no end marker is present. + const removeUntil = endEl && endEl.parentNode === parent ? endEl : null; + let cursor: ChildNode | null = startEl; + const toRemove: ChildNode[] = []; + while (cursor) { + toRemove.push(cursor); + if (cursor === removeUntil) break; + const next: ChildNode | null = cursor.nextSibling; + if (!removeUntil && next && (next as Element).tagName === 'BLOCKQUOTE') break; + cursor = next; + } + const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null; + toRemove.forEach((node) => parent.removeChild(node)); + replacementNodes.forEach((node) => parent.insertBefore(node, insertBefore)); + + const nextHtml = doc.body.innerHTML; + if (nextHtml !== currentHtml) { + editor.commands.setContent(nextHtml, { emitUpdate: true }); + } + }, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]); + useEffect(() => { if (!autoSelectReplyIdentity) return; if (selectedIdentityId || initialData?.selectedIdentityId) return; @@ -1038,11 +1126,12 @@ export function EmailComposer({ // Build HTML signature block (used only in rich text mode) const buildSignatureHtml = (): string => { if (signatureAlreadyInBody) return ''; + const sep = signatureSeparatorEnabled ? `

--
` : `

`; if (signatureIdentity?.htmlSignature) { - return `

--
${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`; + return `${sep}${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`; } if (signatureIdentity?.textSignature) { - return `

--
${signatureIdentity.textSignature.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}`; + return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}`; } return ''; }; @@ -1053,9 +1142,10 @@ export function EmailComposer({ : null; // In plain text mode, send text/plain only (no HTML body) + const signatureOpts = { separator: signatureSeparatorEnabled }; const finalBody = plainTextMode - ? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity)) - : (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity)); + ? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts)) + : (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity, signatureOpts)); const rewritten = plainTextMode ? null : rewriteInlineImages(body); const finalHtmlBody = plainTextMode @@ -1628,6 +1718,7 @@ export function EmailComposer({ onImageUpload={handleImageUpload} placeholder={t('body_placeholder')} hasError={validationErrors.body} + onEditorReady={(ed) => { editorRef.current = ed; }} /> )} @@ -1638,13 +1729,13 @@ export function EmailComposer({ : plainTextMode ? ( getPlainTextSignature(signatureIdentity) ? (
- {'-- \n'}{getPlainTextSignature(signatureIdentity)} + {signatureSeparatorEnabled ? '-- \n' : ''}{getPlainTextSignature(signatureIdentity)}
) : null ) : composerSignatureHtml ? (
--
${composerSignatureHtml}` }} + dangerouslySetInnerHTML={{ __html: `${signatureSeparatorEnabled ? '
--
' : ''}${composerSignatureHtml}` }} /> ) : null} diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index 88975a65..0588d107 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -1,8 +1,10 @@ "use client"; import React, { useEffect, useCallback, useState, useRef } from "react"; -import { useEditor, EditorContent } from "@tiptap/react"; +import { useEditor, EditorContent, type Editor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; +import Paragraph from "@tiptap/extension-paragraph"; +import Heading from "@tiptap/extension-heading"; import Underline from "@tiptap/extension-underline"; import Link from "@tiptap/extension-link"; import TextAlign from "@tiptap/extension-text-align"; @@ -44,6 +46,51 @@ export interface InlineImageUpload { cid?: string; } +// Pasted email content (signatures, replies, quoted text) commonly carries +// inline styles on block elements. StarterKit's default Paragraph/Heading +// drop unknown attributes; extend them to round-trip `style` and `class` so +// signature formatting survives the editor. +const styledBlockAttributes = { + style: { + default: null as string | null, + parseHTML: (el: HTMLElement) => el.getAttribute("style"), + renderHTML: (attrs: Record) => + attrs.style ? { style: attrs.style } : {}, + }, + class: { + default: null as string | null, + parseHTML: (el: HTMLElement) => el.getAttribute("class"), + renderHTML: (attrs: Record) => + attrs.class ? { class: attrs.class } : {}, + }, + "data-signature-block": { + default: null as string | null, + parseHTML: (el: HTMLElement) => el.getAttribute("data-signature-block"), + renderHTML: (attrs: Record) => + attrs["data-signature-block"] + ? { "data-signature-block": attrs["data-signature-block"] } + : {}, + }, +}; + +const StyledParagraph = Paragraph.extend({ + addAttributes() { + return { + ...this.parent?.(), + ...styledBlockAttributes, + }; + }, +}); + +const StyledHeading = Heading.extend({ + addAttributes() { + return { + ...this.parent?.(), + ...styledBlockAttributes, + }; + }, +}); + interface RichTextEditorProps { content: string; onChange: (html: string) => void; @@ -51,6 +98,7 @@ interface RichTextEditorProps { placeholder?: string; className?: string; hasError?: boolean; + onEditorReady?: (editor: Editor) => void; } function ToolbarButton({ @@ -131,17 +179,23 @@ export function RichTextEditor({ placeholder, className, hasError, + onEditorReady, }: RichTextEditorProps) { const onImageUploadRef = React.useRef(onImageUpload); onImageUploadRef.current = onImageUpload; + const onEditorReadyRef = React.useRef(onEditorReady); + onEditorReadyRef.current = onEditorReady; const editor = useEditor({ extensions: [ StarterKit.configure({ - heading: { levels: [1, 2] }, + heading: false, + paragraph: false, link: false, underline: false, }), + StyledParagraph, + StyledHeading.configure({ levels: [1, 2] }), Underline, Link.configure({ openOnClick: false, @@ -239,6 +293,12 @@ export function RichTextEditor({ } }, [content, editor]); + // Expose the editor instance once it's ready so parents can target + // specific nodes (e.g. swap the embedded signature on identity change). + useEffect(() => { + if (editor) onEditorReadyRef.current?.(editor); + }, [editor]); + const addLink = useCallback(() => { if (!editor) return; const previousUrl = editor.getAttributes("link").href; diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx index 68e64245..9223d78b 100644 --- a/components/settings/composing-settings.tsx +++ b/components/settings/composing-settings.tsx @@ -28,6 +28,7 @@ export function ComposingSettings() { attachmentReminderKeywords, subAddressDelimiter, signaturePosition, + signatureSeparatorEnabled, updateSetting, } = useSettingsStore(); @@ -62,6 +63,13 @@ export function ComposingSettings() { /> + + updateSetting('signatureSeparatorEnabled', checked)} + /> + + ()( plainTextMode: state.plainTextMode, subAddressDelimiter: state.subAddressDelimiter, signaturePosition: state.signaturePosition, + signatureSeparatorEnabled: state.signatureSeparatorEnabled, sessionTimeout: state.sessionTimeout, emailNotificationsEnabled: state.emailNotificationsEnabled, emailNotificationSound: state.emailNotificationSound, From ce2731cd9d50a198d0f20ff6fba2edb5e10ddaa3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 12 May 2026 16:04:46 +0200 Subject: [PATCH 038/133] fix: update types for cursor and toRemove --- components/email/email-composer.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index cb1aa8d3..5d9fa94e 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -404,12 +404,12 @@ export function EmailComposer({ // Remove the existing signature range [startEl … endEl] inclusive, or // from startEl to the next blockquote if no end marker is present. const removeUntil = endEl && endEl.parentNode === parent ? endEl : null; - let cursor: ChildNode | null = startEl; - const toRemove: ChildNode[] = []; + const toRemove: Node[] = []; + let cursor: Node | null = startEl; while (cursor) { toRemove.push(cursor); if (cursor === removeUntil) break; - const next: ChildNode | null = cursor.nextSibling; + const next: Node | null = cursor.nextSibling; if (!removeUntil && next && (next as Element).tagName === 'BLOCKQUOTE') break; cursor = next; } From c99934a92c48575c90176fbed2ed22e82a9ef1da Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 12 May 2026 16:06:14 +0200 Subject: [PATCH 039/133] fix: update version to 1.6.4 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index f5d2a585..6463e95e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.3 \ No newline at end of file +1.6.4 \ No newline at end of file From 8b0e2052cf8aa88c308ea5a6093f38f5a55a85d6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 12 May 2026 16:10:29 +0200 Subject: [PATCH 040/133] fix: honor NEXT_PUBLIC_BASE_PATH in admin sidebar nav links #271 --- app/admin/layout.tsx | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 693f0289..a9b759fc 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -31,7 +31,7 @@ import { useThemeStore } from '@/stores/theme-store'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { useUpdateStore, selectHasUpdate } from '@/stores/update-store'; -import { apiFetch } from '@/lib/browser-navigation'; +import { apiFetch, getPathPrefix } from '@/lib/browser-navigation'; // Single-page tab navigation: clicks update a Zustand store. The URL stays // at /admin so React doesn't fire a route transition on every tab switch - @@ -177,6 +177,12 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) return <>{children}; } + // /admin lives outside the [locale] tree, so links back to the webmail + // apps are bare tags (hard navigation). Next.js only auto-applies + // basePath to /router APIs — for these we prepend it manually so + // NEXT_PUBLIC_BASE_PATH=/webmail deployments don't redirect to "/". + const prefix = getPathPrefix(); + const navContent = ( <> From 3f444a8912ee71a4005b7eebdd2ca90f7ea7d0ba Mon Sep 17 00:00:00 2001 From: Lucas Gaitzsch Date: Tue, 12 May 2026 19:56:44 +0200 Subject: [PATCH 041/133] Feature/protocol handlers * Added account selection for protocol links when multiple connected accounts are available, including mailto: links * Added support for handling mailto: links in an already-open PWA/session instead of always opening a new tab * Added webcal: protocol handling for calendar links * Added account selection for webcal: links when multiple calendar-capable accounts are connected * Added an import-or-subscribe choice for detected webcal calendars * Added protocol handler settings for registering mail and calendar handlers and choosing the open mode * Added service worker/session coordination for passing protocol requests between browser/PWA contexts * Added tests and translations for the new protocol handler flows --- app/[locale]/calendar/page.tsx | 157 +++++++- app/[locale]/layout.tsx | 5 +- app/[locale]/page.tsx | 103 ++++- app/[locale]/settings/page.tsx | 8 + app/manifest.ts | 22 +- app/protocol/mailto/page.tsx | 8 + app/protocol/webcal/page.tsx | 8 + components/calendar/ical-import-modal.tsx | 7 +- .../calendar/ical-subscription-modal.tsx | 8 +- .../protocol/mailto-protocol-client.tsx | 107 ++++++ .../protocol/protocol-account-picker.tsx | 161 ++++++++ .../protocol-launch-handler-provider.tsx | 133 +++++++ .../protocol/webcal-protocol-client.tsx | 73 ++++ components/settings/composing-settings.tsx | 37 +- .../settings/protocol-handler-settings.tsx | 108 ++++++ lib/__tests__/email-composer-utils.test.ts | 26 ++ lib/__tests__/protocol-handlers.test.ts | 170 +++++++++ lib/email-composer-utils.ts | 23 ++ lib/protocol-handlers/mailto.ts | 115 ++++++ lib/protocol-handlers/session.ts | 357 ++++++++++++++++++ lib/protocol-handlers/webcal.ts | 48 +++ locales/cs/common.json | 44 +++ locales/de/common.json | 44 +++ locales/en/common.json | 44 +++ locales/es/common.json | 44 +++ locales/fr/common.json | 44 +++ locales/it/common.json | 44 +++ locales/ja/common.json | 44 +++ locales/ko/common.json | 44 +++ locales/lv/common.json | 44 +++ locales/nl/common.json | 44 +++ locales/pl/common.json | 44 +++ locales/pt/common.json | 44 +++ locales/ru/common.json | 44 +++ locales/tr/common.json | 44 +++ locales/uk/common.json | 44 +++ locales/zh/common.json | 44 +++ proxy.ts | 6 +- public/sw.js | 157 ++++++++ stores/settings-store.ts | 18 +- 40 files changed, 2514 insertions(+), 55 deletions(-) create mode 100644 app/protocol/mailto/page.tsx create mode 100644 app/protocol/webcal/page.tsx create mode 100644 components/protocol/mailto-protocol-client.tsx create mode 100644 components/protocol/protocol-account-picker.tsx create mode 100644 components/protocol/protocol-launch-handler-provider.tsx create mode 100644 components/protocol/webcal-protocol-client.tsx create mode 100644 components/settings/protocol-handler-settings.tsx create mode 100644 lib/__tests__/email-composer-utils.test.ts create mode 100644 lib/__tests__/protocol-handlers.test.ts create mode 100644 lib/email-composer-utils.ts create mode 100644 lib/protocol-handlers/mailto.ts create mode 100644 lib/protocol-handlers/session.ts create mode 100644 lib/protocol-handlers/webcal.ts diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index c64b6424..0d158197 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -15,6 +15,7 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useIdentityStore } from "@/stores/identity-store"; +import { useAccountStore } from "@/stores/account-store"; import { toast } from "@/stores/toast-store"; import { useIsMobile } from "@/hooks/use-media-query"; import { Button } from "@/components/ui/button"; @@ -37,6 +38,7 @@ import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { downloadEventICS } from "@/lib/calendar-ics-export"; import { ICalImportModal } from "@/components/calendar/ical-import-modal"; import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal"; +import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker"; import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog"; import { NavigationRail } from "@/components/layout/navigation-rail"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; @@ -56,6 +58,8 @@ import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal import { getUserParticipantId } from "@/lib/calendar-participants"; import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { debug } from "@/lib/debug"; +import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session"; +import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal"; type PendingScopeAction = | { type: "edit"; event: CalendarEvent; updates: Partial; sendScheduling?: boolean } @@ -68,9 +72,10 @@ function isRecurringEvent(event: CalendarEvent): boolean { export default function CalendarPage() { const router = useRouter(); const t = useTranslations("calendar"); + const tWebcalAction = useTranslations("calendar.webcal_action"); const isMobile = useIsMobile(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); - const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); + const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore(); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const { quota, isPushConnected } = useEmailStore(); const { @@ -96,6 +101,10 @@ export default function CalendarPage() { const [showEventModal, setShowEventModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); + const [pendingSubscription, setPendingSubscription] = useState<{ url: string; name: string } | null>(null); + const [showWebcalActionChoice, setShowWebcalActionChoice] = useState(false); + const [pendingWebcalAccountChoice, setPendingWebcalAccountChoice] = useState(null); + const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false); const [editingSubscription, setEditingSubscription] = useState(null); const [sharingCalendarId, setSharingCalendarId] = useState(null); const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState(undefined); @@ -156,10 +165,10 @@ export default function CalendarPage() { if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } redirectToLogin(); - } else if (client && !supportsCalendar) { + } else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) { router.push("/"); } - }, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, router]); + }, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]); useEffect(() => { if (error) { @@ -167,6 +176,84 @@ export default function CalendarPage() { } }, [error]); + const getWebcalProtocolAccounts = useCallback(() => { + const connectedClients = useAuthStore.getState().getAllConnectedClients(); + return useAccountStore.getState().accounts.filter((account) => { + if (!account.isConnected) return false; + return connectedClients.get(account.id)?.supportsCalendars() === true; + }); + }, []); + + const openWebcalForAccount = useCallback(async (pending: ParsedWebcal, accountId: string) => { + setIsProtocolAccountSwitching(true); + try { + if (useAuthStore.getState().activeAccountId !== accountId) { + await switchAccount(accountId); + } + setPendingWebcalAccountChoice(null); + setPendingSubscription({ + url: pending.subscriptionUrl, + name: pending.suggestedName, + }); + setShowWebcalActionChoice(true); + } finally { + setIsProtocolAccountSwitching(false); + } + }, [switchAccount]); + + const handleWebcalProtocolRequest = useCallback((pending: ParsedWebcal) => { + const protocolAccounts = getWebcalProtocolAccounts(); + if (protocolAccounts.length > 1) { + setPendingWebcalAccountChoice(pending); + return; + } + + if (protocolAccounts.length === 0 && !supportsCalendar) { + return; + } + + const accountId = protocolAccounts[0]?.id ?? activeAccountId; + if (accountId) { + void openWebcalForAccount(pending, accountId); + return; + } + + setPendingSubscription({ + url: pending.subscriptionUrl, + name: pending.suggestedName, + }); + setShowWebcalActionChoice(true); + }, [activeAccountId, getWebcalProtocolAccounts, openWebcalForAccount, supportsCalendar]); + + const closeWebcalActionChoice = useCallback(() => { + setShowWebcalActionChoice(false); + setPendingSubscription(null); + }, []); + + const handleImportWebcal = useCallback(() => { + setShowWebcalActionChoice(false); + setShowImportModal(true); + }, []); + + const handleSubscribeWebcal = useCallback(() => { + setShowWebcalActionChoice(false); + setShowSubscriptionModal(true); + }, []); + + useEffect(() => { + if (!isAuthenticated || !client) return; + + const openPendingWebcal = () => { + const pending = consumePendingWebcal(); + if (!pending) return; + + handleWebcalProtocolRequest(pending); + }; + + openPendingWebcal(); + return subscribeToPendingWebcal(openPendingWebcal); + }, [isAuthenticated, client, handleWebcalProtocolRequest]); + useEffect(() => { if (client && !hasFetched.current) { hasFetched.current = true; @@ -955,7 +1042,54 @@ export default function CalendarPage() { }); }, [events, selectedCalendarIds, visibleEvents]); - if (!isAuthenticated || !supportsCalendar) return null; + const renderWebcalAccountPicker = () => pendingWebcalAccountChoice ? ( + void openWebcalForAccount(pendingWebcalAccountChoice, accountId)} + onCancel={() => setPendingWebcalAccountChoice(null)} + /> + ) : null; + + const renderWebcalActionChoice = () => showWebcalActionChoice && pendingSubscription ? ( +
+ + ) : null; + + if (!isAuthenticated) return null; + if (!supportsCalendar) return renderWebcalAccountPicker(); const renderView = () => { if (isLoading && calendars.length === 0) { @@ -1378,14 +1512,23 @@ export default function CalendarPage() { setShowImportModal(false)} + initialUrl={pendingSubscription?.url} + onClose={() => { + setShowImportModal(false); + setPendingSubscription(null); + }} /> )} {showSubscriptionModal && client && ( setShowSubscriptionModal(false)} + initialUrl={pendingSubscription?.url} + initialName={pendingSubscription?.name} + onClose={() => { + setShowSubscriptionModal(false); + setPendingSubscription(null); + }} /> )} @@ -1402,6 +1545,8 @@ export default function CalendarPage() { })()} + {renderWebcalAccountPicker()} + {renderWebcalActionChoice()} - {children} + + {children} + diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 072c7145..5c73a733 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -8,6 +8,7 @@ import { EmailList } from "@/components/email/email-list"; import { EmailViewer } from "@/components/email/email-viewer"; import { EmailComposer } from "@/components/email/email-composer"; import type { ComposerDraftData } from "@/components/email/email-composer"; +import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker"; import { ThreadConversationView } from "@/components/email/thread-conversation-view"; import { MobileHeader } from "@/components/layout/mobile-header"; import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types"; @@ -60,6 +61,9 @@ import { Button } from "@/components/ui/button"; import { useConfig } from "@/hooks/use-config"; import { usePluginStore } from "@/stores/plugin-store"; import { useThemeStore } from "@/stores/theme-store"; +import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session"; +import type { ParsedMailto } from "@/lib/protocol-handlers/mailto"; +import { plainTextToComposerBody } from "@/lib/email-composer-utils"; import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks"; import { emailToReadView } from "@/lib/plugin-projection"; @@ -74,6 +78,7 @@ export default function Home() { const [composerDraftText, setComposerDraftText] = useState(""); const [pendingDraft, setPendingDraft] = useState(null); const [composerSessionId, setComposerSessionId] = useState(0); + const suppressComposerStateSaveSessionRef = useRef(null); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); @@ -89,8 +94,10 @@ export default function Home() { const [isLoadingConversation, setIsLoadingConversation] = useState(false); const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState(null); const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null); + const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState(null); + const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false); const markAsReadTimeoutRef = useRef(null); - const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); + const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); const { identities } = useIdentityStore(); useIdentitySync(); const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); @@ -308,6 +315,13 @@ export default function Home() { [], ); + const getMailtoProtocolAccounts = useCallback(() => { + const connectedClients = useAuthStore.getState().getAllConnectedClients(); + return useAccountStore.getState().accounts.filter((account) => + account.isConnected && connectedClients.has(account.id) + ); + }, []); + // Browser back / forward integration. The restore handler reads the // latest values from a ref so we don't have to recreate the callback on // every render (and so the popstate listener is never stale). @@ -651,6 +665,74 @@ export default function Home() { } }, [initialCheckDone, isAuthenticated, authLoading]); + const openMailtoDraft = useCallback((pending: ParsedMailto) => { + const body = useSettingsStore.getState().plainTextMode + ? pending.body + : plainTextToComposerBody(pending.body); + + if (showComposer) { + suppressComposerStateSaveSessionRef.current = composerSessionId; + } + setComposerSessionId((id) => id + 1); + setPendingDraft({ + to: pending.to.join(", "), + cc: pending.cc.join(", "), + bcc: pending.bcc.join(", "), + subject: pending.subject, + body, + showCc: pending.cc.length > 0, + showBcc: pending.bcc.length > 0, + selectedIdentityId: null, + subAddressTag: "", + mode: "compose", + draftId: null, + }); + setComposerMode("compose"); + setShowComposer(true); + if (isMobile) setActiveView("viewer"); + }, [composerSessionId, isMobile, setActiveView, showComposer]); + + const openMailtoForAccount = useCallback(async (pending: ParsedMailto, accountId: string) => { + setIsProtocolAccountSwitching(true); + try { + if (useAuthStore.getState().activeAccountId !== accountId) { + await switchAccount(accountId); + } + setPendingMailtoAccountChoice(null); + openMailtoDraft(pending); + } finally { + setIsProtocolAccountSwitching(false); + } + }, [openMailtoDraft, switchAccount]); + + const handleMailtoProtocolRequest = useCallback((pending: ParsedMailto) => { + const protocolAccounts = getMailtoProtocolAccounts(); + if (protocolAccounts.length > 1) { + setPendingMailtoAccountChoice(pending); + return; + } + + const accountId = protocolAccounts[0]?.id ?? activeAccountId; + if (accountId) { + void openMailtoForAccount(pending, accountId); + return; + } + + openMailtoDraft(pending); + }, [activeAccountId, getMailtoProtocolAccounts, openMailtoDraft, openMailtoForAccount]); + + useEffect(() => { + if (!isAuthenticated || !client) return; + + const openPendingMailto = () => { + const pending = consumePendingMailto(); + if (pending) handleMailtoProtocolRequest(pending); + }; + + openPendingMailto(); + return subscribeToPendingMailto(openPendingMailto); + }, [isAuthenticated, client, handleMailtoProtocolRequest]); + // Fallback fetch for paths that didn't go through login()'s prefetch // (notably checkAuth on page refresh). The prefetch in auth-store/login() // populates mailboxes before this effect first runs, so on the post-login @@ -2373,7 +2455,13 @@ export default function Home() { } : undefined)} initialDraftText={composerDraftText} initialData={pendingDraft} - onSaveState={(data) => setPendingDraft(data)} + onSaveState={(data) => { + if (suppressComposerStateSaveSessionRef.current === composerSessionId) { + suppressComposerStateSaveSessionRef.current = null; + return; + } + setPendingDraft(data); + }} onSend={async (data) => { await handleEmailSend(data); setPendingDraft(null); @@ -2528,6 +2616,17 @@ export default function Home() {
+ {pendingMailtoAccountChoice && ( + void openMailtoForAccount(pendingMailtoAccountChoice, accountId)} + onCancel={() => setPendingMailtoAccountChoice(null)} + /> + )} diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 9d929361..da3de6fa 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -26,6 +26,7 @@ import { Bell, Puzzle, LayoutGrid, + Link as LinkIcon, BookOpen, PenLine, EyeOff, @@ -63,6 +64,7 @@ import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings import { NotificationSettings } from '@/components/settings/notification-settings'; import { ThemesSettings } from '@/components/settings/themes-settings'; import { PluginsSettings } from '@/components/settings/plugins-settings'; +import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings'; import { useAuthStore, redirectToLogin } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; import { usePluginStore } from '@/stores/plugin-store'; @@ -98,6 +100,7 @@ type Tab = | 'calendar' | 'contacts' | 'files' + | 'protocol_handlers' | 'sidebar_apps' | 'about_data' | 'themes' @@ -133,6 +136,7 @@ const tabIcons: Record = { calendar: Calendar, contacts: BookUser, files: HardDrive, + protocol_handlers: LinkIcon, sidebar_apps: PanelLeftClose, about_data: Info, themes: Palette, @@ -211,6 +215,7 @@ const tabSearchPaths: Record = { calendar: ['calendar.settings', 'calendar.management'], contacts: ['settings.contacts', 'contacts'], files: ['settings.files'], + protocol_handlers: ['protocol_handlers'], sidebar_apps: ['settings.sidebar_apps', 'sidebar_apps'], about_data: ['settings.advanced'], themes: [], @@ -240,6 +245,7 @@ const tabKeywords: Record = { calendar: 'event schedule appointment meeting timezone', contacts: 'address book contact', files: 'attachments cloud drive storage upload', + protocol_handlers: 'mailto webcal links default app protocol handler', sidebar_apps: 'apps webview iframe', about_data: 'export import storage quota privacy backup', themes: 'custom theme css skin appearance', @@ -560,6 +566,7 @@ export default function SettingsPage() { { id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' }, { id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' }, { id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' }, + { id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' }, // Appearance { id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' }, @@ -666,6 +673,7 @@ export default function SettingsPage() { {effectiveActiveTab === 'calendar' && <>
} {effectiveActiveTab === 'contacts' && <>
} {effectiveActiveTab === 'files' && } + {effectiveActiveTab === 'protocol_handlers' && } {effectiveActiveTab === 'sidebar_apps' && } {effectiveActiveTab === 'about_data' && } {effectiveActiveTab === 'themes' && } diff --git a/app/manifest.ts b/app/manifest.ts index 6e5da59e..6d2d065c 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -2,13 +2,26 @@ import type { MetadataRoute } from "next"; export const dynamic = "force-dynamic"; +type WebAppProtocolHandler = { + protocol: string; + url: string; +}; + +type ExtendedManifest = MetadataRoute.Manifest & { + protocol_handlers?: WebAppProtocolHandler[]; + launch_handler?: { + client_mode?: "navigate-existing" | "auto" | "focus-existing" | "navigate-new" + | Array<"navigate-existing" | "auto" | "focus-existing" | "navigate-new">; + }; +}; + // Manifest paths must include the deployment subpath - browsers resolve them // against the document origin, not the manifest's location, and Next.js does // not auto-prefix string literals inside MetadataRoute payloads. const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, ""); const withBase = (p: string) => `${BASE_PATH}${p}`; -export default function manifest(): MetadataRoute.Manifest { +export default function manifest(): ExtendedManifest { const appName = process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || @@ -57,5 +70,12 @@ export default function manifest(): MetadataRoute.Manifest { { src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" }, { src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" }, ], + protocol_handlers: [ + { protocol: "mailto", url: withBase("/protocol/mailto?url=%s") }, + { protocol: "webcal", url: withBase("/protocol/webcal?url=%s") }, + ], + launch_handler: { + client_mode: ["focus-existing", "navigate-new"], + }, }; } diff --git a/app/protocol/mailto/page.tsx b/app/protocol/mailto/page.tsx new file mode 100644 index 00000000..f2797d69 --- /dev/null +++ b/app/protocol/mailto/page.tsx @@ -0,0 +1,8 @@ +import { getTranslations } from "next-intl/server"; +import { MailtoProtocolClient } from "@/components/protocol/mailto-protocol-client"; + +export default async function MailtoProtocolPage() { + const t = await getTranslations("protocol_handlers"); + + return ; +} diff --git a/app/protocol/webcal/page.tsx b/app/protocol/webcal/page.tsx new file mode 100644 index 00000000..47cdbfa8 --- /dev/null +++ b/app/protocol/webcal/page.tsx @@ -0,0 +1,8 @@ +import { getTranslations } from "next-intl/server"; +import { WebcalProtocolClient } from "@/components/protocol/webcal-protocol-client"; + +export default async function WebcalProtocolPage() { + const t = await getTranslations("protocol_handlers"); + + return ; +} diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index 3324fb7a..0c987b96 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -17,6 +17,7 @@ interface ICalImportModalProps { calendars: Calendar[]; client: IJMAPClient; onClose: () => void; + initialUrl?: string; } const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB @@ -25,7 +26,7 @@ const ACCEPTED_EXTENSIONS = [".ics", ".ical"]; type ImportStep = "select" | "preview" | "importing"; type ImportMode = "file" | "url"; -export function ICalImportModal({ calendars, client, onClose }: ICalImportModalProps) { +export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICalImportModalProps) { const t = useTranslations("calendar.import"); const tCal = useTranslations("calendar"); const tCommon = useTranslations("common"); @@ -43,8 +44,8 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP const [isParsing, setIsParsing] = useState(false); const [isDragging, setIsDragging] = useState(false); const [error, setError] = useState(null); - const [importMode, setImportMode] = useState("file"); - const [urlInput, setUrlInput] = useState(""); + const [importMode, setImportMode] = useState(initialUrl ? "url" : "file"); + const [urlInput, setUrlInput] = useState(initialUrl || ""); const [isFetchingUrl, setIsFetchingUrl] = useState(false); const fileInputRef = useRef(null); const modalRef = useRef(null); diff --git a/components/calendar/ical-subscription-modal.tsx b/components/calendar/ical-subscription-modal.tsx index ddf72791..c0fe2411 100644 --- a/components/calendar/ical-subscription-modal.tsx +++ b/components/calendar/ical-subscription-modal.tsx @@ -13,9 +13,11 @@ interface ICalSubscriptionModalProps { client: IJMAPClient; onClose: () => void; editSubscription?: ICalSubscription; + initialUrl?: string; + initialName?: string; } -export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) { +export function ICalSubscriptionModal({ client, onClose, editSubscription, initialUrl, initialName }: ICalSubscriptionModalProps) { const t = useTranslations("calendar.subscription"); const tCommon = useTranslations("common"); const addICalSubscription = useCalendarStore((s) => s.addICalSubscription); @@ -23,8 +25,8 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICa const isEdit = !!editSubscription; - const [url, setUrl] = useState(editSubscription?.url || ""); - const [name, setName] = useState(editSubscription?.name || ""); + const [url, setUrl] = useState(editSubscription?.url || initialUrl || ""); + const [name, setName] = useState(editSubscription?.name || initialName || ""); const [color, setColor] = useState(editSubscription?.color || "#3b82f6"); const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60); const [isSubmitting, setIsSubmitting] = useState(false); diff --git a/components/protocol/mailto-protocol-client.tsx b/components/protocol/mailto-protocol-client.tsx new file mode 100644 index 00000000..e6b8e2b1 --- /dev/null +++ b/components/protocol/mailto-protocol-client.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect } from "react"; +import { parseMailto } from "@/lib/protocol-handlers/mailto"; +import { requestOpenMailtoInExistingClient, savePendingMailto } from "@/lib/protocol-handlers/session"; +import { useSettingsStore } from "@/stores/settings-store"; + +type StandaloneNavigator = Navigator & { standalone?: boolean }; + +function getProtocolPathPrefix(): string { + const marker = "/protocol/mailto"; + const index = window.location.pathname.indexOf(marker); + return index > 0 ? window.location.pathname.slice(0, index) : ""; +} + +function returnToSourcePage() { + window.close(); + + window.setTimeout(() => { + if (window.history.length > 1) { + window.history.back(); + } + }, 150); +} + +function openFallbackAppTab(raw: string): boolean { + const url = `${getProtocolPathPrefix()}/protocol/mailto?url=${encodeURIComponent(raw)}&fallback=1`; + const opened = window.open(url, "_blank"); + if (!opened) return false; + opened.opener = null; + return true; +} + +function shouldOpenFallbackAppTab(): boolean { + const standalone = window.matchMedia?.("(display-mode: standalone)").matches + || (navigator as StandaloneNavigator).standalone === true; + return !standalone && window.history.length > 1; +} + +async function focusExistingClient() { + if (!("serviceWorker" in navigator)) return; + + try { + const registration = await navigator.serviceWorker.ready; + const worker = navigator.serviceWorker.controller ?? registration.active; + worker?.postMessage({ type: "focus-existing-mailto-client" }); + } catch { + // Focusing is a progressive enhancement; the composer handoff still works. + } +} + +interface MailtoProtocolClientProps { + openingText: string; +} + +export function MailtoProtocolClient({ openingText }: MailtoProtocolClientProps) { + useEffect(() => { + let cancelled = false; + + async function handleMailto() { + const params = new URLSearchParams(window.location.search); + const raw = params.get("url"); + const isFallbackAppTab = params.get("fallback") === "1"; + const openMode = useSettingsStore.getState().protocolOpenMode; + const parsed = raw ? parseMailto(raw) : null; + + if (parsed) { + if (!isFallbackAppTab && openMode === "new-tab") { + if (raw && shouldOpenFallbackAppTab() && openFallbackAppTab(raw)) { + returnToSourcePage(); + return; + } + } else if (!isFallbackAppTab) { + const delivered = await requestOpenMailtoInExistingClient(parsed); + if (cancelled) return; + + if (delivered) { + void focusExistingClient(); + returnToSourcePage(); + return; + } + + if (raw && shouldOpenFallbackAppTab() && openFallbackAppTab(raw)) { + returnToSourcePage(); + return; + } + } + + savePendingMailto(parsed); + } + + window.location.replace(`${getProtocolPathPrefix()}/`); + } + + void handleMailto(); + + return () => { + cancelled = true; + }; + }, []); + + return ( +
+

{openingText}

+
+ ); +} diff --git a/components/protocol/protocol-account-picker.tsx b/components/protocol/protocol-account-picker.tsx new file mode 100644 index 00000000..76fdcf8f --- /dev/null +++ b/components/protocol/protocol-account-picker.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { Loader2, X } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { getInitials } from "@/lib/account-utils"; +import type { ParsedMailto } from "@/lib/protocol-handlers/mailto"; +import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal"; +import type { AccountEntry } from "@/stores/account-store"; +import { cn } from "@/lib/utils"; + +type ProtocolAccountPickerProps = { + accounts: AccountEntry[]; + activeAccountId: string | null; + isSwitching?: boolean; + onSelect: (accountId: string) => void; + onCancel: () => void; +} & ( + | { kind: "mailto"; operation?: ParsedMailto } + | { kind: "webcal"; operation?: ParsedWebcal } +); + +function getHost(value: string): string { + try { + return new URL(value).hostname; + } catch { + return value; + } +} + +export function ProtocolAccountPicker({ + kind, + accounts, + activeAccountId, + isSwitching = false, + onSelect, + onCancel, + operation, +}: ProtocolAccountPickerProps) { + const t = useTranslations("protocol_handlers"); + const tCommon = useTranslations("common"); + const details = operation + ? kind === "mailto" + ? [ + { label: t("detail_to"), value: operation.to.join(", ") || "-" }, + { label: t("detail_subject"), value: operation.subject || t("detail_no_subject") }, + ] + : [ + { label: t("detail_calendar"), value: operation.suggestedName }, + { label: t("detail_source"), value: getHost(operation.subscriptionUrl) }, + ] + : []; + + return ( +
+ + ); +} diff --git a/components/protocol/protocol-launch-handler-provider.tsx b/components/protocol/protocol-launch-handler-provider.tsx new file mode 100644 index 00000000..7e8651e2 --- /dev/null +++ b/components/protocol/protocol-launch-handler-provider.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useEffect } from "react"; +import type { ReactNode } from "react"; +import { useTranslations } from "next-intl"; +import { usePathname, useRouter } from "@/i18n/navigation"; +import { getPathPrefix } from "@/lib/browser-navigation"; +import { parseMailto } from "@/lib/protocol-handlers/mailto"; +import { parseWebcal } from "@/lib/protocol-handlers/webcal"; +import { + listenForMailtoRequests, + notifyPendingMailto, + notifyPendingWebcal, + requestOpenMailtoInExistingClient, + savePendingMailto, + savePendingWebcal, +} from "@/lib/protocol-handlers/session"; +import { useSettingsStore } from "@/stores/settings-store"; + +type LaunchParams = { targetURL?: string }; +type StandaloneNavigator = Navigator & { standalone?: boolean }; + +declare global { + interface Window { + launchQueue?: { + setConsumer: (consumer: (launchParams: LaunchParams) => void) => void; + }; + } +} + +function getProtocolLaunch(targetURL: string): + | { kind: "mailto"; raw: string } + | { kind: "webcal"; raw: string } + | null { + let url: URL; + try { + url = new URL(targetURL, window.location.origin); + } catch { + return null; + } + + if (url.origin !== window.location.origin) return null; + + const raw = url.searchParams.get("url"); + if (!raw) return null; + + if (url.pathname.includes("/protocol/mailto")) return { kind: "mailto", raw }; + if (url.pathname.includes("/protocol/webcal")) return { kind: "webcal", raw }; + return null; +} + +function isStandaloneDisplayMode() { + return window.matchMedia?.("(display-mode: standalone)").matches + || (navigator as StandaloneNavigator).standalone === true; +} + +function openProtocolInNewTab(protocol: "mailto" | "webcal", raw: string): boolean { + const url = `${getPathPrefix()}/protocol/${protocol}?url=${encodeURIComponent(raw)}&fallback=1`; + const opened = window.open(url, "_blank"); + if (!opened) return false; + opened.opener = null; + return true; +} + +interface ProtocolLaunchHandlerProviderProps { + children: ReactNode; +} + +export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandlerProviderProps) { + const t = useTranslations("protocol_handlers"); + const router = useRouter(); + const pathname = usePathname(); + + useEffect(() => { + if (pathname.startsWith("/protocol/")) return; + + return listenForMailtoRequests((pending) => { + savePendingMailto(pending); + notifyPendingMailto(); + if (pathname !== "/") router.push("/"); + }, () => ({ + path: pathname, + standalone: isStandaloneDisplayMode(), + focusNotificationTitle: t("focus_notification_title"), + focusNotificationBody: t("focus_notification_body"), + })); + }, [pathname, router, t]); + + useEffect(() => { + if (typeof window === "undefined" || !window.launchQueue) return; + + window.launchQueue.setConsumer((launchParams) => { + if (!launchParams.targetURL) return; + + const launch = getProtocolLaunch(launchParams.targetURL); + if (!launch) return; + + if (launch.kind === "mailto") { + const parsed = parseMailto(launch.raw); + if (!parsed) return; + + if (useSettingsStore.getState().protocolOpenMode === "new-tab") { + if (openProtocolInNewTab("mailto", launch.raw)) return; + savePendingMailto(parsed); + notifyPendingMailto(); + if (pathname !== "/") router.push("/"); + return; + } + + void requestOpenMailtoInExistingClient(parsed).then((delivered) => { + if (delivered) return; + savePendingMailto(parsed); + notifyPendingMailto(); + if (pathname !== "/") router.push("/"); + }); + return; + } + + const parsed = parseWebcal(launch.raw); + if (!parsed) return; + + if (useSettingsStore.getState().protocolOpenMode === "new-tab") { + if (openProtocolInNewTab("webcal", launch.raw)) return; + } + + savePendingWebcal(parsed); + notifyPendingWebcal(); + if (pathname !== "/calendar") router.push("/calendar"); + }); + }, [pathname, router]); + + return children; +} diff --git a/components/protocol/webcal-protocol-client.tsx b/components/protocol/webcal-protocol-client.tsx new file mode 100644 index 00000000..7bd697ff --- /dev/null +++ b/components/protocol/webcal-protocol-client.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect } from "react"; +import { parseWebcal } from "@/lib/protocol-handlers/webcal"; +import { savePendingWebcal } from "@/lib/protocol-handlers/session"; +import { useSettingsStore } from "@/stores/settings-store"; + +type StandaloneNavigator = Navigator & { standalone?: boolean }; + +function getProtocolPathPrefix(): string { + const marker = "/protocol/webcal"; + const index = window.location.pathname.indexOf(marker); + return index > 0 ? window.location.pathname.slice(0, index) : ""; +} + +function returnToSourcePage() { + window.close(); + + window.setTimeout(() => { + if (window.history.length > 1) { + window.history.back(); + } + }, 150); +} + +function openFallbackAppTab(raw: string): boolean { + const url = `${getProtocolPathPrefix()}/protocol/webcal?url=${encodeURIComponent(raw)}&fallback=1`; + const opened = window.open(url, "_blank"); + if (!opened) return false; + opened.opener = null; + return true; +} + +function shouldOpenFallbackAppTab(): boolean { + const standalone = window.matchMedia?.("(display-mode: standalone)").matches + || (navigator as StandaloneNavigator).standalone === true; + return !standalone && window.history.length > 1; +} + +interface WebcalProtocolClientProps { + openingText: string; +} + +export function WebcalProtocolClient({ openingText }: WebcalProtocolClientProps) { + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const raw = params.get("url"); + const isFallbackAppTab = params.get("fallback") === "1"; + + if (raw) { + const parsed = parseWebcal(raw); + if (parsed) { + if (!isFallbackAppTab + && useSettingsStore.getState().protocolOpenMode === "new-tab" + && shouldOpenFallbackAppTab() + && openFallbackAppTab(raw)) { + returnToSourcePage(); + return; + } + + savePendingWebcal(parsed); + } + } + + window.location.replace(`${getProtocolPathPrefix()}/calendar`); + }, []); + + return ( +
+

{openingText}

+
+ ); +} diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx index 9223d78b..93e17c98 100644 --- a/components/settings/composing-settings.tsx +++ b/components/settings/composing-settings.tsx @@ -1,12 +1,10 @@ "use client"; -import { useState, useCallback } from 'react'; +import { useState } from 'react'; import { useTranslations } from 'next-intl'; -import { useConfig } from '@/hooks/use-config'; import { useSettingsStore } from '@/stores/settings-store'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; -import { Mail, X } from 'lucide-react'; -import { getPathPrefix } from '@/lib/browser-navigation'; +import { X } from 'lucide-react'; import { SUPPORTED_SUB_ADDRESS_DELIMITERS, isSupportedSubAddressDelimiter, @@ -18,8 +16,6 @@ const DEFAULT_CUSTOM_DELIMITER = '~'; export function ComposingSettings() { const t = useTranslations('settings.email_behavior'); - const { appName } = useConfig(); - const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [newKeyword, setNewKeyword] = useState(''); const { @@ -32,17 +28,6 @@ export function ComposingSettings() { updateSetting, } = useSettingsStore(); - const handleSetDefaultMailProgram = useCallback(() => { - try { - if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) { - navigator.registerProtocolHandler('mailto', `${window.location.origin}${getPathPrefix()}/compose?mailto=%s`); - setDefaultMailStatus('success'); - } - } catch { - setDefaultMailStatus('error'); - } - }, []); - return ( @@ -168,24 +153,6 @@ export function ComposingSettings() {
)} - - -
- - {defaultMailStatus === 'success' && ( -

{t('default_mail_program.success')}

- )} - {defaultMailStatus === 'error' && ( -

{t('default_mail_program.error')}

- )} -
-
); } diff --git a/components/settings/protocol-handler-settings.tsx b/components/settings/protocol-handler-settings.tsx new file mode 100644 index 00000000..c82f2849 --- /dev/null +++ b/components/settings/protocol-handler-settings.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { getPathPrefix } from "@/lib/browser-navigation"; +import { useSettingsStore } from "@/stores/settings-store"; +import type { ProtocolOpenMode } from "@/stores/settings-store"; +import { toast } from "@/stores/toast-store"; +import { SettingsSection, SettingItem, Select } from "./settings-section"; + +type Protocol = "mailto" | "webcal"; + +function canRegisterProtocolHandler(): boolean { + return typeof navigator !== "undefined" + && "registerProtocolHandler" in navigator + && typeof window !== "undefined" + && window.isSecureContext; +} + +function getProtocolHandlerUrl(protocol: Protocol) { + return `${window.location.origin}${getPathPrefix()}/protocol/${protocol}?url=%s`; +} + +function registerProtocolHandler(protocol: Protocol) { + navigator.registerProtocolHandler( + protocol, + getProtocolHandlerUrl(protocol), + ); +} + +interface ProtocolHandlerSettingsProps { + supportsCalendar: boolean; +} + +export function ProtocolHandlerSettings({ supportsCalendar }: ProtocolHandlerSettingsProps) { + const t = useTranslations("protocol_handlers"); + const protocolOpenMode = useSettingsStore((state) => state.protocolOpenMode); + const updateSetting = useSettingsStore((state) => state.updateSetting); + const [supported, setSupported] = useState(false); + + useEffect(() => { + setSupported(canRegisterProtocolHandler()); + }, []); + + const handleOpenModeChange = async (value: string) => { + const openMode = value as ProtocolOpenMode; + + if (openMode === "active-session" + && typeof window !== "undefined" + && "Notification" in window + && Notification.permission === "default") { + await Notification.requestPermission(); + } + + updateSetting("protocolOpenMode", openMode); + }; + + const handleRegister = (protocol: Protocol) => { + try { + registerProtocolHandler(protocol); + toast.success(protocol === "mailto" ? t("mailto_registered") : t("webcal_registered")); + } catch { + toast.error(t("registration_failed")); + } + }; + + const renderRegistrationControl = (protocol: Protocol) => { + return ( + + ); + }; + + return ( + + {!supported && ( +
+ {t("unsupported")} +
+ )} + + + {renderRegistrationControl("mailto")} + + + {supportsCalendar && ( + + {renderRegistrationControl("webcal")} + + )} + + + setConfig({ ...config, loginCompanyName: v })} /> @@ -1174,7 +1174,7 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick {label} - {value || } + {value || -}
); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 5d9fa94e..03e7593d 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -56,7 +56,7 @@ export interface ComposerDraftData { mode: 'compose' | 'reply' | 'replyAll' | 'forward'; replyTo?: EmailComposerProps['replyTo']; draftId: string | null; - /** When set, overrides the header From: — sent through the selected identity's envelope. */ + /** When set, overrides the header From: - sent through the selected identity's envelope. */ fromOverrideEmail?: string; fromOverrideName?: string; fromOverrideEnabled?: boolean; @@ -239,7 +239,7 @@ export function EmailComposer({ // When "above quote" is configured, splice signature between the user's // drafting area and the quoted content so it reads naturally as a - // closing for the reply body. Send-time append is skipped — see + // closing for the reply body. Send-time append is skipped - see // shouldEmbedSignatureAboveQuote. const plainSep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n'; const signatureBlock = shouldEmbedSignatureAboveQuote @@ -1106,7 +1106,7 @@ export function EmailComposer({ : undefined; // When the user has typed a From override, that becomes the header From // (and MIME-builder From in the S/MIME path). The identity still drives - // the SMTP envelope MAIL FROM — set explicitly so it doesn't mistakenly + // the SMTP envelope MAIL FROM - set explicitly so it doesn't mistakenly // default to the override address. const overrideActive = fromOverrideEnabled && fromOverrideEmail.trim().length > 0; const fromEmail = overrideActive ? fromOverrideEmail.trim() : identityFromEmail; @@ -1184,7 +1184,7 @@ export function EmailComposer({ // would produce a signature whose Subject differs from the visible // From, which most clients reject or flag. Refuse up front. if (overrideActive) { - throw new Error('Cannot use From override with S/MIME — disable one to send.'); + throw new Error('Cannot use From override with S/MIME - disable one to send.'); } // 2. Ensure key is unlocked for signing diff --git a/components/providers/theme-provider.tsx b/components/providers/theme-provider.tsx index 33596c35..46d70020 100644 --- a/components/providers/theme-provider.tsx +++ b/components/providers/theme-provider.tsx @@ -10,5 +10,20 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { initializeTheme(); }, [initializeTheme]); + useEffect(() => { + if (process.env.NODE_ENV === 'production') return; + + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'l') { + e.preventDefault(); + const { resolvedTheme, setTheme } = useThemeStore.getState(); + setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + return <>{children}; } \ No newline at end of file diff --git a/hooks/use-attachment-drag.ts b/hooks/use-attachment-drag.ts index ea564192..4730ba8d 100644 --- a/hooks/use-attachment-drag.ts +++ b/hooks/use-attachment-drag.ts @@ -66,7 +66,7 @@ export function useAttachmentDrag( urlRef.current = url; // Mark as owned so we revoke on unmount. Callers that hand back a // shared URL (e.g. a cached thumbnail blob URL) can return the same - // string each time — we still revoke once on unmount. + // string each time - we still revoke once on unmount. ownedRef.current = true; } return url; @@ -101,7 +101,7 @@ export function useAttachmentDrag( ); const handleDragEnd = useCallback(() => { - // Keep the blob URL around briefly — Chromium asynchronously fetches the + // Keep the blob URL around briefly - Chromium asynchronously fetches the // blob: URL after dragend fires, so revoking immediately races the OS. if (urlRef.current && ownedRef.current) { const url = urlRef.current; diff --git a/lib/demo/fixtures/contacts.ts b/lib/demo/fixtures/contacts.ts index f7f05cb6..145ab4b6 100644 --- a/lib/demo/fixtures/contacts.ts +++ b/lib/demo/fixtures/contacts.ts @@ -2,7 +2,7 @@ import type { ContactCard, AddressBook } from '@/lib/jmap/types'; // randomuser.me serves stable portrait URLs at // https://randomuser.me/api/portraits/{men|women}/{0..99}.jpg -// See https://randomuser.me/documentation#howto — we use these directly +// See https://randomuser.me/documentation#howto - we use these directly // rather than hitting the JSON API so the demo works offline. const portrait = (gender: 'men' | 'women', n: number): string => `https://randomuser.me/api/portraits/${gender}/${n}.jpg`; @@ -160,7 +160,7 @@ export function createDemoContacts(): ContactCard[] { name: { components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: 'Kowalski' }] }, emails: { e1: { address: 'anna.kowalski@example.com', contexts: { private: true }, pref: 1 } }, phones: { p1: { number: '+48-602-555-0144', features: { cell: true } } }, - notes: { n1: { note: 'Sister — lives in Kraków' } }, + notes: { n1: { note: 'Sister - lives in Kraków' } }, anniversaries: { a1: { kind: 'birth', date: { month: 11, day: 4 } } }, media: photo('women', 47), }, @@ -170,7 +170,7 @@ export function createDemoContacts(): ContactCard[] { kind: 'individual', name: { components: [{ kind: 'given', value: 'Marcus' }, { kind: 'surname', value: 'Hughes' }] }, emails: { e1: { address: 'marcus.hughes@example.com', pref: 1 } }, - notes: { n1: { note: 'College friend — book club organiser' } }, + notes: { n1: { note: 'College friend - book club organiser' } }, media: photo('men', 96), }, { diff --git a/lib/demo/fixtures/emails.ts b/lib/demo/fixtures/emails.ts index 2d5c77d2..fd5b7cfe 100644 --- a/lib/demo/fixtures/emails.ts +++ b/lib/demo/fixtures/emails.ts @@ -3,7 +3,7 @@ import { demoDate } from '../demo-utils'; const USER = { name: 'Demo User', email: 'demo@example.com' } as const; -// Helper to keep the fixtures short — auto-assigns a partId/blobId per body. +// Helper to keep the fixtures short - auto-assigns a partId/blobId per body. let bodyCounter = 0; function body(value: string, type: 'text/plain' | 'text/html' = 'text/plain') { const partId = String(++bodyCounter); @@ -53,7 +53,7 @@ export function createDemoEmails(): Email[] { messageId: '', }, - // Mom — personal message, unread + // Mom - personal message, unread { id: 'demo-email-mom', threadId: 'demo-thread-mom', @@ -65,15 +65,15 @@ export function createDemoEmails(): Email[] { to: [USER], subject: 'when are you coming home?', sentAt: demoDate(0, -4, -12), - preview: 'Hi sweetie, your father and I were just talking — we miss you. Any chance you can come down for a weekend...', + preview: 'Hi sweetie, your father and I were just talking - we miss you. Any chance you can come down for a weekend...', hasAttachment: false, ...textOnly( - "Hi sweetie,\n\nYour father and I were just talking — we miss you. Any chance you can come down for a weekend before Christmas?\n\nNo pressure if you're swamped with work. Anna said she might be in town the 22nd, would be nice to all be in one place again.\n\nThe lemon tree finally fruited! Twelve lemons. I'll save you some.\n\nLove,\nMom", + "Hi sweetie,\n\nYour father and I were just talking - we miss you. Any chance you can come down for a weekend before Christmas?\n\nNo pressure if you're swamped with work. Anna said she might be in town the 22nd, would be nice to all be in one place again.\n\nThe lemon tree finally fruited! Twelve lemons. I'll save you some.\n\nLove,\nMom", ), messageId: '<5a8c-mom@example.com>', }, - // GitHub — PR review request + // GitHub - PR review request { id: 'demo-email-gh-pr', threadId: 'demo-thread-gh-pr', @@ -89,13 +89,13 @@ export function createDemoEmails(): Email[] { preview: '@demo-user requested your review on this pull request. Replaces the fixed-window limiter with a leaky token-bucket...', hasAttachment: false, ...bodies( - '@demo-user requested your review on this pull request.\n\nReplaces the fixed-window limiter with a leaky token-bucket so we stop punishing clients at the second-boundary edge. Per-endpoint config lives in rate-limit.toml.\n\nThree files changed, +312 −47.\n\nView it on GitHub:\nhttps://github.com/acme/api-gateway/pull/1284\n\n—\nReply to this email directly, or view it on GitHub.', + '@demo-user requested your review on this pull request.\n\nReplaces the fixed-window limiter with a leaky token-bucket so we stop punishing clients at the second-boundary edge. Per-endpoint config lives in rate-limit.toml.\n\nThree files changed, +312 −47.\n\nView it on GitHub:\nhttps://github.com/acme/api-gateway/pull/1284\n\n-\nReply to this email directly, or view it on GitHub.', '
@demo-user requested your review on this pull request.
Replaces the fixed-window limiter with a leaky token-bucket so we stop punishing clients at the second-boundary edge. Per-endpoint config lives in rate-limit.toml.
Three files changed, +312 −47
View on GitHub
', ), messageId: '', }, - // Hacker Newsletter — newsletter, read + // Hacker Newsletter - newsletter, read { id: 'demo-email-2', threadId: 'demo-thread-2', @@ -105,18 +105,18 @@ export function createDemoEmails(): Email[] { receivedAt: demoDate(-1, -5), from: [{ name: 'TechDigest Weekly', email: 'newsletter@techdigest.example' }], to: [USER], - subject: 'Issue #218 — RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla', + subject: 'Issue #218 - RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla', sentAt: demoDate(-1, -5), preview: 'Your weekly roundup of the most important technology news and open source developments...', hasAttachment: false, ...bodies( - 'TechDigest #218\n\n— THE WEEK IN STANDARDS —\n\n1. RFC 9844: Per-message TLS extensions are now official. The implications for SMTP delivery reports are surprisingly large — Mike Crispin has a write-up that runs through what changes for transactional senders.\n\n2. WebAssembly 2.0 (second public draft). Tail calls are in. SIMD is in. Component model is *almost* in but punted to a separate spec, which feels like the right call.\n\n3. Mozilla quietly shipped a privacy-preserving telemetry channel to Firefox 132. No, it doesn\'t replace ad tracking. Yes, it\'s a real cryptographic system. Worth reading the post.\n\n— TOOLS —\n\n— Datasette 1.0 is out. Ten years from the first commit.\n— Fly.io published their object store, Tigris-style, written in Go.\n— Linear added an SSO migration tool that actually handles the IdP-initiated case.\n\n— ESSAYS —\n\n* "Postgres is enough" by E. Tan — a long-form rebuttal to the microservices-by-default pattern.\n* "I rewrote my home network in TypeScript so you don\'t have to" — exactly what it sounds like.\n\n— UNSUBSCRIBE —\n\nManage your subscription at techdigest.example/manage.', - '
TechDigest · Issue #218

RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla

The week in standards

1. RFC 9844: Per-message TLS extensions are now official. The implications for SMTP delivery reports are surprisingly large — Mike Crispin has a write-up that runs through what changes for transactional senders.

2. WebAssembly 2.0 (second public draft). Tail calls are in. SIMD is in. Component model is almost in but punted to a separate spec, which feels like the right call.

3. Mozilla quietly shipped a privacy-preserving telemetry channel to Firefox 132. No, it doesn\'t replace ad tracking. Yes, it\'s a real cryptographic system.

Tools

  • Datasette 1.0 is out. Ten years from the first commit.
  • Fly.io published their object store, Tigris-style, written in Go.
  • Linear added an SSO migration tool that actually handles the IdP-initiated case.

Essays

"Postgres is enough" by E. Tan — a long-form rebuttal to the microservices-by-default pattern.

"I rewrote my home network in TypeScript so you don\'t have to" — exactly what it sounds like.

Manage your subscription at techdigest.example/manage
', + 'TechDigest #218\n\n- THE WEEK IN STANDARDS -\n\n1. RFC 9844: Per-message TLS extensions are now official. The implications for SMTP delivery reports are surprisingly large - Mike Crispin has a write-up that runs through what changes for transactional senders.\n\n2. WebAssembly 2.0 (second public draft). Tail calls are in. SIMD is in. Component model is *almost* in but punted to a separate spec, which feels like the right call.\n\n3. Mozilla quietly shipped a privacy-preserving telemetry channel to Firefox 132. No, it doesn\'t replace ad tracking. Yes, it\'s a real cryptographic system. Worth reading the post.\n\n- TOOLS -\n\n- Datasette 1.0 is out. Ten years from the first commit.\n- Fly.io published their object store, Tigris-style, written in Go.\n- Linear added an SSO migration tool that actually handles the IdP-initiated case.\n\n- ESSAYS -\n\n* "Postgres is enough" by E. Tan - a long-form rebuttal to the microservices-by-default pattern.\n* "I rewrote my home network in TypeScript so you don\'t have to" - exactly what it sounds like.\n\n- UNSUBSCRIBE -\n\nManage your subscription at techdigest.example/manage.', + '
TechDigest · Issue #218

RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla

The week in standards

1. RFC 9844: Per-message TLS extensions are now official. The implications for SMTP delivery reports are surprisingly large - Mike Crispin has a write-up that runs through what changes for transactional senders.

2. WebAssembly 2.0 (second public draft). Tail calls are in. SIMD is in. Component model is almost in but punted to a separate spec, which feels like the right call.

3. Mozilla quietly shipped a privacy-preserving telemetry channel to Firefox 132. No, it doesn\'t replace ad tracking. Yes, it\'s a real cryptographic system.

Tools

  • Datasette 1.0 is out. Ten years from the first commit.
  • Fly.io published their object store, Tigris-style, written in Go.
  • Linear added an SSO migration tool that actually handles the IdP-initiated case.

Essays

"Postgres is enough" by E. Tan - a long-form rebuttal to the microservices-by-default pattern.

"I rewrote my home network in TypeScript so you don\'t have to" - exactly what it sounds like.

Manage your subscription at techdigest.example/manage
', ), messageId: '', }, - // Thread: Q4 Project Timeline — Alice → Bob → Alice (4 messages) + // Thread: Q4 Project Timeline - Alice → Bob → Alice (4 messages) { id: 'demo-email-3a', threadId: 'demo-thread-3', @@ -198,7 +198,7 @@ export function createDemoEmails(): Email[] { messageId: '', }, - // Email with attachments — invoice + // Email with attachments - invoice { id: 'demo-email-4', threadId: 'demo-thread-4', @@ -213,7 +213,7 @@ export function createDemoEmails(): Email[] { preview: "Hi, please find attached the invoice for October and a screenshot of the latest prototype...", hasAttachment: true, ...textOnly( - "Hi,\n\nPlease find attached the invoice for October and a screenshot of the latest prototype. I went with Option B for the hero (the one with the asymmetric grid) since you mentioned the symmetrical version felt too flat in our last call.\n\nIf the invoice line items look off, ping me — I had to back out the November pre-payment.\n\nBest regards,\nSarah", + "Hi,\n\nPlease find attached the invoice for October and a screenshot of the latest prototype. I went with Option B for the hero (the one with the asymmetric grid) since you mentioned the symmetrical version felt too flat in our last call.\n\nIf the invoice line items look off, ping me - I had to back out the November pre-payment.\n\nBest regards,\nSarah", ), attachments: [ { partId: 'att-1', blobId: 'demo-blob-att-1', size: 145000, name: 'Invoice-2024-089.pdf', type: 'application/pdf' }, @@ -222,7 +222,7 @@ export function createDemoEmails(): Email[] { messageId: '', }, - // Carlos — starred, social + // Carlos - starred, social { id: 'demo-email-5', threadId: 'demo-thread-5', @@ -232,17 +232,17 @@ export function createDemoEmails(): Email[] { receivedAt: demoDate(-2, -1), from: [{ name: 'Carlos Rivera', email: 'carlos.rivera@example.com' }], to: [USER], - subject: 'Friday dinner — moved to 7:30 (sorry!)', + subject: 'Friday dinner - moved to 7:30 (sorry!)', sentAt: demoDate(-2, -1), - preview: 'Quick heads up — had to push the dinner back half an hour. Bistro could only do the late seating...', + preview: 'Quick heads up - had to push the dinner back half an hour. Bistro could only do the late seating...', hasAttachment: false, ...textOnly( - "Quick heads up — had to push the dinner back half an hour. Bistro could only do the late seating.\n\nNew time: Friday, 7:30 PM\nThe Garden Bistro, 123 Oak Street\n\nReservation under my name, 8 people. Let me know if that doesn't work for you and I can try to wrangle something.\n\nCheers,\nCarlos", + "Quick heads up - had to push the dinner back half an hour. Bistro could only do the late seating.\n\nNew time: Friday, 7:30 PM\nThe Garden Bistro, 123 Oak Street\n\nReservation under my name, 8 people. Let me know if that doesn't work for you and I can try to wrangle something.\n\nCheers,\nCarlos", ), messageId: '', }, - // Linear — issue assigned + // Linear - issue assigned { id: 'demo-email-linear', threadId: 'demo-thread-linear', @@ -252,7 +252,7 @@ export function createDemoEmails(): Email[] { receivedAt: demoDate(0, -7, -15), from: [{ name: 'Linear', email: 'notifications@linear.app' }], to: [USER], - subject: 'BUL-2031 was assigned to you — "Compose: drag-and-drop attachments duplicated on slow networks"', + subject: 'BUL-2031 was assigned to you - "Compose: drag-and-drop attachments duplicated on slow networks"', sentAt: demoDate(0, -7, -15), preview: 'Priya Sharma assigned this issue to you. Repro on a throttled connection (Slow 3G): drop a file twice and...', hasAttachment: false, @@ -263,7 +263,7 @@ export function createDemoEmails(): Email[] { messageId: '', }, - // Anna — sister, photos + // Anna - sister, photos { id: 'demo-email-anna', threadId: 'demo-thread-anna', @@ -278,7 +278,7 @@ export function createDemoEmails(): Email[] { preview: "finally got around to going through these. there are like 600 more on the drive but here's the highlights...", hasAttachment: true, ...textOnly( - "ok finally got around to going through these. there are like 600 more on the drive but here's the highlights — the ones I'd actually want to print.\n\nmom looked SO happy. dad cried during the speech btw, did you see?\n\nlet me know which ones you want full-res of\n\na", + "ok finally got around to going through these. there are like 600 more on the drive but here's the highlights - the ones I'd actually want to print.\n\nmom looked SO happy. dad cried during the speech btw, did you see?\n\nlet me know which ones you want full-res of\n\na", ), attachments: [ { partId: 'att-3', blobId: 'demo-blob-att-3', size: 1800000, name: 'wedding-001.jpg', type: 'image/jpeg' }, @@ -298,17 +298,17 @@ export function createDemoEmails(): Email[] { receivedAt: demoDate(-2, -3, -45), from: [{ name: 'AWS Billing', email: 'no-reply-aws@amazon.com' }], to: [USER], - subject: 'Your AWS bill is available — $127.43', + subject: 'Your AWS bill is available - $127.43', sentAt: demoDate(-2, -3, -45), preview: 'Your bill for the previous billing period is now available. Total this period: $127.43 (down $4.12)...', hasAttachment: false, ...textOnly( - "Your bill for the previous billing period is now available.\n\nTotal this period: $127.43 (down $4.12 from last period)\n\nTop services:\n EC2 — $61.20\n S3 — $28.94\n Route 53 — $14.50\n CloudFront — $11.02\n Other — $11.77\n\nView the full invoice in the Billing Console.", + "Your bill for the previous billing period is now available.\n\nTotal this period: $127.43 (down $4.12 from last period)\n\nTop services:\n EC2 - $61.20\n S3 - $28.94\n Route 53 - $14.50\n CloudFront - $11.02\n Other - $11.77\n\nView the full invoice in the Billing Console.", ), messageId: '', }, - // 2FA code — system, unread + // 2FA code - system, unread { id: 'demo-email-2fa', threadId: 'demo-thread-2fa', @@ -323,12 +323,12 @@ export function createDemoEmails(): Email[] { preview: "Use this code within 10 minutes to sign in. If you didn't request it, ignore this email.", hasAttachment: false, ...textOnly( - "Your verification code: 814-302\n\nUse this code within 10 minutes to sign in. If you didn't request it, you can safely ignore this email — your account remains secure.", + "Your verification code: 814-302\n\nUse this code within 10 minutes to sign in. If you didn't request it, you can safely ignore this email - your account remains secure.", ), messageId: '', }, - // LinkedIn — cold-ish + // LinkedIn - cold-ish { id: 'demo-email-linkedin', threadId: 'demo-thread-linkedin', @@ -338,17 +338,17 @@ export function createDemoEmails(): Email[] { receivedAt: demoDate(-3, -11), from: [{ name: 'LinkedIn', email: 'jobs-noreply@linkedin.com' }], to: [USER], - subject: '5 jobs matching "staff engineer · remote · eu" — including one at Datadog', + subject: '5 jobs matching "staff engineer · remote · eu" - including one at Datadog', sentAt: demoDate(-3, -11), preview: "We thought you'd be interested in these jobs based on your profile and search history.", hasAttachment: false, ...textOnly( - 'Based on your saved search "staff engineer · remote · eu":\n\n1. Staff Software Engineer — Datadog (Remote, EU)\n2. Principal Engineer, Platform — Sentry (Remote, EU)\n3. Staff Backend Engineer — Linear (Remote)\n4. Tech Lead, Infrastructure — Tailscale (Remote, EU)\n5. Staff Engineer, Mobile — Notion (Remote, EU)\n\nManage job alerts at linkedin.com/jobs/preferences.', + 'Based on your saved search "staff engineer · remote · eu":\n\n1. Staff Software Engineer - Datadog (Remote, EU)\n2. Principal Engineer, Platform - Sentry (Remote, EU)\n3. Staff Backend Engineer - Linear (Remote)\n4. Tech Lead, Infrastructure - Tailscale (Remote, EU)\n5. Staff Engineer, Mobile - Notion (Remote, EU)\n\nManage job alerts at linkedin.com/jobs/preferences.', ), messageId: '', }, - // Book club — Marcus + // Book club - Marcus { id: 'demo-email-bookclub', threadId: 'demo-thread-bookclub', @@ -358,7 +358,7 @@ export function createDemoEmails(): Email[] { receivedAt: demoDate(-1, -14), from: [{ name: 'Marcus Hughes', email: 'marcus.hughes@example.com' }], to: [USER, { name: 'Emma Wilson', email: 'emma.wilson@example.com' }, { name: 'David Park', email: 'david.park@example.com' }], - subject: 'book club thursday — picking the next one', + subject: 'book club thursday - picking the next one', sentAt: demoDate(-1, -14), preview: 'Reminder: 7pm at mine. We finish off Le Guin and pick the next read. My vote is the Calvino but I know Emma...', hasAttachment: false, @@ -378,7 +378,7 @@ export function createDemoEmails(): Email[] { receivedAt: demoDate(0, -9, -30), from: [{ name: 'DHL Express', email: 'noreply@dhl.com' }], to: [USER], - subject: 'Your package is out for delivery — arriving today', + subject: 'Your package is out for delivery - arriving today', sentAt: demoDate(0, -9, -30), preview: 'Tracking 1Z 999 AA1 0123 4567 84 · Estimated delivery: today between 14:00 and 18:00.', hasAttachment: false, @@ -398,12 +398,12 @@ export function createDemoEmails(): Email[] { receivedAt: demoDate(-2, -16), from: [{ name: 'Olivia Bennett (via Notion)', email: 'team@mail.notion.so' }], to: [USER], - subject: 'Olivia shared "Q1 2026 — design north star" with you', + subject: 'Olivia shared "Q1 2026 - design north star" with you', sentAt: demoDate(-2, -16), preview: 'Olivia Bennett shared a page with you in the Northwind workspace. Open in Notion to view.', hasAttachment: false, ...textOnly( - 'Olivia Bennett shared a page with you in the Northwind workspace.\n\n"Q1 2026 — design north star"\n\nOpen in Notion: https://notion.so/northwind/q1-design-north-star', + 'Olivia Bennett shared a page with you in the Northwind workspace.\n\n"Q1 2026 - design north star"\n\nOpen in Notion: https://notion.so/northwind/q1-design-north-star', ), messageId: '', }, @@ -438,7 +438,7 @@ export function createDemoEmails(): Email[] { receivedAt: demoDate(-5, -10), from: [{ name: 'Booking.com', email: 'no-reply@booking.com' }], to: [USER], - subject: 'Confirmation 4892-7714-3320 — Hotel Lago, Lake Como (Dec 22–25)', + subject: 'Confirmation 4892-7714-3320 - Hotel Lago, Lake Como (Dec 22–25)', sentAt: demoDate(-5, -10), preview: 'Your booking is confirmed. Check-in: Dec 22, after 15:00. Check-out: Dec 25, before 11:00.', hasAttachment: true, @@ -466,7 +466,7 @@ export function createDemoEmails(): Email[] { preview: 'I have been spending the slow weeks of November in the workshop, slowly forging a knife from a piece of...', hasAttachment: false, ...textOnly( - "Hello, friends.\n\nI have been spending the slow weeks of November in the workshop, slowly forging a knife from a piece of railway track. It is going badly, in the way that is good for one's soul.\n\nWhat I'm reading: Annie Dillard, again. \"The Writing Life\". Specifically the chapter about her cabin, which I read every year around this time and which always makes me want to throw my laptop into the sea.\n\nWhat I'm watching: very little. There is something about December that makes television feel like an admission of defeat.\n\nUntil next month —\nR.", + "Hello, friends.\n\nI have been spending the slow weeks of November in the workshop, slowly forging a knife from a piece of railway track. It is going badly, in the way that is good for one's soul.\n\nWhat I'm reading: Annie Dillard, again. \"The Writing Life\". Specifically the chapter about her cabin, which I read every year around this time and which always makes me want to throw my laptop into the sea.\n\nWhat I'm watching: very little. There is something about December that makes television feel like an admission of defeat.\n\nUntil next month -\nR.", ), messageId: '', }, @@ -481,12 +481,12 @@ export function createDemoEmails(): Email[] { receivedAt: demoDate(0, -10), from: [{ name: 'Jennifer Hayes', email: 'jennifer@talent-partners.example' }], to: [USER], - subject: 'Senior role — Distributed Systems — €180-220k + equity', + subject: 'Senior role - Distributed Systems - €180-220k + equity', sentAt: demoDate(0, -10), preview: "Hi, I came across your profile and thought you'd be a great fit for a senior position with one of our clients...", hasAttachment: false, ...textOnly( - "Hi,\n\nI came across your profile and thought you'd be a great fit for a senior position with one of our clients — a well-funded Series B (real-time data infrastructure, 60-person eng team, fully remote within EU).\n\nThe core stack: Rust + Postgres + a non-trivial amount of Go. Hiring level is roughly equivalent to Staff at FAANG.\n\nWould you be open to a 15-minute call this week or next?\n\nBest,\nJennifer Hayes\nTalent Partners", + "Hi,\n\nI came across your profile and thought you'd be a great fit for a senior position with one of our clients - a well-funded Series B (real-time data infrastructure, 60-person eng team, fully remote within EU).\n\nThe core stack: Rust + Postgres + a non-trivial amount of Go. Hiring level is roughly equivalent to Staff at FAANG.\n\nWould you be open to a 15-minute call this week or next?\n\nBest,\nJennifer Hayes\nTalent Partners", ), messageId: '', }, @@ -501,7 +501,7 @@ export function createDemoEmails(): Email[] { receivedAt: demoDate(-1, -2), from: [{ name: "Dr. Smith's Office", email: 'appointments@drsmith.example' }], to: [USER], - subject: 'Appointment reminder — Tuesday at 10:00', + subject: 'Appointment reminder - Tuesday at 10:00', sentAt: demoDate(-1, -2), preview: 'This is a friendly reminder of your upcoming cleaning appointment on Tuesday at 10:00 AM.', hasAttachment: false, @@ -559,10 +559,10 @@ export function createDemoEmails(): Email[] { to: [{ name: 'Sofia Russo', email: 'sofia.russo@example.com' }], subject: 'Re: when are you coming home?', sentAt: demoDate(0, -2, -10), - preview: "Mom — I miss you too. Let me check the calendar tonight and I'll get back to you tomorrow about the weekend...", + preview: "Mom - I miss you too. Let me check the calendar tonight and I'll get back to you tomorrow about the weekend...", hasAttachment: false, ...textOnly( - "Mom — I miss you too. Let me check the calendar tonight and I'll get back to you tomorrow about the weekend. Lemons sound like a bribe and I will not pretend otherwise.\n\nLove you both.", + "Mom - I miss you too. Let me check the calendar tonight and I'll get back to you tomorrow about the weekend. Lemons sound like a bribe and I will not pretend otherwise.\n\nLove you both.", ), messageId: '', inReplyTo: ['<5a8c-mom@example.com>'], @@ -597,7 +597,7 @@ export function createDemoEmails(): Email[] { receivedAt: demoDate(0, -8), from: [USER], to: [{ name: 'Jennifer Hayes', email: 'jennifer@talent-partners.example' }], - subject: 'Re: Senior role — Distributed Systems', + subject: 'Re: Senior role - Distributed Systems', sentAt: demoDate(0, -8), preview: "Hi Jennifer, thanks for reaching out. I'm not actively looking, but the role sounds interesting enough that...", hasAttachment: false, @@ -691,12 +691,12 @@ export function createDemoEmails(): Email[] { receivedAt: demoDate(-1, -15), from: [{ name: 'Michael Torres', email: 'michael.torres@company.example' }], to: [USER, { name: 'Alice Johnson', email: 'alice.johnson@example.com' }, { name: 'James Miller', email: 'james.miller@company.example' }], - subject: '[Project] Q1 2026 roadmap — first cut', + subject: '[Project] Q1 2026 roadmap - first cut', sentAt: demoDate(-1, -15), preview: 'Attached is the first cut of the Q1 roadmap. Three themes: reliability, mobile, and the long-promised...', hasAttachment: true, ...textOnly( - "Team,\n\nAttached is the first cut of the Q1 roadmap. Three themes:\n\n1. Reliability (Alice's team)\n2. Mobile parity (cross-functional)\n3. The long-promised search rework (James, this is mostly on you)\n\nLet's leave comments in the doc rather than do a meeting — I'd rather have the meeting be the *decisions*, not the discussion. Closing comments end-of-week.\n\nM", + "Team,\n\nAttached is the first cut of the Q1 roadmap. Three themes:\n\n1. Reliability (Alice's team)\n2. Mobile parity (cross-functional)\n3. The long-promised search rework (James, this is mostly on you)\n\nLet's leave comments in the doc rather than do a meeting - I'd rather have the meeting be the *decisions*, not the discussion. Closing comments end-of-week.\n\nM", ), attachments: [ { partId: 'att-7', blobId: 'demo-blob-att-7', size: 84000, name: 'Q1-2026-roadmap-v0.pdf', type: 'application/pdf' }, @@ -719,7 +719,7 @@ export function createDemoEmails(): Email[] { preview: 'Please review the updated PTO policy that takes effect January 1st...', hasAttachment: false, ...textOnly( - 'Dear team,\n\nPlease review the updated PTO policy effective January 1st. Key changes include:\n\n- Increased annual allowance from 20 to 25 days\n- Flexible half-day options\n- Rollover limit increased to 10 days\n\nPlease acknowledge receipt.\n\nBest,\nMaria — People Ops', + 'Dear team,\n\nPlease review the updated PTO policy effective January 1st. Key changes include:\n\n- Increased annual allowance from 20 to 25 days\n- Flexible half-day options\n- Rollover limit increased to 10 days\n\nPlease acknowledge receipt.\n\nBest,\nMaria - People Ops', ), messageId: '', }, @@ -732,12 +732,12 @@ export function createDemoEmails(): Email[] { receivedAt: demoDate(-21, -4), from: [{ name: 'Fastmail Support', email: 'support@fastmail.com' }], to: [USER], - subject: 'Re: Ticket #438201 — DKIM signing fails on cross-account aliases', + subject: 'Re: Ticket #438201 - DKIM signing fails on cross-account aliases', sentAt: demoDate(-21, -4), - preview: "Thanks for the additional logs. We were able to reproduce on our side — the issue was indeed the alias resolution...", + preview: "Thanks for the additional logs. We were able to reproduce on our side - the issue was indeed the alias resolution...", hasAttachment: false, ...textOnly( - "Hi,\n\nThanks for the additional logs. We were able to reproduce on our side — the issue was indeed the alias resolution path skipping the DKIM signer step. Fix has been deployed to the AU and SY clusters; EU rolls out tomorrow.\n\nResolved on our end. Please reopen if you see anything related.\n\nBest,\nClaire — Fastmail Support", + "Hi,\n\nThanks for the additional logs. We were able to reproduce on our side - the issue was indeed the alias resolution path skipping the DKIM signer step. Fix has been deployed to the AU and SY clusters; EU rolls out tomorrow.\n\nResolved on our end. Please reopen if you see anything related.\n\nBest,\nClaire - Fastmail Support", ), messageId: '', }, @@ -752,7 +752,7 @@ export function createDemoEmails(): Email[] { receivedAt: demoDate(-3, -12), from: [{ name: 'Hetzner', email: 'billing@hetzner.com' }], to: [USER], - subject: 'Invoice #INV-2024-1042 — €49.99 (paid)', + subject: 'Invoice #INV-2024-1042 - €49.99 (paid)', sentAt: demoDate(-3, -12), preview: 'Your payment of €49.99 has been processed successfully...', hasAttachment: true, @@ -773,12 +773,12 @@ export function createDemoEmails(): Email[] { receivedAt: demoDate(-9, -8), from: [{ name: 'Porkbun', email: 'support@porkbun.com' }], to: [USER], - subject: 'Renewal confirmation — example.com (1 year)', + subject: 'Renewal confirmation - example.com (1 year)', sentAt: demoDate(-9, -8), preview: 'Your domain example.com has been renewed for 1 year. Next renewal: 11 months from today.', hasAttachment: false, ...textOnly( - "Hi,\n\nYour domain example.com has been renewed for 1 year.\n\nAmount: $11.06\nNext renewal: 11 months from today\nAutorenew: on\n\nReply to this email if you need a tax-receipt-style invoice.\n\n— Porkbun", + "Hi,\n\nYour domain example.com has been renewed for 1 year.\n\nAmount: $11.06\nNext renewal: 11 months from today\nAutorenew: on\n\nReply to this email if you need a tax-receipt-style invoice.\n\n- Porkbun", ), messageId: '', }, @@ -811,12 +811,12 @@ export function createDemoEmails(): Email[] { receivedAt: demoDate(-2, -3), from: [{ name: 'Secure Banking', email: 'security-alert@secur1ty-bank.example' }], to: [USER], - subject: 'URGENT: Unusual activity on your account — verify within 24 hours', + subject: 'URGENT: Unusual activity on your account - verify within 24 hours', sentAt: demoDate(-2, -3), preview: "We've detected suspicious activity. Click below to verify your identity or your account will be suspended...", hasAttachment: false, ...textOnly( - "We've detected suspicious activity on your account. To prevent suspension, please verify your details within 24 hours by clicking the link below.\n\n[Phishing demo — never click links like this in real life.]", + "We've detected suspicious activity on your account. To prevent suspension, please verify your details within 24 hours by clicking the link below.\n\n[Phishing demo - never click links like this in real life.]", ), messageId: '', }, @@ -829,7 +829,7 @@ export function createDemoEmails(): Email[] { receivedAt: demoDate(-3, -19), from: [{ name: 'CryptoGrowth Daily', email: 'invest@cryptogrowth.example' }], to: [USER], - subject: '🚀 The coin Elon won\'t tell you about — 1000x potential', + subject: '🚀 The coin Elon won\'t tell you about - 1000x potential', sentAt: demoDate(-3, -19), preview: 'Three early backers turned $500 into $5M in 90 days. Today, you have a chance to get in even earlier...', hasAttachment: false, diff --git a/lib/reply-identity.ts b/lib/reply-identity.ts index faef11b9..bd5b9dee 100644 --- a/lib/reply-identity.ts +++ b/lib/reply-identity.ts @@ -73,7 +73,7 @@ export interface ReplyFromResolution { /** * Override for the outgoing `From:` header. Populated when the incoming * message was delivered to an address on a domain the user owns (by - * identity) but that isn't itself a configured identity — typical + * identity) but that isn't itself a configured identity - typical * domain-catch-all deployments. When set, the composer should put this * address (and `overrideName`) in the message's From header while sending * through the chosen identity. diff --git a/locales/cs/common.json b/locales/cs/common.json index c31e0f9e..dab2bd4d 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -565,7 +565,7 @@ "from_override": { "toggle_off": "Přepsat", "toggle_on": "Zrušit přepsání", - "toggle_tooltip": "Volně upravujte jméno a adresu odesílatele. Pošta se stále odesílá přes vaši identitu — mění se pouze viditelné záhlaví Od.", + "toggle_tooltip": "Volně upravujte jméno a adresu odesílatele. Pošta se stále odesílá přes vaši identitu - mění se pouze viditelné záhlaví Od.", "name_label": "Jméno odesílatele", "name_placeholder": "Jméno", "email_label": "E-mailová adresa odesílatele", diff --git a/locales/de/common.json b/locales/de/common.json index e37259ff..a0ed998f 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -565,7 +565,7 @@ "from_override": { "toggle_off": "Überschreiben", "toggle_on": "Überschreibung aufheben", - "toggle_tooltip": "Bearbeiten Sie Absendername und -adresse frei. Die E-Mail wird weiterhin über Ihre Identität gesendet — nur die sichtbare Absenderkopfzeile ändert sich.", + "toggle_tooltip": "Bearbeiten Sie Absendername und -adresse frei. Die E-Mail wird weiterhin über Ihre Identität gesendet - nur die sichtbare Absenderkopfzeile ändert sich.", "name_label": "Absendername", "name_placeholder": "Name", "email_label": "Absender-E-Mail-Adresse", diff --git a/locales/en/common.json b/locales/en/common.json index d5cddbc4..da1d1943 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -568,7 +568,7 @@ "from_override": { "toggle_off": "Override", "toggle_on": "Cancel override", - "toggle_tooltip": "Edit the From name and address freely. Mail is still sent through your identity — only the visible From header changes.", + "toggle_tooltip": "Edit the From name and address freely. Mail is still sent through your identity - only the visible From header changes.", "name_label": "From name", "name_placeholder": "Name", "email_label": "From email address", diff --git a/locales/es/common.json b/locales/es/common.json index 5ad9e06e..22674f3f 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -565,7 +565,7 @@ "from_override": { "toggle_off": "Anular", "toggle_on": "Cancelar anulación", - "toggle_tooltip": "Edita libremente el nombre y la dirección del remitente. El correo aún se envía a través de tu identidad — solo cambia el encabezado De visible.", + "toggle_tooltip": "Edita libremente el nombre y la dirección del remitente. El correo aún se envía a través de tu identidad - solo cambia el encabezado De visible.", "name_label": "Nombre del remitente", "name_placeholder": "Nombre", "email_label": "Dirección de correo del remitente", diff --git a/locales/fr/common.json b/locales/fr/common.json index cb797136..fbeb6a1d 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -565,7 +565,7 @@ "from_override": { "toggle_off": "Remplacer", "toggle_on": "Annuler le remplacement", - "toggle_tooltip": "Modifiez librement le nom et l'adresse d'expéditeur. Le courrier est toujours envoyé via votre identité — seul l'en-tête De visible change.", + "toggle_tooltip": "Modifiez librement le nom et l'adresse d'expéditeur. Le courrier est toujours envoyé via votre identité - seul l'en-tête De visible change.", "name_label": "Nom de l'expéditeur", "name_placeholder": "Nom", "email_label": "Adresse e-mail de l'expéditeur", diff --git a/locales/it/common.json b/locales/it/common.json index d428634a..906be587 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -565,7 +565,7 @@ "from_override": { "toggle_off": "Sovrascrivi", "toggle_on": "Annulla sovrascrittura", - "toggle_tooltip": "Modifica liberamente nome e indirizzo del mittente. La posta viene comunque inviata tramite la tua identità — cambia solo l'intestazione Da visibile.", + "toggle_tooltip": "Modifica liberamente nome e indirizzo del mittente. La posta viene comunque inviata tramite la tua identità - cambia solo l'intestazione Da visibile.", "name_label": "Nome mittente", "name_placeholder": "Nome", "email_label": "Indirizzo email del mittente", diff --git a/locales/ja/common.json b/locales/ja/common.json index fc44e4ac..fee9a16a 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -565,7 +565,7 @@ "from_override": { "toggle_off": "上書き", "toggle_on": "上書きを取り消す", - "toggle_tooltip": "差出人名とアドレスを自由に編集できます。メールは引き続きあなたのアイデンティティ経由で送信されます — 表示される差出人ヘッダーのみが変更されます。", + "toggle_tooltip": "差出人名とアドレスを自由に編集できます。メールは引き続きあなたのアイデンティティ経由で送信されます - 表示される差出人ヘッダーのみが変更されます。", "name_label": "差出人名", "name_placeholder": "名前", "email_label": "差出人メールアドレス", diff --git a/locales/ko/common.json b/locales/ko/common.json index 40baaef4..c355a18c 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -565,7 +565,7 @@ "from_override": { "toggle_off": "재정의", "toggle_on": "재정의 취소", - "toggle_tooltip": "보낸 사람 이름과 주소를 자유롭게 편집하세요. 메일은 여전히 사용자의 ID를 통해 전송되며 — 표시되는 보낸 사람 헤더만 변경됩니다.", + "toggle_tooltip": "보낸 사람 이름과 주소를 자유롭게 편집하세요. 메일은 여전히 사용자의 ID를 통해 전송되며 - 표시되는 보낸 사람 헤더만 변경됩니다.", "name_label": "보낸 사람 이름", "name_placeholder": "이름", "email_label": "보낸 사람 이메일 주소", diff --git a/locales/lv/common.json b/locales/lv/common.json index 8915b65d..494cfae2 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -565,7 +565,7 @@ "from_override": { "toggle_off": "Pārrakstīt", "toggle_on": "Atcelt pārrakstīšanu", - "toggle_tooltip": "Brīvi rediģējiet sūtītāja vārdu un adresi. Pasts joprojām tiek sūtīts caur jūsu identitāti — mainās tikai redzamais No galvenes ieraksts.", + "toggle_tooltip": "Brīvi rediģējiet sūtītāja vārdu un adresi. Pasts joprojām tiek sūtīts caur jūsu identitāti - mainās tikai redzamais No galvenes ieraksts.", "name_label": "Sūtītāja vārds", "name_placeholder": "Vārds", "email_label": "Sūtītāja e-pasta adrese", diff --git a/locales/nl/common.json b/locales/nl/common.json index a0f52322..4d84c4ad 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -565,7 +565,7 @@ "from_override": { "toggle_off": "Overschrijven", "toggle_on": "Overschrijven annuleren", - "toggle_tooltip": "Bewerk de naam en het adres van de afzender vrij. E-mail wordt nog steeds via je identiteit verzonden — alleen de zichtbare Van-koptekst verandert.", + "toggle_tooltip": "Bewerk de naam en het adres van de afzender vrij. E-mail wordt nog steeds via je identiteit verzonden - alleen de zichtbare Van-koptekst verandert.", "name_label": "Afzendernaam", "name_placeholder": "Naam", "email_label": "E-mailadres afzender", diff --git a/locales/pl/common.json b/locales/pl/common.json index c552a09c..c109f85f 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -565,7 +565,7 @@ "from_override": { "toggle_off": "Zastąp", "toggle_on": "Anuluj zastąpienie", - "toggle_tooltip": "Swobodnie edytuj nazwę i adres nadawcy. Poczta jest nadal wysyłana przez twoją tożsamość — zmienia się tylko widoczny nagłówek Od.", + "toggle_tooltip": "Swobodnie edytuj nazwę i adres nadawcy. Poczta jest nadal wysyłana przez twoją tożsamość - zmienia się tylko widoczny nagłówek Od.", "name_label": "Nazwa nadawcy", "name_placeholder": "Nazwa", "email_label": "Adres e-mail nadawcy", diff --git a/locales/pt/common.json b/locales/pt/common.json index a80a3bfa..f7ebecf8 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -565,7 +565,7 @@ "from_override": { "toggle_off": "Substituir", "toggle_on": "Cancelar substituição", - "toggle_tooltip": "Edite livremente o nome e o endereço do remetente. O email ainda é enviado através da sua identidade — apenas o cabeçalho De visível muda.", + "toggle_tooltip": "Edite livremente o nome e o endereço do remetente. O email ainda é enviado através da sua identidade - apenas o cabeçalho De visível muda.", "name_label": "Nome do remetente", "name_placeholder": "Nome", "email_label": "Endereço de email do remetente", diff --git a/locales/ru/common.json b/locales/ru/common.json index 2bc4a4b2..727cb7c7 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -565,7 +565,7 @@ "from_override": { "toggle_off": "Переопределить", "toggle_on": "Отменить переопределение", - "toggle_tooltip": "Свободно редактируйте имя и адрес отправителя. Письмо по-прежнему отправляется через вашу учётную запись — меняется только видимый заголовок От.", + "toggle_tooltip": "Свободно редактируйте имя и адрес отправителя. Письмо по-прежнему отправляется через вашу учётную запись - меняется только видимый заголовок От.", "name_label": "Имя отправителя", "name_placeholder": "Имя", "email_label": "Email отправителя", diff --git a/locales/tr/common.json b/locales/tr/common.json index 36c461dc..dd672442 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -568,7 +568,7 @@ "from_override": { "toggle_off": "Geçersiz kıl", "toggle_on": "Geçersiz kılmayı iptal et", - "toggle_tooltip": "Gönderen adını ve adresini serbestçe düzenleyin. Posta hâlâ kimliğiniz üzerinden gönderilir — yalnızca görünür Kimden başlığı değişir.", + "toggle_tooltip": "Gönderen adını ve adresini serbestçe düzenleyin. Posta hâlâ kimliğiniz üzerinden gönderilir - yalnızca görünür Kimden başlığı değişir.", "name_label": "Gönderen adı", "name_placeholder": "Ad", "email_label": "Gönderen e-posta adresi", diff --git a/locales/uk/common.json b/locales/uk/common.json index 1781c8ea..8681584e 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -565,7 +565,7 @@ "from_override": { "toggle_off": "Замінити", "toggle_on": "Скасувати заміну", - "toggle_tooltip": "Вільно редагуйте ім'я та адресу відправника. Пошта все ще надсилається через вашу ідентичність — змінюється лише видимий заголовок Від.", + "toggle_tooltip": "Вільно редагуйте ім'я та адресу відправника. Пошта все ще надсилається через вашу ідентичність - змінюється лише видимий заголовок Від.", "name_label": "Ім'я відправника", "name_placeholder": "Ім'я", "email_label": "Електронна адреса відправника", diff --git a/locales/zh/common.json b/locales/zh/common.json index c0ad9758..bd154c93 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -565,7 +565,7 @@ "from_override": { "toggle_off": "覆盖", "toggle_on": "取消覆盖", - "toggle_tooltip": "自由编辑发件人姓名和地址。邮件仍通过您的身份发送 — 仅可见的发件人标题发生变化。", + "toggle_tooltip": "自由编辑发件人姓名和地址。邮件仍通过您的身份发送 - 仅可见的发件人标题发生变化。", "name_label": "发件人姓名", "name_placeholder": "姓名", "email_label": "发件人电子邮件地址", diff --git a/proxy.ts b/proxy.ts index c3af96fe..135a6f32 100644 --- a/proxy.ts +++ b/proxy.ts @@ -37,7 +37,7 @@ export async function proxy(request: NextRequest) { pathname === "/api/health" || pathname.startsWith("/_next/") || pathname.startsWith("/branding/") || - // Public read endpoint — serves wizard-uploaded branding assets so + // Public read endpoint - serves wizard-uploaded branding assets so // image previews work during the wizard. No auth on the GET route. pathname.startsWith("/api/admin/branding/") || /\.[^/]+$/.test(pathname); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 3fa8fa93..fad3d44f 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -770,7 +770,7 @@ export const useAuthStore = create()( const accountStore = useAccountStore.getState(); const slot = accountStore.getNextCookieSlot(); - // SSO token exchange and config fetch are independent — fire both + // SSO token exchange and config fetch are independent - fire both // up front and let them resolve in parallel. const [ssoRes, config] = await Promise.all([ apiFetch('/api/auth/sso/complete', { diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 7a57ee42..90ed796d 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -824,6 +824,13 @@ if (typeof window !== 'undefined') { if (res.status === 404) { syncWarn('Settings sync endpoint returned 404, disabling sync'); syncEnabled = false; + } else if (res.status === 403) { + // Identity mismatch — current session cookies don't match the + // username/serverUrl we're syncing for (common in dev mock mode where + // no stalwart-context cookie is written, or when rememberMe is off). + // Retrying won't help for this session; disable to stop the noise. + syncWarn('Settings sync rejected (identity mismatch), disabling sync'); + syncEnabled = false; } else if (res.status >= 500 && retries > 0) { const body = await res.json().catch(() => ({})); syncWarn('Settings sync got server error:', body.error || `status ${res.status}`, '- retrying...'); From fae15f073e3382efa0cb3b4e8dc0b1a3cc6e2653 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 14 May 2026 21:49:37 +0200 Subject: [PATCH 050/133] fix: honor cookieSameSite admin config override #284 --- app/api/auth/session/route.ts | 12 +++++++----- lib/oauth/cookie-config.ts | 15 +++++++++------ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 5711df57..7d9dce6c 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -20,10 +20,12 @@ import { recordLogin } from '@/lib/telemetry/login-tracker'; import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; -const COOKIE_OPTIONS = { - ...getCookieOptions(), - maxAge: SESSION_COOKIE_MAX_AGE, -}; +function sessionCookieOptions() { + return { + ...getCookieOptions(), + maxAge: SESSION_COOKIE_MAX_AGE, + }; +} function getSlot(request: NextRequest): number { const raw = request.nextUrl.searchParams.get('slot'); @@ -88,7 +90,7 @@ export async function POST(request: NextRequest) { : await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false }); const token = encryptSession(normalizedServerUrl, username, password); const cookieStore = await cookies(); - cookieStore.set(cookieName, token, COOKIE_OPTIONS); + cookieStore.set(cookieName, token, sessionCookieOptions()); setStalwartAuthContextInStore(cookieStore, slot, { serverUrl: normalizedServerUrl, username, diff --git a/lib/oauth/cookie-config.ts b/lib/oauth/cookie-config.ts index 5e111a42..96a4d82c 100644 --- a/lib/oauth/cookie-config.ts +++ b/lib/oauth/cookie-config.ts @@ -1,13 +1,16 @@ -const COOKIE_SAME_SITE = (process.env.COOKIE_SAME_SITE || 'lax') as 'lax' | 'none' | 'strict'; -const COOKIE_SECURE = process.env.COOKIE_SECURE !== undefined - ? process.env.COOKIE_SECURE === 'true' - : (COOKIE_SAME_SITE === 'none' || process.env.NODE_ENV === 'production'); +import { configManager } from '@/lib/admin/config-manager'; + +type SameSite = 'lax' | 'none' | 'strict'; export function getCookieOptions() { + const sameSite = configManager.get('cookieSameSite', 'lax'); + const secure = process.env.COOKIE_SECURE !== undefined + ? process.env.COOKIE_SECURE === 'true' + : (sameSite === 'none' || process.env.NODE_ENV === 'production'); return { httpOnly: true, - secure: COOKIE_SECURE, - sameSite: COOKIE_SAME_SITE, + secure, + sameSite, path: '/', maxAge: 30 * 24 * 60 * 60, }; From 5fdf226ebe751f359384f218c38ec67dd9b9e3b9 Mon Sep 17 00:00:00 2001 From: Jesper Ordrup Date: Fri, 15 May 2026 11:25:04 +0200 Subject: [PATCH 051/133] feat(i18n): add danish localization --- FEATURES.md | 2 +- components/providers/intl-provider.tsx | 2 + components/ui/flag-icons.tsx | 10 + components/ui/language-switcher.tsx | 3 +- i18n/request.ts | 3 + i18n/routing.ts | 2 +- locales/da/common.json | 2912 ++++++++++++++++++++++++ 7 files changed, 2931 insertions(+), 3 deletions(-) create mode 100644 locales/da/common.json diff --git a/FEATURES.md b/FEATURES.md index 37638ba2..a7cf5459 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -98,7 +98,7 @@ ## Internationalization -15 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · Türkçe · 한국어 · Polski · Latviešu · 简体中文 · Українська +15 languages: English · Français · 日本語 · Español · Italiano · Danish · Deutsch · Nederlands · Português · Русский · Türkçe · 한국어 · Polski · Latviešu · 简体中文 · Українська Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`. diff --git a/components/providers/intl-provider.tsx b/components/providers/intl-provider.tsx index aa6addd3..8f4598e7 100644 --- a/components/providers/intl-provider.tsx +++ b/components/providers/intl-provider.tsx @@ -19,6 +19,7 @@ import ruMessages from '@/locales/ru/common.json'; import trMessages from '@/locales/tr/common.json'; import ukMessages from '@/locales/uk/common.json'; import zhMessages from '@/locales/zh/common.json'; +import daMessages from '@/locales/da/common.json'; // Pre-loaded translations (loaded at build time, not runtime) const ALL_MESSAGES = { @@ -38,6 +39,7 @@ const ALL_MESSAGES = { tr: trMessages, uk: ukMessages, zh: zhMessages, + da: daMessages }; interface IntlProviderProps { diff --git a/components/ui/flag-icons.tsx b/components/ui/flag-icons.tsx index e7127ec1..03d57b30 100644 --- a/components/ui/flag-icons.tsx +++ b/components/ui/flag-icons.tsx @@ -201,6 +201,16 @@ export function FlagCS(props: FlagProps) { ); } +/** Danish flag - Red with a white cross */ +export function FlagDa(props: FlagProps) { + return ( + + + + + ); +} + /** Map locale codes to flag components */ export const flagComponents: Record ReactElement> = { en: FlagGB, diff --git a/components/ui/language-switcher.tsx b/components/ui/language-switcher.tsx index 02f26028..0cb7ce9c 100644 --- a/components/ui/language-switcher.tsx +++ b/components/ui/language-switcher.tsx @@ -13,9 +13,10 @@ const languages = [ { value: 'fr', label: 'Français' }, { value: 'ja', label: '日本語' }, { value: 'ko', label: '한국어' }, + { value: 'da', label: 'Danish' }, + { value: 'de', label: 'Deutsch' }, { value: 'es', label: 'Español' }, { value: 'it', label: 'Italiano' }, - { value: 'de', label: 'Deutsch' }, { value: 'lv', label: 'Latviešu' }, { value: 'nl', label: 'Nederlands' }, { value: 'pl', label: 'Polski' }, diff --git a/i18n/request.ts b/i18n/request.ts index adcc39b2..2fe7fa26 100644 --- a/i18n/request.ts +++ b/i18n/request.ts @@ -17,6 +17,9 @@ export default getRequestConfig(async ({ requestLocale }) => { case 'fr': messages = (await import('../locales/fr/common.json')).default; break; + case 'da': + messages = (await import('../locales/da/common.json')).default; + break; case 'de': messages = (await import('../locales/de/common.json')).default; break; diff --git a/i18n/routing.ts b/i18n/routing.ts index adec38c1..fae1da1f 100644 --- a/i18n/routing.ts +++ b/i18n/routing.ts @@ -13,7 +13,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as | 'as-needed'; export const routing = defineRouting({ - locales: ['cs', 'en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'], + locales: ['cs', 'en', 'fr', 'da', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'], defaultLocale: 'en', localePrefix }); diff --git a/locales/da/common.json b/locales/da/common.json new file mode 100644 index 00000000..60431992 --- /dev/null +++ b/locales/da/common.json @@ -0,0 +1,2912 @@ +{ + "login": { + "title": "Webmail", + "username_label": "Email", + "username_placeholder": "bruger@eksempel.dk", + "password_label": "Adgangskode", + "password_placeholder": "Indtast din adgangskode", + "jmap_endpoint_label": "JMAP-server", + "jmap_endpoint_placeholder": "https://mail.eksempel.dk", + "jmap_endpoint_cors_hint": "Serveren skal tillade CORS-anmodninger fra dette domæne.", + "jmap_server_label": "Server", + "jmap_server_auto_picked": "Server valgt ud fra din e-maildomæne.", + "sign_in": "Log ind", + "signing_in": "Logger ind...", + "loading": "Indlæser...", + "reconnecting": "Forbindelse mistet. Forsøger at genoprette forbindelse…", + "error": { + "invalid_credentials": "Ugyldig e-mail eller adgangskode. Kontroller dine oplysninger og prøv igen.", + "connection_failed": "Kan ikke nå serveren. Kontrollér din internetforbindelse og prøv igen.", + "cors_blocked": "Serveren kan nås, men blokerer cross-origin-anmodninger. Kontrollér JMAP-serverens CORS-indstillinger og tillad dette domæne.", + "server_error": "Serveren er midlertidigt utilgængelig. Prøv igen senere.", + "generic": "Der opstod en uventet fejl. Kontakt din administrator, hvis problemet fortsætter.", + "totp_required": "En to-faktor-godkendelseskode er påkrævet. Indtast din kode nedenfor.", + "totp_invalid": "Ugyldig godkendelseskode. Kontrollér din autentificeringsapp og prøv igen.", + "oauth_discovery_failed": "SSO er aktiveret, men identitetsudbyderen kunne ikke nås. Kontrollér din OAuth-konfiguration." + }, + "show_password": "Vis adgangskode", + "hide_password": "Skjul adgangskode", + "totp_toggle": "Jeg har en 2FA-kode", + "remember_me": "Husk mig", + "config_error": { + "title": "Konfigurationsfejl", + "fetch_failed": "Kan ikke indlæse applikationskonfiguration. Prøv igen senere.", + "server_not_configured": "Mailserveren er ikke konfigureret. Kontakt din administrator." + }, + "remove_from_history": "Fjern fra historik", + "totp_label": "Godkendelseskode", + "totp_placeholder": "000000", + "session_expired": "Din session er udløbet. Log venligst ind igen.", + "dismiss": "Afvis", + "or": "eller", + "sign_in_sso": "Log ind med SSO", + "add_account_title": "Tilføj konto", + "add_account_subtitle": "Log ind med en anden konto", + "cancel": "Annuller", + "website": "Hjemmeside", + "imprint": "Impressum", + "privacy_policy": "Privatlivspolitik", + "try_demo": "Prøv demo", + "demo_description": "Udforsk med eksempeldata - ingen konto nødvendig", + "demo_launching": "Åbner demo...", + "demo_login_button": "Start demo", + "demo_tagline": "Oplev en fuldt udstyret e-mailklient. Ingen konto påkrævet.", + "demo_no_signup": "Ingen tilmelding nødvendig - udforsk frit med eksempeldata", + "oauth_completing": "Fuldfører login...", + "oauth_error": { + "title": "Godkendelse mislykkedes", + "invalid_state": "Sikkerhedsvalidering mislykkedes. Prøv at logge ind igen.", + "missing_params": "Manglende godkendelsesdata. Prøv at logge ind igen.", + "token_exchange_failed": "Kunne ikke fuldføre godkendelse. Prøv igen.", + "access_denied": "Adgang blev nægtet. Kontakt din administrator.", + "back_to_login": "Tilbage til login" + } + }, + "sidebar": { + "close": "Luk", + "compose": "Ny besked", + "compose_hint": "Ny besked (c)", + "search_placeholder": "Søg i mail...", + "search_placeholder_hint": "Søg i mail... (tryk /)", + "storage": "Lagerplads", + "storage_used": "Brugt", + "storage_free": "Ledig", + "storage_total": "I alt", + "sign_out": "Log ud", + "sign_out_of": "Log ud af {account}", + "sign_out_all": "Log ud af alle konti", + "add_account": "Tilføj konto", + "set_as_default": "Angiv som standard", + "switch_account": "Skift konto", + "contacts": "Kontakter", + "calendar": "Kalender", + "settings": "Indstillinger", + "admin": "Admin", + "files": "Filer", + "loading_mailboxes": "Indlæser postkasser...", + "push_connected": "Realtidsopdateringer aktive", + "push_disconnected": "Realtidsopdateringer inaktive", + "keyboard_shortcuts": "Tastaturgenveje", + "theme": { + "light": "Lys tilstand", + "dark": "Mørk tilstand", + "system": "Systemtema" + }, + "language": { + "title": "Sprog" + }, + "mailboxes": { + "inbox": "Indbakke", + "sent": "Sendt", + "drafts": "Kladder", + "trash": "Papirkurv", + "archive": "Arkiv", + "starred": "Stjernemarkerede", + "all_mail": "Al mail", + "spam": "Spam", + "important": "Vigtig" + }, + "unified_inbox": "Samlet indbakke", + "unified_sent": "Alle sendte", + "unified_drafts": "Alle kladder", + "unified_trash": "Alle papirkurve", + "unified_archive": "Alle arkiver", + "unified_junk": "Alt spam", + "all_accounts": "Alle konti", + "expand": "Udvid", + "collapse": "Skjul", + "expand_tooltip": "Udvid", + "collapse_tooltip": "Skjul", + "mobile": { + "search": "Søg", + "compose": "Ny besked", + "go_back": "Gå tilbage" + }, + "clear_search": "Ryd søgning", + "vacation_active": "Fraværsbeskeder er aktive", + "demo_banner": "Demo-tilstand", + "demo_reset": "Nulstil", + "demo_tour": "Rundvisning", + "tags": "Tags", + "folders": "Mapper", + "shared": "Delt", + "mail": "Mail", + "nav_label": "Navigation", + "add_app": "Apps" + }, + "protocol_handlers": { + "title": "Standardapps", + "description": "Vælg om e-mail- og kalenderlinks åbnes i Bulwark. Teknisk set registrerer Bulwark sig som protokolhåndtering for mailto:- og webcal:-links.", + "unsupported": "Denne browser eller forbindelse understøtter ikke manuel registrering af protokolhåndtering. Du kan muligvis stadig bruge den installerede PWA via browser- eller OS-indstillinger.", + "mailto_label": "E-mail-links", + "mailto_description": "Åbn mailto:-links i Bulwark med en forudfyldt komponist.", + "protocol_open_mode_label": "Ved åbning af protokollinks", + "protocol_open_mode_description": "Vælg om Bulwark åbner mailto:- og webcal:-links i en ny fane eller genbruger en åben session. Indstillingen \\\"aktiv session\\\" kræver notifikationstilladelse, så du kan klikke på en nødnotifikation for at bringe Bulwark frem, hvis browseren blokerer fokus.", + "protocol_open_mode_active_session": "Åbn i aktiv session hvis muligt", + "protocol_open_mode_new_tab": "Åbn altid ny fane", + "focus_notification_title": "Åbn Bulwark", + "focus_notification_body": "Linket blev åbnet i Bulwark. Klik for at bringe vinduet frem.", + "webcal_label": "Kalenderlinks", + "webcal_description": "Åbn webcal:-links i Bulwark med en forudfyldt kalenderabonnementsdialog.", + "register_mailto": "Registrer e-mail-app", + "register_webcal": "Registrer kalender-app", + "mailto_registered": "Registrering af e-mail-håndtering anmodet", + "webcal_registered": "Registrering af kalenderhåndtering anmodet", + "registration_failed": "Registrering af protokolhåndtering mislykkedes", + "opening_mailto": "Åbner komponist...", + "opening_webcal": "Åbner kalender...", + "browser_note": "Din browser eller dit operativsystem beder dig muligvis bekræfte dette og kræver muligvis, at Bulwark er installeret, før det kan vælges som standardapp.", + "select_account_title": "Vælg konto", + "select_mailto_account": "Vælg hvilken konto der skal åbne dette e-mail-link.", + "select_webcal_account": "Vælg hvilken konto der skal åbne dette kalenderlink.", + "select_account_note": "Dette gælder kun for dette protokollink.", + "detail_to": "Til", + "detail_subject": "Emne", + "detail_no_subject": "Intet emne", + "detail_calendar": "Kalender", + "detail_source": "Kilde", + "active_account": "Aktiv", + "switching_account": "Skifter konto..." + }, + "sidebar_apps": { + "modal_title": "Sidepanel-apps", + "add_new": "Tilføj app", + "edit_app": "Rediger app", + "name_label": "Navn", + "name_placeholder": "Min app", + "name_required": "Navn er påkrævet", + "url_label": "URL", + "url_required": "URL er påkrævet", + "url_invalid": "Indtast en gyldig http- eller https-URL", + "icon_label": "Ikon", + "icon_required": "Ikon er påkrævet", + "open_mode_label": "Åbningstilstand", + "open_new_tab": "Ny fane", + "open_inline": "Indlejret", + "cancel": "Annuller", + "add": "Tilføj", + "update": "Opdater", + "delete": "Slet", + "delete_confirm_title": "Slet app", + "delete_confirm": "Er du sikker på, at du vil slette \\\"{name}\\\"?", + "no_apps": "Ingen apps tilføjet endnu", + "no_apps_hint": "Tilføj brugerdefinerede apps og links til dit sidepanel", + "search_icons": "Søg efter ikoner...", + "show_popular": "Populære", + "show_all": "Alle", + "no_icons_found": "Ingen ikoner fundet", + "inline_badge": "Indlejret", + "tab_badge": "Fane", + "show_on_mobile": "Vis på mobil" + }, + "email_list": { + "no_emails": "Ingen beskeder fundet", + "no_emails_description": "Denne postkasse er tom", + "no_search_results": "Ingen resultater fundet", + "no_search_results_description": "Prøv at justere din søgning eller filtre", + "loading": "Indlæser e-mails...", + "unread": "ulæst", + "to_me": "Til mig", + "to_recipients": "Til {count} modtagere", + "and_others": "og {count} andre", + "draft": "Kladde", + "starred": "Stjernemarkerede", + "conversations_count": "{count} af {total} samtaler", + "conversations_count_plus": "{count}+ samtaler", + "conversations_count_simple": "{count} samtaler", + "no_conversations": "Ingen samtaler", + "loading_more": "Indlæser flere e-mails...", + "no_more_emails": "Ikke flere e-mails at indlæse", + "batch_actions": { + "select": "Vælg e-mails", + "select_all": "Vælg alle", + "selected_messages": "{count, plural, one {1 e-mail} other {# e-mails}} valgt", + "mark_read": "Markér som læst", + "mark_unread": "Markér som ulæst", + "delete": "Slet", + "delete_confirm_title": "Slet e-mails", + "delete_confirm_message": "Er du sikker på, at du vil slette {count, plural, one {1 e-mail} other {# e-mails}}?", + "clear_selection": "Ryd markering" + }, + "permanent_delete": "Slet permanent", + "permanent_delete_confirm_title": "Slet permanent", + "permanent_delete_confirm_message": "Denne e-mail vil blive slettet permanent. Denne handling kan ikke fortrydes.", + "permanent_delete_confirm_batch_message": "Disse {count, plural, one {1 e-mail} other {# e-mails}} vil blive slettet permanent. Denne handling kan ikke fortrydes.", + "empty_folder": { + "button": "Tøm mappe", + "confirm_title": "Tøm mappe", + "confirm_message": "Alle e-mails i denne mappe vil blive slettet permanent. Denne handling kan ikke fortrydes.", + "confirm_button": "Tøm mappe", + "junk_hint": "Du kan tømme spam-mappen for permanent at fjerne alle beskeder.", + "trash_hint": "Du kan tømme papirkurven for permanent at fjerne alle beskeder." + } + }, + "email_viewer": { + "no_email_selected": "Ingen e-mail valgt", + "no_email_description": "Vælg en e-mail fra listen for at se den her", + "no_conversation_selected": "Ingen samtale valgt", + "no_conversation_description": "Vælg en samtale fra listen for at læse den her", + "compose": "Ny besked", + "compose_hint": "Skriv ny besked", + "no_subject": "(Intet emne)", + "loading_email": "Indlæser e-mail...", + "loading": "Indlæser...", + "reply": "Svar", + "reply_all": "Svar alle", + "forward": "Videresend", + "delete": "Slet", + "archive": "Arkivér", + "star": "Stjernemarkér", + "unstar": "Fjern stjernemarkering", + "mark_unread": "Markér som ulæst", + "mark_read": "Markér som læst", + "unread": "Ulæst", + "read": "Læst", + "spam_short": "Spam", + "not_spam_short": "Ikke spam", + "move": "Flyt", + "print": "Udskriv", + "view_source": "Vis kilde", + "export_email": "Eksportér som .eml", + "import_email": "Importér .eml", + "keyboard_shortcuts": "Tastaturgenveje (?)", + "email_source": "E-mail-kilde", + "draft_banner": "Denne besked er en kladde", + "edit_draft": "Redigér", + "copy_source": "Kopier til udklipsholder", + "source_copied": "Kilde kopieret til udklipsholder", + "attachments": "Vedhæftninger", + "important": "Vigtig", + "download": "Download", + "download_all": "Download alle", + "from": "Fra", + "to": "Til", + "cc": "CC", + "bcc": "BCC", + "date": "Dato", + "subject": "Emne", + "show_details": "Vis detaljer", + "hide_details": "Skjul detaljer", + "external_content_warning": "Billeder og eksternt indhold er blevet blokeret", + "load_external_content": "Indlæs billeder", + "trust_sender": "Stol altid på denne afsender", + "back_to_list": "Tilbage til liste", + "view_contact": "Vis kontakt", + "message_details": "Beskeddetaljer", + "more_reply_options": "Flere svar-muligheder", + "set_color": "Sæt tag", + "tag": "Tag", + "more_actions": "Flere handlinger", + "previous": "Forrige", + "next": "Næste", + "move_to": "Flyt til...", + "remove_color": "Fjern tag", + "more_count": "+{count} mere", + "characters_count": "{count} tegn", + "quick_reply_placeholder": "Skriv et hurtigt svar...", + "more_options": "Flere muligheder", + "sending": "Sender...", + "security_authentication": "Sikkerhed & godkendelse", + "technical_details": "Tekniske detaljer", + "message_id_label": "Message-ID:", + "reply_to_label": "Svar-til:", + "delivery_time_label": "Leveringstid:", + "conversation_part_label": "Del af samtale:", + "previous_messages": "{count} tidligere besked", + "previous_messages_plural": "{count} tidligere beskeder", + "time": { + "day": "dag", + "days": "dage", + "hour": "time", + "hours": "timer", + "minute": "minut", + "minutes": "minutter" + }, + "unknown_sender": "Ukendt", + "recipient_me": "mig", + "recipient_and_others": "{name} og {count} andre", + "recipient_to_prefix": "Til:", + "authentication": { + "title": "Godkendelse", + "status": { + "verified": "Bekræftet", + "warning": "Advarsel", + "none": "Ikke godkendt" + }, + "spf": { + "pass": "SPF bestået", + "fail": "SPF fejlet", + "none": "Ingen SPF" + }, + "dkim": { + "pass": "DKIM gyldig", + "fail": "DKIM ugyldig", + "none": "Ingen DKIM" + }, + "dmarc": { + "pass": "DMARC bestået", + "fail": "DMARC fejlet", + "none": "Ingen DMARC" + }, + "spam_score": "Spam-score", + "tooltip_spf": "Sender Policy Framework: Bekræfter at afsendelsesserveren har tilladelse til at sende e-mails på vegne af domænet", + "tooltip_dkim": "DomainKeys Identified Mail: Bekræfter at e-mailen ikke er ændret under transport ved hjælp af en kryptografisk signatur", + "tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: Sikrer at SPF og DKIM er i overensstemmelse med afsenderens domæne og fastsætter en politik for fejl", + "policy": "Politik", + "result": { + "pass": "Bestået", + "fail": "Fejlet", + "softfail": "Soft fejl", + "neutral": "Neutral", + "permerror": "Permanent fejl", + "temperror": "Midlertidig fejl", + "none": "Ingen" + } + }, + "details": { + "recipients_routing": "Modtagere & routing", + "authentication_security": "Godkendelse & sikkerhed", + "identifiers_threading": "Identifikatorer & trådning", + "mailing_list": "Mailingliste", + "message_properties": "Beskedegenskaber", + "sent": "Sendt", + "received": "Modtaget", + "delivery_time": "Leveringstid", + "in_reply_to": "Svar-til", + "references": "Referencer", + "thread_id": "Tråd-ID", + "size": "Størrelse", + "mime_type": "MIME-type", + "attachments_summary": "{count} filer · {size}", + "list_id": "Liste-ID", + "list_help": "Listehjælp", + "list_post": "Listeopslag", + "list_unsubscribe": "Afmeld", + "iprev": "Omvendt DNS", + "spam_status": "Spam-status", + "ai_verdict": "AI-vurdering", + "account": "Konto", + "no_subject": "(intet emne)" + }, + "headers": { + "routing": "Routing", + "received": "Modtaget", + "message_id": "Besked-ID", + "list_info": "Listeinformation" + }, + "color_tag": { + "title": "Farvetag", + "red": "Rød", + "orange": "Orange", + "yellow": "Gul", + "green": "Grøn", + "blue": "Blå", + "purple": "Lilla", + "pink": "Pink", + "none": "Ingen" + }, + "tooltips": { + "reply": "Svar (r)", + "reply_all": "Svar alle (a)", + "forward": "Videresend (f)", + "archive": "Arkivér (e)", + "delete": "Slet (# eller Del)", + "star": "Stjernemarkér (s)", + "unstar": "Fjern stjerne (s)", + "compose": "Ny besked (c)", + "previous": "Forrige e-mail", + "next": "Næste e-mail", + "edit_draft": "Redigér kladde" + }, + "spam": { + "button_title": "Rapportér spam", + "not_spam_title": "Markér som legitim", + "toast_success": "Flyttet til spam", + "toast_batch": "{count} e-mails flyttet til spam", + "toast_undo": "Fortryd", + "toast_not_spam_success": "Flyttet til indbakke", + "toast_not_spam_batch": "{count} e-mails flyttet til indbakke", + "error": "Kunne ikke rapportere spam", + "error_not_spam": "Kunne ikke gendanne e-mail" + }, + "unsubscribe_banner": { + "label": "Nyhedsbrev", + "button": "Afmeld", + "confirm_title": "Afmeld denne afsender?", + "confirm_button": "Bekræft", + "cancel": "Annuller", + "success_http": "Afmeldingsside åbnet i ny fane", + "success_mailto": "Afmeldingsanmodning sendt til din e-mailklient", + "error": "Kan ikke afmelde", + "dismiss": "Afvis" + }, + "calendar_invitation": { + "loading": "Indlæser begivenhedsdetaljer…", + "title": "Kalenderinvitation", + "published_title": "Offentliggjort begivenhed", + "response_title": "Begivenhedssvar", + "update_title": "Begivenhedsopdatering", + "counter_title": "Modforslag", + "refresh_title": "Opdateringsanmodning", + "declined_counter_title": "Modforslag afvist", + "cancelled_title": "Begivenhed aflyst", + "organizer": "Organiseret af {name}", + "organizer_label": "Organiseret af", + "attendees": "{count, plural, one {# deltager} other {# deltagere}}", + "accept": "Accepter", + "maybe": "Måske", + "decline": "Afvis", + "add_to_calendar": "Tilføj til kalender", + "added": "Tilføjet til kalender", + "rsvp_sent": "Svar sendt", + "parse_error": "Kunne ikke læse invitationen", + "action_failed": "Kunne ikke udføre denne kalenderhandling.", + "no_calendar": "Kalender ikke tilgængelig", + "published_info": "Denne begivenhed blev delt til reference.", + "response_info": "Denne besked indeholder et deltagersvar.", + "response_info_organizer": "Dette deltagersvar opdaterer din begivenhed.", + "update_info": "Denne besked opdaterer en eksisterende begivenhed.", + "counter_info": "Denne besked foreslår ændringer til en begivenhed.", + "counter_info_organizer": "Denne deltager foreslog ændringer til din begivenhed.", + "refresh_info": "Denne besked anmoder om de seneste begivenhedsdetaljer.", + "refresh_info_organizer": "En deltager anmodede om de seneste begivenhedsdetaljer.", + "declined_counter_info": "Organisatoren afviste et modforslag.", + "authentication_failed_info": "Mailgodkendelseskontrol for denne invitation mislykkedes. Vær forsigtig med kalenderhandlinger.", + "authentication_missing_info": "Denne invitation inkluderer ikke bekræftet mailgodkendelse. Bekræft detaljerne med organisatoren, hvis noget ser usædvanligt ud.", + "sender_mismatch_info": "Denne invitation blev sendt fra {sender}, mens organisatoren i kalenderdataene er {organizer}.", + "sender_mismatch_unverified_info": "Denne invitation blev sendt fra {sender}, mens organisatoren i kalenderdataene er {organizer}, og beskeden kunne ikke bekræftes.", + "organizer_role": "Du organiserer denne begivenhed", + "your_response": "Dit svar: {status}", + "response_needed": "Kræver svar", + "response_accepted": "Accepteret", + "response_tentative": "Foreløbig", + "response_declined": "Afslået", + "response_delegated": "Delegeret", + "actor_sent_info": "Sendt af {name}.", + "actor_response_info": "{name} svarede {status}.", + "actor_counter_info": "{name} foreslog ændringer til denne begivenhed.", + "actor_refresh_info": "{name} bad om de seneste begivenhedsdetaljer.", + "actor_declined_counter_info": "{name} afviste modforslaget.", + "actor_note": "Note: {comment}", + "actor_unknown": "Nogen", + "proposed_changes": "Foreslåede ændringer", + "change_title": "Titel", + "change_time": "Tid", + "change_location": "Sted", + "change_description": "Beskrivelse", + "change_empty": "Ingen", + "change_from_to": "{before} -> {after}", + "apply_proposal": "Anvend foreslåede ændringer", + "proposal_applied": "Foreslåede ændringer anvendt.", + "review_proposal": "Gennemgå forslag", + "review_request": "Gennemgå anmodning", + "view_in_calendar": "Vis i kalender", + "select_calendar": "Vælg kalender", + "already_in_calendar": "Allerede i din kalender", + "request_info": "Du er blevet inviteret til denne begivenhed. Svar for at lade arrangøren kende din tilgængelighed.", + "cancel_info": "Arrangøren har aflyst denne begivenhed.", + "event_updated": "Opdatering #{sequence}", + "event_status_tentative": "Foreløbig", + "event_status_cancelled": "Aflyst", + "expand": "Vis detaljer", + "collapse": "Skjul detaljer" + }, + "send": "Send", + "more": "mere" + }, + "email_composer": { + "new_message": "Ny besked", + "reply": "Svar", + "reply_all": "Svar alle", + "forward": "Videresend", + "reply_to": "Svar", + "reply_all_to": "Svar alle", + "forward_message": "Videresend", + "from": "Fra", + "to": "Til", + "cc": "CC", + "bcc": "BCC", + "subject": "Emne", + "body_placeholder": "Skriv din besked...", + "send": "Send", + "cancel": "Annuller", + "attach": "Vedhæft", + "attach_photos": "Fotos & videoer", + "attach_files": "Filer", + "discard": "Kassér", + "discard_draft_title": "Kassér kladde?", + "discard_draft_confirm": "Du har ugemte ændringer. Vil du kassere denne kladde?", + "saving": "Gemmer...", + "sending": "Sender...", + "add_link": "Tilføj link", + "draft_saved": "Kladde gemt", + "save_failed": "Kunne ikke gemme", + "to_placeholder": "Modtageres e-mailadresser", + "cc_placeholder": "Cc-modtagere", + "bcc_placeholder": "Bcc-modtagere", + "subject_placeholder": "Emne", + "cc_label": "Cc:", + "bcc_label": "Bcc:", + "subject_label": "Emne:", + "file_size_kb": "KB", + "prefix": { + "forward": "Vs:", + "reply": "Sv:" + }, + "no_subject": "(Intet emne)", + "unknown_sender": "Ukendt", + "quote": { + "reply_header": "Den {date} skrev {sender}:", + "forward_header": "---------- Videresendt besked ----------", + "from": "Fra: {sender}", + "date": "Dato: {date}", + "subject": "Emne: {subject}", + "to": "Til: {recipients}" + }, + "remove_sub_address": "Fjern underadresse", + "from_override": { + "toggle_off": "Tilsidesæt", + "toggle_on": "Annuller tilsidesættelse", + "toggle_tooltip": "Redigér Fra-navn og -adresse frit. Mail sendes stadig gennem din identitet - kun den synlige Fra-header ændres.", + "name_label": "Fra-navn", + "name_placeholder": "Navn", + "email_label": "Fra-e-mailadresse", + "email_placeholder": "alias@eksempel.dk" + }, + "use_template": "Skabelon", + "save_as_template": "Gem som skabelon", + "validation": { + "recipient_required": "Tilføj en modtager for at sende", + "subject_required": "Tilføj et emne", + "body_required": "Skriv en besked eller vedhæft en fil" + }, + "upload_progress": "Uploader {uploaded} / {total}", + "upload_cancel": "Annuller upload", + "upload_failed": "Kunne ikke uploade {filename}", + "drop_files": "Træk filer hertil for at vedhæfte", + "show_less": "Vis mindre", + "send_failed": "Kunne ikke sende e-mail", + "continue_draft": "Fortsæt kladde", + "close_draft_title": "Gem eller kassér kladde?", + "close_draft_message": "Du har ugemte ændringer. Vil du gemme dette som en kladde eller kassere det?", + "save_draft": "Gem kladde", + "smime_sign_on": "S/MIME-signering aktiveret", + "smime_sign_off": "Aktivér S/MIME-signering", + "smime_encrypt_on": "S/MIME-kryptering aktiveret", + "smime_encrypt_off": "Aktivér S/MIME-kryptering", + "smime_encrypt_unavailable": "S/MIME-kryptering utilgængelig – mangler modtager-certifikater", + "smime_unlock_title": "Lås S/MIME-nøgle op", + "smime_unlock_message": "Indtast adgangssætningen for at låse din S/MIME-signeringsnøgle op.", + "smime_unlock_button": "Lås op", + "smime_passphrase_placeholder": "Adgangssætning", + "forgot_attachment": { + "title": "Har du glemt en vedhæftning?", + "message": "Din besked nævner \"{keyword}\" men ingen fil er vedhæftet. Send alligevel?", + "send_anyway": "Send alligevel", + "back": "Tilbage til redigering" + }, + "link_url_prompt": "Indtast URL'en" + }, + "confirm_dialog": { + "confirm": "Bekræft", + "cancel": "Annuller" + }, + "common": { + "loading": "Indlæser...", + "error": "Fejl", + "success": "Succes", + "cancel": "Annuller", + "save": "Gem", + "delete": "Slet", + "edit": "Redigér", + "close": "Luk", + "search": "Søg", + "refresh": "Opdatér", + "settings": "Indstillinger", + "help": "Hjælp", + "logout": "Log ud", + "yes": "Ja", + "no": "Nej", + "unknown": "Ukendt", + "app_title": "Webmail", + "reconnecting": "Forbindelse mistet. Forsøger at genoprette forbindelse…", + "rate_limited_title": "Server-godkendelse er midlertidigt hastighedsbegrænset.", + "rate_limited_detail": "Bulwark har sat baggrundsanmodninger på pause for at undgå udelukkelse. Prøver igen om {seconds}s.", + "rate_limited_action_title": "Anmodning sat på pause for at undgå udelukkelse.", + "rate_limited_action_detail": "Bulwark venter på, at serverens nedkølingsperiode slutter, før der sendes flere godkendte anmodninger. Prøv igen om {seconds}s." + }, + "notifications": { + "email_sent": "E-mail sendt succesfuldt", + "email_deleted": "E-mail slettet", + "email_archived": "E-mail arkiveret", + "email_starred": "E-mail stjernemarkeret", + "email_unstarred": "Stjernemarkering fjernet", + "email_marked_read": "E-mail markeret som læst", + "email_marked_unread": "E-mail markeret som ulæst", + "copied_to_clipboard": "Kopieret til udklipsholder", + "source_copied": "Kilde kopieret til udklipsholder", + "error_sending": "Kunne ikke sende e-mail", + "error_deleting": "Kunne ikke slette e-mail", + "error_loading": "Kunne ikke indlæse e-mails", + "new_email": "Ny e-mail", + "new_email_from": "Fra {sender}", + "click_to_view": "Klik for at se", + "email_moved": "E-mail flyttet", + "emails_moved": "{count} e-mails flyttet", + "moved_to_mailbox": "Flyttet til {mailbox}", + "move_failed": "Flytning mislykkedes", + "move_error": "Kunne ikke flytte e-mails til den valgte mappe", + "email_tagged": "E-mail tagget", + "emails_tagged": "{count} e-mails tagget", + "tag_failed": "Tagning mislykkedes", + "identity_created": "Identitet oprettet succesfuldt", + "identity_updated": "Identitet opdateret succesfuldt", + "identity_deleted": "Identitet slettet", + "identity_set_primary": "Primær identitet opdateret", + "identity_create_failed": "Kunne ikke oprette identitet: {error}", + "identity_update_failed": "Kunne ikke opdatere identitet: {error}", + "identity_delete_failed": "Kunne ikke slette identitet: {error}", + "identity_unauthorized": "Du er ikke autoriseret til at sende fra denne e-mailadresse", + "identity_not_found": "Identitet ikke fundet", + "vacation_saved": "Fraværsbesked-indstillinger gemt", + "vacation_save_failed": "Kunne ikke gemme fraværsbesked-indstillinger", + "filters_saved": "Filtre gemt succesfuldt", + "filters_save_failed": "Kunne ikke gemme filtre", + "filters_deleted": "Filterregel slettet", + "templates_exported": "Skabeloner eksporteret succesfuldt", + "templates_imported": "{count, plural, one {# skabelon} other {# skabeloner}} importeret", + "templates_import_errors": "Nogle skabeloner kunne ikke importeres", + "templates_import_empty": "Ingen skabeloner fundet i filen", + "export_email_error": "Kunne ikke eksportere e-mail", + "import_email_success": "E-mail importeret succesfuldt", + "import_email_error": "Kunne ikke importere e-mail" + }, + "date": { + "today": "I dag", + "yesterday": "I går", + "this_week": "Denne uge", + "last_week": "Sidste uge", + "this_month": "Denne måned", + "older": "Ældre", + "just_now": "Lige nu", + "minutes_ago": "{count} minut siden", + "minutes_ago_plural": "{count} minutter siden", + "hours_ago": "{count} time siden", + "hours_ago_plural": "{count} timer siden", + "days_ago": "{count} dag siden", + "days_ago_plural": "{count} dage siden" + }, + "language": { + "title": "Sprog", + "english": "English", + "french": "Français", + "japanese": "日本語", + "spanish": "Español", + "italian": "Italiano", + "german": "Deutsch", + "dutch": "Nederlands", + "portuguese": "Português", + "russian": "Русский", + "select_language": "Vælg sprog", + "switch_to_english": "Skift til engelsk", + "switch_to_french": "Skift til fransk", + "switch_to_japanese": "Skift til japansk", + "switch_to_spanish": "Skift til spansk", + "switch_to_italian": "Skift til italiensk", + "switch_to_german": "Skift til tysk", + "switch_to_dutch": "Skift til nederlandsk", + "switch_to_portuguese": "Skift til portugisisk", + "switch_to_russian": "Skift til russisk", + "switching": "Skifter sprog...", + "switch": "Skift sprog", + "current": "Nuværende sprog", + "polish": "Polski", + "switch_to_polish": "Skift til polsk", + "en": "English", + "fr": "Français", + "de": "Deutsch", + "es": "Español", + "it": "Italiano", + "ja": "日本語", + "ko": "한국어", + "lv": "Latviešu", + "nl": "Nederlands", + "pl": "Polski", + "pt": "Português", + "ru": "Русский", + "uk": "Українська", + "zh": "简体中文" + }, + "settings": { + "title": "Indstillinger", + "back_to_mail": "Tilbage til mail", + "save_success": "Indstillinger gemt succesfuldt", + "import_success": "Indstillinger importeret succesfuldt", + "import_error": "Kunne ikke importere indstillinger", + "reset_confirm": "Er du sikker på, at du vil nulstille alle indstillinger til standard?", + "unsaved_changes": "Du har ugemte ændringer", + "discard_changes": "Kassér ugemte ændringer?", + "discard": "Kassér", + "keep_editing": "Fortsæt redigering", + "search_placeholder": "Søg i indstillinger", + "search_clear": "Ryd søgning", + "search_no_results": "Ingen match i indstillinger", + "tabs": { + "appearance": "Udseende", + "language": "Sprog & region", + "email": "E-mail-adfærd", + "composer": "Komponist", + "privacy": "Privatliv & sikkerhed", + "account": "Konto", + "identities": "Identiteter", + "vacation": "Fraværsbeskeder", + "advanced": "Avanceret", + "calendar": "Kalender", + "filters": "Filtre", + "templates": "Skabeloner", + "folders": "Mapper", + "keywords": "Tags", + "security": "Sikkerhed", + "files": "Filer", + "contacts": "Kontakter", + "encryption": "Kryptering", + "protocol_handlers": "Standardapps", + "sidebar_apps": "Sidepanel-apps", + "notifications": "Notifikationer", + "layout": "Layout", + "reading": "Læsning", + "composing": "Skrivning", + "content_senders": "Indhold & afsendere", + "about_data": "Om & data", + "debug": "Debug" + }, + "tab_groups": { + "general": "Generelt", + "account": "Konto & identitet", + "organization": "Mailorganisering", + "apps": "Apps", + "system": "System", + "appearance": "Udseende", + "mail": "Mail", + "privacy": "Privatliv & sikkerhed", + "advanced": "Avanceret" + }, + "appearance": { + "title": "Udseende", + "description": "Tilpas udseendet af din webmail", + "theme": { + "label": "Tema", + "description": "Vælg dit foretrukne farveskema", + "light": "Lys", + "dark": "Mørk", + "system": "System" + }, + "language": { + "label": "Sprog", + "description": "Vælg dit foretrukne sprog" + }, + "font_size": { + "label": "Skriftstørrelse", + "description": "Juster tekststørrelse for bedre læsbarhed", + "small": "Lille", + "medium": "Mellem", + "large": "Stor" + }, + "list_density": { + "label": "Tæthed", + "description": "Kontrollér afstand og polstring i brugergrænsefladen", + "extra_compact": "Ekstra kompakt", + "compact": "Kompakt", + "regular": "Almindelig", + "comfortable": "Komfortabel" + }, + "animations": { + "label": "Aktivér animationer", + "description": "Vis glatte overgange og effekter" + }, + "toolbar_position": { + "label": "Værktøjslinjens position", + "description": "Hvor e-mail-handlingsknapper (Svar, Arkivér, Slet osv.) skal vises", + "top": "Top", + "below_subject": "Under emne" + }, + "toolbar_labels": { + "label": "Vis værktøjslinjeetiketter", + "description": "Vis tekstetiketter ved siden af værktøjslinjeikoner. Deaktivér for at spare plads, når du kender ikonerne." + }, + "hide_account_switcher": { + "label": "Skjul kontoskifter i sidepanel", + "description": "Skjul kontovælgeren øverst i mappesidepanelet. Du kan stadig skifte konto fra navigationslinjen nederst." + }, + "show_rail_account_list": { + "label": "Vis kontoavatarer på navigationslinje", + "description": "Vis individuelle kontocirkler nederst på navigationslinjen for hurtig skift, med en log-ud-knap nedenunder." + }, + "unified_mailbox": { + "label": "Samlet postkasse", + "description": "Vis samlede mapper (Indbakke, Sendt osv.) på tværs af alle tilknyttede konti" + }, + "colorful_sidebar_icons": { + "label": "Farverige sidepane-ikoner", + "description": "Farvelæg mappe- og tag-ikoner efter type (blå indbakke, rød spam, grøn sendt osv.). Deaktivér for et monokromt sidepanel." + } + }, + "keywords": { + "title": "E-mail-tags", + "description": "Definér tags til at organisere dine e-mails med farver. Disse gemmes som JMAP-nøgleord på serveren.", + "add_keyword": "Tilføj tag", + "reset_defaults": "Nulstil til standard", + "label_field": "Visningsnavn", + "label_placeholder": "f.eks. Arbejde, Privat, Vigtigt", + "id_field": "Tag-ID", + "id_placeholder": "f.eks. arbejde, privat", + "color_field": "Farve", + "id_exists": "Dette tag-ID findes allerede", + "edit": "Redigér tag", + "delete": "Slet tag", + "save": "Gem", + "add": "Tilføj", + "cancel": "Annuller", + "migrating": "Opdaterer tag på eksisterende e-mails…", + "migration_error": "Kunne ikke opdatere tag på eksisterende e-mails" + }, + "notifications": { + "test_sound": "Test notifikationslyd", + "sounds": { + "default": "Standard (Bip)", + "cheerful": "Glad", + "involved": "Engageret", + "swift": "Hurtig gestus", + "relax": "Afslapning" + }, + "push": { + "title": "Baggrundsnotifikationer", + "description": "Modtag systemnotifikationer for ny mail, når dette site er lukket. Leveres via Bulwark push-relæet; relæet ser aldrig mail-indhold.", + "relay_label": "Push-relæ", + "relay_desc": "Som standard det hosted Bulwark-relæ. Ændr kun, hvis du selv-host'er.", + "relay_placeholder": "https://notifikationer.relay.eksempel.dk", + "status_active": "Aktiv på denne enhed", + "status_inactive": "Ikke aktiveret på denne enhed", + "status_unsupported": "Denne browser understøtter ikke Web Push", + "status_busy": "Arbejder…", + "enable": "Aktivér", + "reenable": "Genregistrér", + "disable": "Deaktivér", + "confirm_disable_title": "Deaktivér baggrundsnotifikationer?", + "confirm_disable_message": "Denne enhed stopper med at modtage alarmer, når sitet er lukket.", + "ios_hint": "På iOS skal du installere sitet på din startskærm først - Safari leverer kun Web Push til installerede PWA'er." + }, + "sound_selection": { + "title": "Notifikationslyd", + "description": "Vælg hvilken lyd der skal afspilles for notifikationer", + "choose": "Lyd", + "choose_desc": "Vælg en notifikationstone og klik på højttalerikonet for at forhåndsvise den" + }, + "email": { + "title": "E-mail-notifikationer", + "description": "Konfigurér notifikationer for indkommende e-mails", + "enabled": "E-mail-notifikationer", + "enabled_desc": "Vis notifikationer, når nye e-mails ankommer", + "sound": "Notifikationslyd", + "sound_desc": "Afspil en lydadvarsel, når nye e-mails ankommer" + }, + "calendar": { + "title": "Kalendernotifikationer", + "description": "Konfigurér notifikationer for kalenderbegivenheder", + "enabled": "Begivenhedsnotifikationer", + "enabled_desc": "Vis alarmer for kommende kalenderbegivenheder", + "sound": "Notifikationslyd", + "sound_desc": "Afspil en lyd for kalenderpåmindelser", + "invitation_parsing": "Pars e-mail-invitationer", + "invitation_parsing_desc": "Registrér kalenderinvitationer i e-mail-vedhæftninger og vis kalenderhandlinger" + } + }, + "language_region": { + "title": "Sprog & region", + "description": "Konfigurér sprog og regionale præferencer", + "language": { + "label": "Sprog", + "description": "Vælg dit foretrukne sprog", + "english": "English", + "french": "Français" + }, + "date_format": { + "label": "Datoformat", + "description": "Hvordan datoer skal vises", + "regional": "Regionalt", + "iso": "ISO 8601", + "custom": "Brugerdefineret" + }, + "time_format": { + "label": "Tidsformat", + "description": "Vælg mellem 12-timers eller 24-timers ur", + "12h": "12-timers", + "24h": "24-timers" + }, + "first_day": { + "label": "Første ugedag", + "description": "Start ugen på søndag eller mandag", + "sunday": "Søndag", + "monday": "Mandag" + } + }, + "email_behavior": { + "title": "E-mail-adfærd", + "description": "Konfigurér hvordan e-mails håndteres", + "mark_read": { + "label": "Markér som læst", + "description": "Hvornår e-mails skal markeres som læst, når de åbnes", + "instant": "Øjeblikkeligt", + "delay_3s": "Efter 3 sekunder", + "delay_5s": "Efter 5 sekunder", + "never": "Aldrig" + }, + "delete_action": { + "label": "Slet-handling", + "description": "Hvad der sker, når du sletter en e-mail", + "trash": "Flyt til papirkurv", + "permanent": "Slet permanent", + "warning": "E-mails vil blive slettet permanent og kan ikke gendannes. Denne handling er irreversibel." + }, + "archive_mode": { + "label": "Arkivér i", + "description": "Hvordan e-mails organiseres ved arkivering", + "single": "En enkelt mappe", + "year": "En mappe pr. år", + "month": "En mappe pr. måned", + "reorganize": "Omorganisér eksisterende arkiv", + "reorganize_success": "{count, plural, =0 {Ingen e-mails at omorganisere} =1 {1 e-mail omorganiseret} other {# e-mails omorganiseret}}", + "reorganize_error": "Kunne ikke omorganisere arkiv" + }, + "permanently_delete_junk": { + "label": "Slet spam permanent", + "description": "Slet e-mails fra spam-mappen permanent i stedet for at flytte dem til papirkurven" + }, + "mail_layout": { + "label": "Mail-layout", + "description": "Vælg mellem den klassiske opdelte læserude, en Gmail-lignende fokuseret læsestrøm eller en Zimbra-lignende læserude i bunden.", + "split": "Opdelt rude", + "split_description": "Hold beskedlisten og læseruden synlige side om side.", + "focus": "Fokuseret liste", + "focus_description": "Vis én linje pr. besked og åbn mail i fuld bredde, mens mappesidepanelet forbliver synligt.", + "horizontal": "Læserude i bunden", + "horizontal_description": "Vis beskedlisten øverst og åbn den valgte besked i en læserude nedenunder." + }, + "show_preview": { + "label": "Vis forhåndsvisningstekst", + "description": "Vis e-mail-forhåndsvisning i listen", + "focus_description": "Vis indlejret forhåndsvisningstekst inde i den fokuserede én-linjers beskedliste" + }, + "disable_threading": { + "label": "Deaktivér samtale-gruppering", + "description": "Vis e-mails som individuelle beskeder i stedet for grupperet efter samtale" + }, + "plain_text_mode": { + "label": "Kun almindelig tekst", + "description": "Deaktivér den rige teksteditor og send alle e-mails som ren tekst, inklusive svar og videresendelser" + }, + "auto_select_reply_identity": { + "label": "Svar fra modtaget adresse", + "description": "Når du svarer, send fra den adresse som beskeden oprindeligt blev sendt til. Matcher først identiteter; for domæne catch-all-leveringer omskriver Fra-headeren til aliaset, mens der sendes gennem din primære identitet." + }, + "signature_position": { + "label": "Signaturplacering", + "description": "Hvor din signatur indsættes i svar og videresendelser. Før citeret tekst læses naturligt som en afslutning på svaret; under bevarer den oprindelige besked sammenhængende.", + "above_quote": "Før citeret tekst", + "below_quote": "Efter citeret tekst" + }, + "signature_separator": { + "label": "Signatur-afgrænser", + "description": "Præfiksér signaturen med standard \"-- \"-afgrænserlinjen (RFC 3676). Slå fra, hvis du hellere vil gå direkte fra din besked til signaturen." + }, + "sub_address_delimiter": { + "label": "Underadresse-afgrænser", + "description": "Tegn der adskiller dit brugernavn fra et underadresse-tag. Match afgrænseren som din mailserver bruger (f.eks. bruger{delimiter}tag@domæne.dk).", + "option": "{delimiter} (bruger{delimiter}tag@domæne.dk)", + "custom": "Brugerdefineret…", + "custom_input_label": "Brugerdefineret afgrænsertegn" + }, + "attachment_click_action": { + "label": "Klik-handling for vedhæftninger", + "description": "Vælg om et klik på en vedhæftet fil forhåndsviser den eller downloader den med det samme", + "preview": "Forhåndsvis når muligt", + "download": "Download med det samme" + }, + "attachment_position": { + "label": "Placering af vedhæftninger", + "description": "Hvor vedhæftninger skal vises i e-mail-headeren", + "beside-sender": "Ved siden af afsender", + "below-header": "Under header" + }, + "emails_per_page": { + "10": "10 e-mails", + "25": "25 e-mails", + "50": "50 e-mails", + "100": "100 e-mails", + "label": "E-mails pr. side", + "description": "Antal e-mails der indlæses ad gangen" + }, + "always_light_mode": { + "label": "Vis altid e-mails i lys tilstand", + "description": "Vis e-mail-indhold i lys tilstand, selv når appen er i mørk tilstand, for at undgå problemer med mørk tilstand-konvertering" + }, + "external_content": { + "label": "Eksternt indhold", + "description": "Hvordan billeder og eksternt indhold håndteres", + "ask": "Spørg altid", + "block": "Bloker altid", + "allow": "Tillad altid" + }, + "trusted_senders": { + "label": "Betroede afsendere", + "description": "Administrér afsendere hvis billeder indlæses automatisk", + "count_zero": "Ingen", + "count_one": "1 afsender", + "count_other": "{count} afsendere", + "modal_title": "Betroede afsendere", + "empty_title": "Ingen betroede afsendere endnu", + "empty_description": "Når du ser en e-mail med blokerede billeder, klik på \"Stol altid på denne afsender\" for at tilføje dem her.", + "add_manually": "Tilføj afsender manuelt", + "add_button": "Tilføj", + "add_placeholder": "Indtast e-mailadresse", + "search_placeholder": "Søg efter afsendere...", + "no_results": "Ingen afsendere matcher din søgning", + "remove": "Fjern", + "close": "Luk", + "invalid_email": "Indtast en gyldig e-mailadresse", + "already_added": "Denne afsender er allerede betroet", + "save_error": "Kunne ikke gemme - tjek Kontakter-debugloggen for detaljer", + "use_address_book_label": "Synkronisér med adressebog", + "use_address_book_description": "Gem betroede afsendere i en dedikeret \"Betroede afsendere\"-adressebog, så de synkroniseres på tværs af alle dine enheder" + }, + "hover_actions": { + "label": "Hurtige hover-handlinger", + "description": "Vælg hvilke hurtige handlinger der vises, når du holder musen over en e-mail i listen", + "delete": "Slet", + "star": "Stjernemarkér / Fjern stjerne", + "mark_read": "Markér læst/ulæst", + "archive": "Arkivér", + "tag": "Tag", + "spam": "Markér som spam", + "none_selected": "Ingen handlinger valgt", + "mode_label": "Visningstilstand", + "mode_inline": "Indlejret", + "mode_floating": "Flydende", + "corner_label": "Flydende position", + "corner_top-left": "Øverst til venstre", + "corner_top-right": "Øverst til højre", + "corner_bottom-left": "Nederst til venstre", + "corner_bottom-right": "Nederst til højre" + }, + "default_mail_program": { + "label": "Standard mailprogram", + "description": "Registrér {appName} som dit standard mailprogram for mailto:-links", + "button": "Angiv som standard", + "success": "Browseren blev bedt om at angive som standard", + "error": "Din browser understøtter ikke denne funktion" + }, + "attachment_reminder": { + "label": "Påmindelse om vedhæftning", + "description": "Advar før afsendelse, når din besked nævner vedhæftninger, men ingen er vedhæftet", + "keywords_label": "Udløsende nøgleord", + "keywords_description": "Ord eller sætninger der udløser påmindelsen, når de findes i din besked", + "add_placeholder": "Tilføj nøgleord...", + "add": "Tilføj", + "remove": "Fjern" + }, + "hide_inline_image_attachments": { + "label": "Skjul indlejrede billeder fra vedhæftninger", + "description": "Billeder indlejret i beskedteksten vises ikke som separate vedhæftninger" + }, + "attachment_image_previews": { + "label": "Vis billedforhåndsvisninger i vedhæftninger", + "description": "Vis billedvedhæftninger som miniaturekort i stedet for generiske filikoner" + } + }, + "composer": { + "title": "Komponist", + "description": "Konfigurér indstillinger for e-mail-komposition", + "autosave": { + "label": "Auto-gem interval", + "description": "Hvor ofte kladder gemmes automatisk", + "30s": "Hvert 30. sekund", + "1m": "Hvert minut", + "2m": "Hvert 2. minut", + "5m": "Hvert 5. minut" + }, + "send_confirmation": { + "label": "Send-bekræftelse", + "description": "Spørg om bekræftelse før afsendelse af e-mails" + }, + "default_reply": { + "label": "Standard svar-tilstand", + "description": "Standardhandling ved klik på svar", + "reply": "Svar", + "reply_all": "Svar alle" + } + }, + "privacy": { + "title": "Privatliv & sikkerhed", + "description": "Administrér dine privatlivs- og sikkerhedsindstillinger", + "external_images": { + "label": "Bloker eksterne billeder", + "description": "Forhindr sporing via eksterne billeder" + }, + "session_timeout": { + "label": "Sessionstimeout", + "description": "Log automatisk ud efter inaktivitet", + "never": "Aldrig", + "30m": "30 minutter", + "1h": "1 time", + "4h": "4 timer" + }, + "clear_cache": { + "label": "Ryd cache", + "description": "Fjern cached data og midlertidige filer", + "button": "Ryd cache", + "confirm": "Er du sikker på, at du vil rydde cachen?", + "success": "Cache ryddet succesfuldt" + } + }, + "account": { + "title": "Konto", + "description": "Se dine kontooplysninger", + "name_label": "Visningsnavn", + "username_label": "Brugernavn", + "account_type_label": "Kontotype", + "auth_method_label": "Godkendelse", + "auth_method_oauth": "Single Sign-On (OAuth/OIDC)", + "auth_method_basic": "Adgangskode", + "demo_account": "Demo-konto", + "email": { + "label": "E-mailadresse", + "value": "{email}" + }, + "server": { + "label": "JMAP-server", + "value": "{server}" + }, + "storage": { + "label": "Lagerforbrug", + "used": "{used} af {total} brugt", + "percentage": "{percent}% brugt" + }, + "last_sync": { + "label": "Sidste synkronisering", + "value": "{time}" + } + }, + "security": { + "title": "Kontosikkerhed", + "description": "Administrér din adgangskode, to-faktor-godkendelse og sikkerhedsindstillinger", + "detecting": "Registrerer serverfunktioner...", + "not_available": "Kontosikkerhedsstyring er ikke tilgængelig for denne mailserver. Nødvendige tilladelser er muligvis deaktiveret. Se dokumentationen for detaljer.", + "password": { + "title": "Skift adgangskode", + "current": "Nuværende adgangskode", + "new": "Ny adgangskode", + "confirm": "Bekræft ny adgangskode", + "submit": "Skift adgangskode", + "success": "Adgangskode ændret succesfuldt", + "error_title": "Adgangskodeændring mislykkedes", + "error_mismatch": "De nye adgangskoder er ikke ens", + "error_min_length": "Adgangskoden skal være mindst 8 tegn", + "error_generic": "Kunne ikke ændre adgangskode" + }, + "display_name": { + "label": "Visningsnavn", + "description": "Dit navn som det vises på serveren", + "placeholder": "Indtast dit visningsnavn", + "save": "Gem", + "success": "Visningsnavn opdateret", + "error": "Kunne ikke opdatere visningsnavn" + }, + "totp": { + "section_title": "To-faktor-godkendelse", + "label": "TOTP-godkendelse", + "description": "Tilføj et ekstra lag af sikkerhed med en tidsbaseret engangskode", + "active": "Aktiveret", + "inactive": "Deaktiveret", + "enabled": "To-faktor-godkendelse aktiveret", + "disabled": "To-faktor-godkendelse deaktiveret", + "enable_error": "Kunne ikke aktivere 2FA", + "disable_error": "Kunne ikke deaktivere 2FA", + "setup_instructions": "Kopier denne URL ind i din autentificeringsapp (Google Authenticator, Authy osv.):", + "verification_code": "Bekræftelseskode", + "confirm": "Bekræft", + "disable": "Deaktivér", + "disable_confirm_prompt": "Indtast din adgangskode for at deaktivere to-faktor-godkendelse.", + "password_required": "Adgangskode er påkrævet", + "code_required": "Bekræftelseskode er påkrævet", + "code_invalid": "Ugyldig bekræftelseskode. Kontrollér din autentificeringsapp og prøv igen." + }, + "app_passwords": { + "title": "App-adgangskoder", + "description": "Opret adgangskoder til apps, der ikke understøtter to-faktor-godkendelse", + "add": "Tilføj", + "create": "Opret", + "cancel": "Annuller", + "done": "Færdig", + "generate": "Generér", + "name_label": "App-navn", + "name_placeholder": "f.eks. Thunderbird, iPhone Mail", + "expires_label": "Udløber (valgfrit)", + "allowed_ips_label": "Tilladte IP'er (valgfrit)", + "allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24", + "allowed_ips_hint": "Komma- eller mellemrumssepareret. Lad være tom for at tillade alle IP'er.", + "password_label": "Adgangskode (lad være tom for auto-generering)", + "password_placeholder": "Auto-genereret hvis tom", + "copy_now_warning": "Kopier denne adgangskode nu - den vil ikke blive vist igen.", + "added": "App-adgangskode oprettet", + "removed": "App-adgangskode fjernet", + "add_error": "Kunne ikke oprette app-adgangskode", + "remove_error": "Kunne ikke fjerne app-adgangskode", + "none": "Ingen app-adgangskoder konfigureret" + }, + "api_keys": { + "title": "API-nøgler", + "description": "Opret API-nøgler til scripts og integrationer, der taler direkte med serveren", + "name_label": "Nøglenavn", + "name_placeholder": "f.eks. Backup-script, CI-runner", + "copy_now_warning": "Kopier denne API-nøgle nu - den vil ikke blive vist igen.", + "added": "API-nøgle oprettet", + "removed": "API-nøgle fjernet", + "add_error": "Kunne ikke oprette API-nøgle", + "remove_error": "Kunne ikke fjerne API-nøgle", + "none": "Ingen API-nøgler konfigureret" + }, + "encryption": { + "section_title": "Kryptering af lagrede data", + "label": "E-mail-kryptering", + "description": "Krypter lagrede e-mails på serveren for ekstra privatliv", + "active": "{type}-kryptering aktiveret", + "inactive": "Deaktiveret", + "enabled": "Kryptering af lagrede data aktiveret", + "disabled_success": "Kryptering af lagrede data deaktiveret", + "error": "Kunne ikke opdatere krypteringsindstillinger" + }, + "email_client": { + "title": "E-mail-klientopsætning", + "description": "Brug disse legitimationsoplysninger til at konfigurere din desktop- eller mobil-e-mailklient (Thunderbird, Apple Mail, Outlook osv.)", + "jmap_username_label": "JMAP-brugernavn", + "copy": "Kopier", + "copied": "Kopieret", + "password_instructions": "Brug dit JMAP-brugernavn ovenfor sammen med en app-adgangskode for at logge ind i din e-mailklient. Opret en app-adgangskode i sektionen ovenfor, hvis du ikke allerede har gjort det." + } + }, + "identities": { + "title": "Afsenderidentiteter", + "description": "Administrér e-mailadresser du kan sende fra", + "identities_count": { + "label": "Dine identiteter", + "description": "E-mailadresser konfigureret til afsendelse", + "count_zero": "Ingen identiteter", + "count_one": "1 identitet", + "count_other": "{count} identiteter" + }, + "manage": "Administrér identiteter", + "sub_addressing": { + "label": "Underadressering", + "description": "Brug tags som bruger+tag@domæne.dk til at organisere indkommende mail", + "learn_more": "Lær mere" + } + }, + "vacation": { + "title": "Fraværsbeskeder", + "description": "Svar automatisk på indkommende e-mails, mens du er væk", + "loading": "Indlæser ferieindstillinger...", + "not_supported": "Din mailserver understøtter ikke fraværsbeskeder.", + "fetch_error": "Kunne ikke indlæse ferieindstillinger. Prøv igen.", + "status": { + "label": "Fraværsbeskeder", + "description": "Send et automatisk svar til personer, der skriver til dig", + "active": "Aktiv", + "inactive": "Inaktiv" + }, + "date_range": { + "title": "Datoperiode", + "description": "Begræns eventuelt auto-svaret til en bestemt periode", + "start": "Startdato", + "start_description": "Lad være tom for ingen startbegrænsning", + "end": "Slutdato", + "end_description": "Lad være tom for ingen slutbegrænsning" + }, + "message": { + "title": "Auto-svar-besked", + "description": "Beskeden der sendes som svar", + "subject_label": "Emne", + "subject_description": "Emnelinje for auto-svaret", + "subject_placeholder": "Fraværende", + "body_label": "Beskedtekst", + "body_description": "Ren tekst-beskedindhold", + "body_placeholder": "Tak for din e-mail. Jeg er i øjeblikket fraværende og vil svare, når jeg vender tilbage." + }, + "preview": { + "title": "Forhåndsvisning", + "show": "Vis forhåndsvisning", + "hide": "Skjul forhåndsvisning" + }, + "save": "Gem ændringer", + "saving": "Gemmer...", + "warnings": { + "end_before_start": "Slutdato skal være efter startdato", + "start_in_past": "Startdato er tidligere end i dag", + "empty_body": "Beskedteksten er tom - modtagere vil modtage et tomt svar" + } + }, + "folders": { + "title": "Mapper", + "description": "Administrér dine e-mail-mapper og tildel standardroller", + "folder_list": "Dine mapper", + "folder_list_description": "Klik på et mappeikon for at tilpasse det", + "standard_roles": "Standard mapproller", + "standard_roles_description": "Tildel hvilke mapper der bruges til standard postkasseroller som Indbakke, Sendt, Papirkurv osv.", + "role_inbox": "Indbakke", + "role_drafts": "Kladder", + "role_sent": "Sendt", + "role_trash": "Papirkurv", + "role_junk": "Spam", + "role_archive": "Arkiv", + "role_none": "Ingen", + "create_folder": "Opret mappe", + "create_subfolder": "Opret undermappe", + "subfolder_of": "Inde i {name}", + "subfolder_name": "Undermappenavn", + "new_folder_name": "Mappenavn", + "rename": "Omdøb", + "change_icon": "Skift ikon", + "delete": "Slet", + "confirm_delete": "Er du sikker på, at du vil slette \"{name}\"? E-mails i denne mappe flyttes til papirkurven.", + "create": "Opret", + "cancel": "Annuller", + "no_folders": "Ingen brugerdefinerede mapper", + "cannot_delete_role": "Kan ikke slette en mappe med en standardrolle. Fjern rollen først.", + "folder_created": "Mappe oprettet", + "folder_renamed": "Mappe omdøbt", + "folder_deleted": "Mappe slettet", + "role_updated": "Mappens rolle opdateret", + "error_create": "Kunne ikke oprette mappe", + "error_rename": "Kunne ikke omdøbe mappe", + "error_delete": "Kunne ikke slette mappe", + "error_delete_has_children": "Kan ikke slette mappe: den indeholder stadig undermapper. Slet eller flyt dem først.", + "error_delete_has_email": "Kan ikke slette mappe: den indeholder stadig e-mails. Flyt eller slet dem først.", + "error_role": "Kunne ikke opdatere mappens rolle" + }, + "advanced": { + "title": "Avanceret", + "description": "Avancerede indstillinger og udviklerindstillinger", + "debug_mode": { + "label": "Debug-tilstand", + "description": "Aktivér detaljeret logning til fejlfinding" + }, + "debug_categories": { + "description": "Vælg hvilke kategorier der skal logges. Deaktivér kategorier du ikke har brug for at reducere konsolstøj.", + "jmap": "JMAP-klient", + "jmap_description": "Mappeoperationer, e-mail-hentning og JMAP-protokolforespørgsler", + "calendar": "Kalender", + "calendar_description": "Kalenderbegivenheder, import og planlægningsbeskeder", + "tasks": "Opgaver", + "tasks_description": "Kalenderopgaveoprettelse, -hentning og -opdateringer", + "auth": "Godkendelse", + "auth_description": "Login, TOTP, token-udveksling og sessionsstyring", + "filters": "Filtre", + "filters_description": "Sieve-filterregler og feriescripts", + "email": "E-mail-visning", + "email_description": "E-mail-visning, TNEF-behandling og markér-som-læst", + "push": "Push-notifikationer", + "push_description": "Push-notifikationsopsætning og -levering", + "contacts": "Kontakter & adressebøger", + "contacts_description": "Kontaktsynkronisering, adressebogsoperationer og betroede afsendere" + }, + "settings_sync": { + "label": "Indstillingssynkronisering", + "description": "Synkronisér dine indstillinger på tværs af browsere og enheder" + }, + "sender_favicons": { + "label": "Afsender-favicons", + "description": "Vis hjemmesideikoner som profilbilleder for forretningsafsendere" + }, + "show_avatars_in_junk": { + "label": "Vis avatarer i spam-mappe", + "description": "Vis profilbilleder og favicons for afsendere i spam-mappen. Deaktiveret som standard for at undgå at give visuel legitimitet til phishing-forsøg." + }, + "keyboard_shortcuts": { + "label": "Tastaturgenveje", + "description": "Se tilgængelige tastaturgenveje", + "button": "Vis genveje" + }, + "reset_settings": { + "label": "Nulstil indstillinger", + "description": "Gendan alle indstillinger til standardværdier", + "button": "Nulstil til standard" + }, + "export_settings": { + "label": "Eksportér indstillinger", + "description": "Download dine indstillinger som JSON", + "button": "Eksportér" + }, + "import_settings": { + "label": "Importér indstillinger", + "description": "Upload indstillinger fra JSON-fil", + "button": "Importér" + }, + "about": { + "title": "Bulwark Webmail" + } + }, + "sidebar_apps": { + "title": "Sidepanel-apps", + "description": "Administrér brugerdefinerede apps og links i dit sidepanel", + "keep_loaded": "Hold apps indlæst", + "keep_loaded_description": "Hold indlejrede apps kørende i baggrunden, når du skifter mellem dem, for at undgå genindlæsning", + "manage_title": "Brugerdefinerede apps", + "manage_description": "Tilføj, redigér eller fjern brugerdefinerede apps fra dit sidepanel" + }, + "contacts": { + "title": "Kontakter", + "description": "Importér og eksportér dine kontakter", + "group_by_letter_label": "Gruppér efter første bogstav", + "group_by_letter_description": "Vis alfabetiske sektionsoverskrifter i kontaktlisten", + "import_label": "Importér kontakter", + "import_description": "Importér kontakter fra en vCard-fil (.vcf)", + "export_label": "Eksportér kontakter", + "export_description": "Eksportér alle kontakter som en vCard-fil (.vcf)", + "manage_title": "Adressebøger", + "manage_description": "Omdøb dine adressebøger", + "no_address_books": "Ingen adressebøger fundet", + "categories_title": "Kategorier", + "categories_description": "Omdøb kontaktkategorier", + "no_categories": "Ingen kategorier fundet" + }, + "filters": { + "title": "E-mail-filtre", + "description": "Opret regler til automatisk at sortere, mærke og administrere indkommende e-mails", + "add_rule": "Tilføj regel", + "no_rules": "Ingen filterregler", + "no_rules_description": "Opret regler for automatisk at organisere dine indkommende e-mails", + "vacation_active": "Fraværsbeskeder er aktive", + "vacation_active_description": "Auto-svar er aktiveret for indkommende beskeder", + "vacation_configure": "Konfigurér", + "edit_rule": "Redigér regel", + "new_rule": "Ny regel", + "delete_rule": "Slet regel", + "delete_confirm": "Er du sikker på, at du vil slette denne regel?", + "enable": "Aktivér", + "disable": "Deaktivér", + "raw_editor": "Raw Sieve-editor", + "raw_editor_warning": "Redigering af det rå Sieve-script kan ødelægge visuel regelledigering. Ændringer foretaget her tilsidesætter den visuelle builder.", + "validate": "Valider", + "validation_success": "Scriptet er gyldigt", + "validation_error": "Scriptet har fejl", + "save": "Gem regler", + "saving": "Gemmer...", + "saved": "Filtre gemt succesfuldt", + "save_failed": "Kunne ikke gemme filtre", + "loading": "Indlæser filtre...", + "not_supported": "Din mailserver understøtter ikke e-mail-filtre.", + "rule_name": "Regelnavn", + "rule_name_placeholder": "f.eks. Sorter nyhedsbreve", + "match_all": "Match ALLE betingelser", + "match_any": "Match ENHVER betingelse", + "conditions": "Betingelser", + "add_condition": "Tilføj betingelse", + "actions": "Handlinger", + "add_action": "Tilføj handling", + "stop_processing": "Stop behandling af efterfølgende regler", + "condition_fields": { + "from": "Fra", + "to": "Til", + "cc": "Cc", + "subject": "Emne", + "header": "Brugerdefineret header", + "size": "Størrelse", + "body": "Brødtekst" + }, + "comparators": { + "contains": "indeholder", + "not_contains": "indeholder ikke", + "is": "er præcis", + "not_is": "er ikke", + "starts_with": "begynder med", + "ends_with": "slutter med", + "matches": "matcher mønster", + "greater_than": "er større end", + "less_than": "er mindre end" + }, + "action_types": { + "move": "Flyt til mappe", + "copy": "Kopier til mappe", + "forward": "Videresend til", + "mark_read": "Markér som læst", + "star": "Stjernemarkér besked", + "add_label": "Tilføj tag", + "discard": "Kassér (slet stille)", + "reject": "Afvis med besked", + "keep": "Behold i indbakke", + "stop": "Stop behandling" + }, + "move_to_folder": "Vælg mappe", + "copy_to_folder": "Vælg mappe", + "forward_to": "Videresend til e-mailadresse", + "forward_placeholder": "email@eksempel.dk", + "reject_message": "Afvisningsbesked", + "reject_placeholder": "Din e-mail er blevet afvist", + "label_name": "Tag-navn", + "label_placeholder": "Vælg tag", + "header_name": "Headernavn", + "header_placeholder": "f.eks. X-Mailing-List", + "size_bytes": "Størrelse i bytes", + "size_placeholder": "f.eks. 1000000", + "system_managed": "Systemadministreret regel", + "opaque_warning": "Dette script blev redigeret uden for den visuelle builder. Kun raw Sieve-redigering er tilgængelig.", + "open_sieve_editor": "Åbn raw Sieve-editor", + "fetch_error": "Kunne ikke indlæse filtre", + "expanded_view": "Udvidet visning", + "expanded_view_description": "Vis filterregler med detaljerede betingelses- og handlingsblokke", + "if": "Hvis", + "then": "Så", + "match_all_conditions": "alle matcher", + "match_any_condition": "enhver matcher", + "and": "og", + "or": "eller", + "cancel": "Annuller", + "confirm_delete": "Slet", + "rule_list": "Filterregler", + "drag_to_reorder": "Træk for at omorganisere", + "match_type": "Match-type", + "reset_to_visual": "Nulstil til visuel builder", + "reset_warning": "Dette vil kassere det nuværende script og starte forfra.", + "confirm_reset": "Nulstil", + "validation_empty_name": "Regelnavn er påkrævet", + "validation_empty_conditions": "Mindst én betingelse med en værdi er påkrævet", + "validation_empty_actions": "Mindst én handling er påkrævet", + "templates_section": "Start fra skabelon", + "template_newsletters": "Flyt nyhedsbreve til mappe", + "template_receipts": "Auto-arkiver kvitteringer", + "template_important": "Markér vigtige e-mails", + "template_notifications": "Filtrér notifikationer", + "sieve_editor": { + "title": "Sieve-script-editor", + "warning": "Redigering af det rå Sieve-script kan ødelægge visuel regelledigering. Ændringer foretaget her tilsidesætter den visuelle builder.", + "script_content": "Sieve-script", + "valid": "Scriptet er gyldigt", + "invalid": "Scriptet har fejl", + "save_warning": "Lagring vil overskrive eventuelle visuelle regler. Dette kan ikke fortrydes. Klik Gem igen for at bekræfte.", + "validating": "Validerer...", + "validate": "Valider", + "cancel": "Annuller", + "save": "Gem", + "confirm_save": "Bekræft gem", + "validation_failed": "Valideringsanmodning mislykkedes" + }, + "rule_summary": { + "conditions_count": "{count, plural, one {# betingelse} other {# betingelser}}", + "actions_count": "{count, plural, one {# handling} other {# handlinger}}" + }, + "origin_external": "Ekstern", + "managed_by_tooltip": "Administreres af {source}. Redigér det i den pågældende app, eller brug raw Sieve-editoren." + }, + "templates": { + "title": "E-mail-skabeloner", + "description": "Opret genanvendelige e-mail-skabeloner med pladsholdervariabler", + "add": "Ny skabelon", + "edit": "Redigér skabelon", + "name": "Skabelonnavn", + "name_placeholder": "f.eks. Opfølgning", + "category": "Kategori", + "category_placeholder": "f.eks. Arbejde, Privat", + "subject": "Emne", + "subject_placeholder": "E-mail-emnelinje", + "body": "Brødtekst", + "body_placeholder": "E-mail-tekst...", + "recipients_placeholder": "email@eksempel.dk", + "identity": "Send som", + "default_identity": "Standardidentitet", + "favorite": "Favorit", + "cancel": "Annuller", + "create": "Opret", + "update": "Opdater", + "confirm_delete": "Slet", + "no_templates": "Ingen skabeloner endnu", + "manage": "Administrér skabeloner", + "count": "{count, plural, one {# skabelon} other {# skabeloner}}", + "export_import": "Eksportér & importér", + "export_import_description": "Sikkerhedskopér dine skabeloner eller overfør dem til en anden enhed", + "export": "Eksportér", + "import": "Importér", + "validation": { + "empty": "Skabelonnavn er påkrævet", + "too_long": "Skabelonnavn skal være 200 tegn eller mindre" + } + }, + "files": { + "display": { + "title": "Visning", + "description": "Konfigurér hvordan filer og mapper vises" + }, + "folder_layout": { + "label": "Mappenavigation", + "description": "Vælg hvordan mapper vises: integreret med filer eller i et sidepanel-træ", + "inline": "Integreret", + "sidebar": "Sidepanel" + }, + "default_view": { + "label": "Standardvisning", + "description": "Vælg mellem gitter- og listelayout", + "list": "Liste", + "grid": "Gitter" + }, + "default_sort": { + "label": "Standardsortering", + "description": "Vælg standardsortering for filer", + "name": "Navn", + "size": "Størrelse", + "modified": "Ændret" + }, + "sort_direction": { + "label": "Sorteringsretning", + "description": "Vælg stigende eller faldende rækkefølge", + "ascending": "Stigende", + "descending": "Faldende" + }, + "icons": { + "title": "Ikoner", + "description": "Konfigurér filikonernes udseende" + }, + "show_icons": { + "label": "Vis filikoner", + "description": "Vis ikoner ved siden af filer og mapper" + }, + "colored_icons": { + "label": "Farvede ikoner", + "description": "Brug farverige ikoner i stedet for monokrome" + }, + "show_thumbnails": { + "label": "Vis miniaturer", + "description": "Vis billedforhåndsvisninger i stedet for ikoner for billedfiler" + }, + "behavior": { + "title": "Adfærd", + "description": "Konfigurér filbrowserens adfærd" + }, + "show_hidden": { + "label": "Vis skjulte filer", + "description": "Vis filer og mapper der begynder med et punktum" + }, + "preview": { + "label": "Forhåndsvisning" + } + } + }, + "errors": { + "page_error_title": "Noget gik galt", + "page_error_description": "Vi stødte på en uventet fejl. Prøv igen eller gå tilbage til startsiden.", + "sidebar_error": "Kunne ikke indlæse postkasser", + "email_list_error": "Kunne ikke indlæse e-mails", + "viewer_error_title": "Kan ikke vise e-mail", + "viewer_error_description": "Der var et problem med at vise denne e-mail. Den kan indeholde indhold, der ikke understøttes.", + "composer_error": "Kunne ikke indlæse komponist", + "settings_error_title": "Indstillinger utilgængelige", + "settings_error_description": "Kunne ikke indlæse indstillinger. Dine præferencer bliver muligvis ikke gemt.", + "try_again": "Prøv igen", + "reload": "Genindlæs", + "reload_emails": "Genindlæs e-mails", + "reload_settings": "Genindlæs indstillinger", + "retry": "Prøv igen", + "go_home": "Gå til indbakke" + }, + "context_menu": { + "reply": "Svar", + "reply_all": "Svar alle", + "forward": "Videresend", + "mark_read": "Markér som læst", + "mark_unread": "Markér som ulæst", + "star": "Stjernemarkér", + "unstar": "Fjern stjerne", + "move_to": "Flyt til...", + "archive": "Arkivér", + "delete": "Slet", + "mark_as_spam": "Rapportér spam", + "not_spam": "Ikke spam", + "color_tag": "Tag", + "remove_color": "Fjern tag", + "items_selected": "{count} e-mails valgt", + "edit_draft": "Redigér kladde" + }, + "mailbox_context_menu": { + "mark_folder_read": "Markér mappe som læst", + "mark_folder_tree_read": "Markér mappe & undermapper som læst", + "mark_all_folders_read": "Markér alle mapper som læst", + "new_subfolder": "Ny undermappe...", + "new_folder": "Ny mappe...", + "rename": "Omdøb...", + "import_email": "Importér .eml...", + "empty_folder": "Tøm mappe", + "empty_folder_generic": "Tøm mappe", + "delete_folder": "Slet mappe", + "refresh": "Opdatér", + "mark_all_confirm_title": "Markér alle mapper som læst", + "mark_all_confirm_message": "Markér alle ulæste beskeder på din personlige konto som læst?", + "delete_confirm_title": "Slet mappe", + "delete_confirm_message": "Slet mappen \"{name}\" permanent? Denne handling kan ikke fortrydes.", + "prompt_new_subfolder": "Indtast et navn til den nye undermappe.", + "prompt_new_folder": "Indtast et navn til den nye mappe.", + "prompt_rename": "Indtast et nyt navn til denne mappe.", + "placeholder_folder_name": "Mappenavn", + "create": "Opret", + "rename_confirm": "Omdøb", + "toast_marked_read": "Mappe markeret som læst", + "toast_marked_read_count": "Markerede {count, plural, one {1 besked} other {# beskeder}} som læst", + "toast_already_read": "Ingen ulæste beskeder", + "toast_marked_all_read": "Alle mapper markeret som læst", + "toast_emptied": "Mappe tømt", + "toast_folder_created": "Mappe oprettet", + "toast_folder_renamed": "Mappe omdøbt", + "toast_folder_deleted": "Mappe slettet", + "toast_error_mark_read": "Kunne ikke markere som læst", + "toast_error_empty": "Kunne ikke tømme mappe", + "toast_error_create": "Kunne ikke oprette mappe", + "toast_error_rename": "Kunne ikke omdøbe mappe", + "toast_error_delete": "Kunne ikke slette mappe", + "toast_error_delete_has_children": "Mappen har undermapper. Fjern dem først.", + "toast_error_delete_has_email": "Mappen er ikke tom. Tøm den først." + }, + "shortcuts": { + "title": "Tastaturgenveje", + "tip": "Tryk ? når som helst for at se denne hjælp", + "sections": { + "navigation": "Navigation", + "actions": "E-mail-handlinger", + "global": "Globalt", + "threads": "Tråde", + "composer": "Komponist" + }, + "navigation": { + "next_email": "Næste e-mail", + "previous_email": "Forrige e-mail", + "open_email": "Åbn e-mail", + "close_email": "Luk / Fravaælg" + }, + "actions": { + "reply": "Svar", + "reply_all": "Svar alle", + "forward": "Videresend", + "star": "Slå stjerne til/fra", + "archive": "Arkivér", + "delete": "Slet", + "mark_unread": "Markér som ulæst", + "mark_read": "Markér som læst", + "toggle_spam": "Rapportér spam / Ikke spam" + }, + "global": { + "compose": "Ny e-mail", + "search": "Fokusér søgning", + "help": "Vis genveje", + "refresh": "Opdatér e-mails", + "select_all": "Vælg alle" + }, + "threads": { + "expand_collapse": "Udvid/skjul tråd" + }, + "composer": { + "template_picker": "Åbn skabelonvælger" + } + }, + "threads": { + "messages_one": "{count} besked", + "messages_other": "{count} beskeder", + "messages_tooltip": "{count, plural, one {# besked i denne samtale} other {# beskeder i denne samtale}}", + "expand": "Udvid samtale", + "collapse": "Skjul samtale", + "loading": "Indlæser samtale...", + "mark_read": "Markér samtale som læst", + "mark_unread": "Markér samtale som ulæst", + "archive": "Arkivér samtale", + "delete": "Slet samtale", + "star": "Stjernemarkér samtale", + "unstar": "Fjern stjernemarkering", + "toggle_thread": "Slå tråd til/fra" + }, + "identities": { + "modal_title": "Administrér afsenderidentiteter", + "create_new": "Opret ny identitet", + "edit_identity": "Redigér identitet", + "delete_confirm": "Slet denne identitet? Dette kan ikke fortrydes.", + "cannot_delete": "Denne identitet kan ikke slettes", + "primary_identity": "Primær", + "set_as_primary": "Angiv som primær", + "no_identities": "Ingen identiteter fundet", + "display": { + "reply_to": "Svar-til:", + "bcc": "BCC:", + "signature": "Signatur:", + "preview": "Forhåndsvisning:" + }, + "validation_errors": { + "invalid_emails": "Ugyldige e-mails: {emails}", + "unknown_error": "Ukendt fejl" + }, + "form": { + "name_label": "Visningsnavn", + "name_placeholder": "f.eks. Arbejdsmail, Privat", + "name_required": "Navn er påkrævet", + "email_label": "E-mailadresse", + "email_placeholder": "din.email@eksempel.dk", + "email_required": "E-mail er påkrævet", + "email_invalid": "Indtast en gyldig e-mailadresse", + "email_immutable": "E-mailadresse kan ikke ændres efter oprettelse", + "reply_to_label": "Svar-til (valgfrit)", + "reply_to_placeholder": "anden@email.dk", + "bcc_label": "Auto BCC (valgfrit)", + "bcc_placeholder": "arkiv@email.dk", + "text_signature_label": "Tekstsignatur", + "html_signature_label": "HTML-signatur", + "save": "Gem identitet", + "cancel": "Annuller", + "creating": "Opretter...", + "updating": "Opdaterer..." + }, + "sub_address": { + "button_tooltip": "Brug underadresse", + "popover_title": "Tilføj underadresse-tag", + "tag_input_placeholder": "Indtast tag (f.eks. shopping)", + "preview_label": "Forhåndsvisning:", + "recent_tags": "Seneste tags", + "suggested_tags": "Foreslået", + "use_address": "Brug denne adresse", + "invalid_tag": "Tag må kun indeholde bogstaver, tal og bindestreger", + "tag_too_long": "Tag skal være 30 tegn eller mindre", + "help_text": "E-mails sendt til bruger{delimiter}tag@domæne.dk ankommer i din indbakke", + "validation": { + "empty": "Tag kan ikke være tomt", + "too_long": "Tag skal være {max} tegn eller mindre", + "invalid_chars": "Tag må kun indeholde bogstaver, tal og bindestreger" + } + }, + "badge": { + "sent_via": "via", + "sub_address_tag": "Sendt med underadresse: {tag}", + "identity_name": "Sendt med identitet: {name}", + "identity_short": "via {name}", + "subaddress_tag": "+{tag}" + }, + "delete_button": "Slet", + "delete_confirm_title": "Slet identitet" + }, + "templates": { + "picker_title": "Vælg en skabelon", + "search_placeholder": "Søg efter skabeloner...", + "section_favorites": "Favoritter", + "section_recent": "Seneste", + "section_uncategorized": "Andet", + "no_templates": "Ingen skabeloner endnu", + "no_results": "Ingen skabeloner fundet", + "fill_placeholders": "Udfyld pladsholderværdier", + "enter_value": "Indtast en værdi...", + "preview": "Forhåndsvisning", + "insert_with_values": "Indsæt med værdier", + "insert_raw": "Indsæt rå", + "copy_suffix": "(kopi)", + "placeholder": "Variabel", + "placeholders": { + "recipient_name": "Modtagers navn", + "company": "Virksomhedsnavn", + "date": "Dagens dato", + "day_of_week": "Ugedag", + "sender_name": "Dit navn" + } + }, + "contacts": { + "title": "Kontakter", + "search_placeholder": "Søg efter kontakter...", + "create_new": "Ny kontakt", + "no_category": "Ingen kategori", + "rename_category": "Omdøb kategori", + "category_name_label": "Kategorinavn", + "category_renamed": "Kategori omdøbt", + "category_rename_failed": "Kunne ikke omdøbe kategori", + "category_added": "Kontakt tilføjet til {name}", + "category_added_plural": "{count} kontakter tilføjet til {name}", + "empty_state": "Ingen kontakter endnu", + "empty_state_title": "Ingen kontakter endnu", + "empty_state_subtitle": "Opret din første kontakt eller importér fra en vCard-fil", + "empty_search": "Ingen kontakter matcher din søgning", + "empty_search_hint": "Prøv en anden søgeterm", + "empty_filtered": "Ingen kontakter matcher dine filtre", + "empty_filtered_hint": "Prøv at justere eller rydde filtre", + "clear_search": "Ryd søgning", + "import_vcard": "Importér vCard", + "delete_confirm_title": "Slet kontakt", + "delete_confirm": "Er du sikker på, at du vil slette denne kontakt?", + "local_mode": "Kontakter gemmes lokalt (serveren understøtter ikke JMAP-kontakter)", + "back_to_contacts": "Tilbage til kontakter", + "tabs": { + "all": "Alle", + "groups": "Grupper" + }, + "shared": { + "title": "Delt" + }, + "address_books": { + "title": "Mine adressebøger", + "shared_prefix": "Delt: {name}", + "moved": "Kontakt flyttet til {name}", + "moved_plural": "{count} kontakter flyttet til {name}", + "move_failed": "Kunne ikke flytte kontakt", + "address_book": "Adressebog", + "rename": "Omdøb adressebog", + "name_label": "Adressebogsnavn", + "renamed": "Adressebog omdøbt", + "rename_failed": "Kunne ikke omdøbe adressebog", + "default": "Standard", + "manage": "Administrér adressebøger", + "share": "Del adressebog", + "new_contact_in_book": "Ny kontakt i denne adressebog", + "delete": "Slet adressebog", + "confirm_delete": "Slet \"{name}\"? Alle kontakter i denne adressebog fjernes.", + "deleted": "Adressebog slettet", + "delete_failed": "Kunne ikke slette adressebog" + }, + "detail": { + "emails": "E-mailadresser", + "phones": "Telefonnumre", + "organizations": "Organisationer", + "addresses": "Adresser", + "notes": "Noter", + "titles": "Titler & roller", + "online_services": "Online tjenester", + "anniversaries": "Mærkedage", + "personal_info": "Personlige oplysninger", + "languages": "Sprog", + "categories": "Kategorier", + "related_contacts": "Relaterede kontakter", + "crypto_keys": "Krypto-nøgler", + "cert_issuer": "Udsteder", + "cert_expires": "Udløber", + "cert_expired": "Udløbet", + "cert_fingerprint": "Fingeraftryk", + "cert_algorithm": "Algoritme", + "import_to_smime": "Importér til S/MIME", + "cert_already_imported": "Allerede importeret til S/MIME", + "cert_imported": "Certifikat importeret til S/MIME-lager", + "cert_import_failed": "Kunne ikke importere certifikat", + "no_contact_selected": "Vælg en kontakt for at se detaljer", + "compose_email": "Skriv e-mail", + "copy_email": "Kopier e-mail", + "copy_phone": "Kopier telefonnummer", + "copy_url": "Kopier URL", + "copied": "Kopieret til udklipsholder", + "copy_failed": "Kunne ikke kopiere til udklipsholder", + "created": "Oprettet", + "updated": "Sidst opdateret", + "timezone": "Tidszone", + "anniversary_birth": "Fødselsdag", + "anniversary_death": "Dødsdag", + "anniversary_wedding": "Bryllupsdag", + "anniversary_other": "Andet", + "personal_expertise": "Ekspertise", + "personal_hobby": "Hobby", + "personal_interest": "Interesse", + "personal_other": "Andet", + "gender": "Køn", + "gender_masculine": "Mand", + "gender_feminine": "Kvinde", + "gender_other": "Andet", + "gender_none": "Ikke relevant", + "gender_unknown": "Ukendt", + "calendar": "Kalender", + "calendar_uri": "Kalender-URL", + "scheduling_uri": "Planlægnings-URL", + "freebusy_uri": "Ledig/Opptagen-URL", + "section_contact": "Kontaktoplysninger", + "section_work": "Arbejde", + "section_personal": "Privat", + "email_default_label": "E-mail", + "phone_default_label": "Telefon", + "address_default_label": "Adresse", + "online_service_default_label": "Online", + "organization_label": "Organisation", + "title_label": "Titel", + "role_label": "Rolle", + "language_label": "Sprog", + "related_default_label": "Relateret", + "more_actions": "Flere handlinger", + "age_years": "{count, plural, one {1 år gammel} other {# år gammel}}", + "years_since": "{count, plural, one {1 år} other {# år}}" + }, + "activity": { + "recent_emails": "Seneste e-mails", + "upcoming_events": "Kommende begivenheder", + "no_emails": "Ingen seneste e-mails", + "no_events": "Ingen kommende begivenheder", + "no_subject": "(Intet emne)", + "no_title": "(Ingen titel)", + "load_failed": "Kunne ikke indlæse", + "unknown_sender": "Ukendt afsender", + "all_day": "Hele dagen" + }, + "form": { + "create_title": "Ny kontakt", + "edit_title": "Redigér kontakt", + "section_address_book": "Adressebog", + "select_address_book": "Vælg en adressebog...", + "section_identity": "Navn & identitet", + "section_work": "Arbejde & organisation", + "prefix": "Præfiks", + "prefix_placeholder": "Dr., hr., fru", + "given_name": "Fornavn", + "middle_name": "Mellemnavn", + "surname": "Efternavn", + "suffix": "Suffiks", + "suffix_placeholder": "Jr., Sr., III", + "nickname": "Kaldenavn", + "nickname_placeholder": "Kaldenavn", + "email": "E-mail", + "email_placeholder": "email@eksempel.dk", + "phone": "Telefon", + "phone_placeholder": "+45 12 34 56 78", + "phone_type": "Type", + "phone_voice": "Telefon", + "phone_cell": "Mobil", + "phone_fax": "Fax", + "phone_pager": "Personsøger", + "phone_video": "Video", + "phone_text": "Tekst", + "organization": "Organisation", + "organization_placeholder": "Virksomhedsnavn", + "department": "Afdeling", + "department_placeholder": "Afdeling", + "job_title": "Jobtitel", + "job_title_placeholder": "f.eks. Softwareudvikler", + "role": "Rolle", + "role_placeholder": "f.eks. Teamleder", + "addresses": "Adresser", + "add_address": "Tilføj adresse", + "street": "Gade", + "city": "By", + "region": "Region", + "postcode": "Postnummer", + "country": "Land", + "online_services": "Online tjenester", + "add_online_service": "Tilføj online tjeneste", + "url_placeholder": "https://...", + "service_placeholder": "Tjeneste", + "anniversaries": "Mærkedage", + "add_anniversary": "Tilføj dato", + "anniversary_birth": "Fødselsdag", + "anniversary_wedding": "Bryllupsdag", + "anniversary_death": "Mindesmærke", + "anniversary_other": "Andet", + "personal_info": "Personlige oplysninger", + "add_personal_info": "Tilføj indførsel", + "personal_info_placeholder": "f.eks. Fotografi", + "personal_expertise": "Ekspertise", + "personal_hobby": "Hobby", + "personal_interest": "Interesse", + "personal_other": "Andet", + "level": "Niveau", + "level_high": "Høj", + "level_medium": "Mellem", + "level_low": "Lav", + "categories": "Kategorier", + "categories_placeholder": "f.eks. Familie, Venner, Kolleger", + "categories_hint": "Skriv for at søge eller tilføje kategorier", + "category_add": "Tilføj", + "note": "Noter", + "note_placeholder": "Tilføj en note...", + "gender": "Køn", + "gender_sex": "Biologisk køn", + "gender_male": "Mand", + "gender_female": "Kvinde", + "gender_other": "Andet", + "gender_none": "Ikke relevant", + "gender_unknown": "Ukendt", + "gender_identity": "Kønsidentitet", + "gender_identity_placeholder": "Kønsidentitet...", + "calendar": "Kalender", + "calendar_uri": "Kalender-URL", + "scheduling_uri": "Planlægnings-URL", + "freebusy_uri": "Ledig/Opptagen-URL", + "context_work": "Arbejde", + "context_private": "Privat", + "add_email": "Tilføj e-mail", + "add_phone": "Tilføj telefon", + "save": "Gem", + "cancel": "Annuller", + "creating": "Opretter...", + "updating": "Opdaterer...", + "name_required": "Mindst et fornavn eller efternavn er påkrævet", + "email_invalid": "Indtast en gyldig e-mailadresse", + "email_error_inline": "Ugyldigt e-mailformat", + "save_failed": "Kunne ikke gemme kontakt", + "delete": "Slet", + "upload_photo": "Upload billede", + "remove_photo": "Fjern billede", + "photo_hint": "JPG eller PNG, op til 10 MB. Vil blive ændret i størrelse.", + "photo_too_large": "Billedet er for stort (max 10 MB)", + "photo_invalid": "Ugyldig billedfil", + "change_photo": "Skift" + }, + "groups": { + "create": "Ny gruppe", + "edit": "Redigér gruppe", + "empty": "Ingen grupper endnu", + "delete_confirm_title": "Slet gruppe", + "delete_confirm": "Er du sikker på, at du vil slette denne gruppe?", + "name_label": "Gruppenavn", + "name_placeholder": "f.eks. Team, Familie", + "name_required": "Gruppenavn er påkrævet", + "save_failed": "Kunne ikke gemme gruppe", + "members_label": "Medlemmer", + "search_members": "Søg efter kontakter at tilføje...", + "no_members": "Ingen medlemmer i denne gruppe", + "member_count": "{count, plural, =0 {Ingen medlemmer} one {1 medlem} other {# medlemmer}}" + }, + "import": { + "title": "Importér kontakter", + "drop_hint": "Klik for at vælge en vCard-fil", + "file_types": ".vcf eller .vcard filer", + "no_contacts": "Ingen kontakter fundet i filen", + "parse_error": "Kunne ikke parse vCard-fil", + "found": "{count, plural, one {1 kontakt fundet} other {# kontakter fundet}}", + "duplicate": "Dublet", + "select_all": "Vælg alle", + "deselect_all": "Fravælg alle", + "selected": "{count, plural, one {1 valgt} other {# valgte}}", + "import_button": "Importér", + "importing": "Importerer...", + "success": "{count, plural, one {1 kontakt importeret} other {# kontakter importeret}}", + "failed": "Import mislykkedes", + "close": "Luk", + "file_too_large": "Filen er for stor (max 5 MB)" + }, + "export": { + "title": "Eksportér kontakter", + "success": "{count, plural, one {1 kontakt eksporteret} other {# kontakter eksporteret}}" + }, + "bulk": { + "selected": "{count, plural, one {1 valgt} other {# valgte}}", + "select_all": "Vælg alle", + "delete": "Slet", + "delete_confirm_title": "Slet kontakter", + "delete_confirm": "Slet {count, plural, one {1 kontakt} other {# kontakter}}?", + "deleted": "{count, plural, one {1 kontakt slettet} other {# kontakter slettet}}", + "add_to_group": "Tilføj til gruppe", + "choose_group": "Vælg en gruppe", + "adding_contacts": "Tilføjer {count, plural, one {1 kontakt} other {# kontakter}}", + "added_to_group": "Kontakter tilføjet til gruppe", + "export": "Eksportér", + "clear": "Ryd markering" + }, + "toast": { + "created": "Kontakt oprettet", + "updated": "Kontakt opdateret", + "deleted": "Kontakt slettet", + "error_create": "Kunne ikke oprette kontakt", + "error_update": "Kunne ikke opdatere kontakt", + "error_delete": "Kunne ikke slette kontakt" + }, + "context_menu": { + "open": "Åbn", + "edit": "Redigér", + "send_email": "Send e-mail", + "add_to_group": "Tilføj til gruppe", + "export_vcard": "Eksportér som vCard", + "delete": "Slet", + "call": "Ring", + "duplicate": "Duplikér", + "print": "Udskriv" + }, + "filters": { + "toggle": "Filtre", + "select": "Vælg", + "clear": "Ryd", + "close": "Luk", + "title": "Avancerede filtre", + "organization": "Virksomhed", + "organization_placeholder": "f.eks. Acme Corp", + "job_title": "Jobtitel", + "job_title_placeholder": "f.eks. Designer", + "location": "Sted", + "location_placeholder": "By eller land", + "email_domain": "E-maildomæne", + "email_domain_placeholder": "eksempel.dk", + "birthday_month": "Fødselsdag i", + "any_month": "Enhver måned", + "has_email": "Har e-mail", + "has_phone": "Har telefon", + "has_photo": "Har billede" + } + }, + "calendar": { + "title": "Kalender", + "back_to_email": "Tilbage til e-mail", + "back_to_month": "Tilbage til måned", + "my_calendars": "Kalendere", + "birthday_calendar": "Fødselsdage", + "mini_calendar_change": "Klik for at skifte måned", + "views": { + "month": "Måned", + "week": "Uge", + "day": "Dag", + "agenda": "Agenda", + "today": "I dag", + "month_hint": "Måned (m)", + "week_hint": "Uge (w)", + "day_hint": "Dag (d)", + "agenda_hint": "Agenda (a)", + "tasks": "Opgaver", + "tasks_hint": "Opgaver (k)" + }, + "events": { + "create": "Opret begivenhed", + "edit": "Redigér begivenhed", + "delete": "Slet begivenhed", + "details": "Begivenhedsdetaljer", + "no_events": "Ingen begivenheder", + "all_day": "Hele dagen", + "more": "+{count} mere", + "no_title": "(Ingen titel)", + "resize": "Tilpas størrelse på begivenhed", + "duplicate": "Duplikér", + "today_header": "I dag", + "tomorrow_header": "I morgen", + "export_ics": "Eksportér som .ics", + "copy_title": "Kopier titel", + "copy_link": "Kopier mødelink", + "new_event": "Ny begivenhed", + "new_all_day_event": "Ny heldagsbegivenhed", + "new_task": "Ny opgave", + "go_to_today": "Gå til i dag" + }, + "detail": { + "add_note": "Tilføj en note...", + "save_note": "Gem", + "note_saved": "Note tilføjet", + "open_link": "Åbn link", + "meeting_link": "Mødelink", + "tentative": "Foreløbig", + "cancelled": "Aflyst", + "delete_confirm": "Slet denne begivenhed?" + }, + "form": { + "title": "Titel", + "description": "Beskrivelse", + "location": "Sted", + "meeting_link": "Mødelink", + "start_date": "Startdato", + "end_date": "Slutdato", + "start_time": "Starttidspunkt", + "end_time": "Sluttidspunkt", + "all_day_event": "Heldagsbegivenhed", + "calendar_select": "Kalender", + "save": "Gem", + "cancel": "Annuller", + "delete_confirm": "Er du sikker på, at du vil slette denne begivenhed?", + "color": "Farve" + }, + "participants": { + "title": "Deltagere", + "add": "Tilføj deltager", + "organizer": "Arrangør", + "attendee": "Deltager", + "accepted": "Accepteret", + "declined": "Afslået", + "tentative": "Foreløbig", + "needs_action": "Kræver handling", + "remove": "Fjern", + "edit": "Redigér", + "email_placeholder": "Tilføj e-mailadresse eller søg efter kontakter", + "send_invitations": "Send invitationer til deltagere", + "status_summary": "{accepted} accepteret, {pending} afventer", + "invited_by": "Invitation fra {name}", + "respond_below": "Svar ved hjælp af knapperne nedenfor", + "rsvp_label": "Dit svar", + "cancel_notification": "Deltagere vil blive underrettet om aflysningen", + "you_organizer": "Du er arrangøren", + "you_attendee": "Du er deltager", + "no_participants": "Ingen deltagere", + "count": "{count, plural, one {# deltager} other {# deltagere}}" + }, + "recurrence": { + "title": "Gentagelse", + "none": "Gentages ikke", + "daily": "Dagligt", + "weekly": "Ugentligt", + "monthly": "Månedligt", + "yearly": "Årligt", + "every_n_days": "Hver {count} dag", + "every_n_weeks": "Hver {count} uge", + "every_n_months": "Hver {count} måned", + "until": "Indtil", + "occurrences": "{count} forekomster" + }, + "recurrence_scope": { + "edit_title": "Redigér tilbagevendende begivenhed", + "delete_title": "Slet tilbagevendende begivenhed", + "description": "Dette er en tilbagevendende begivenhed. Hvilke begivenheder vil du ændre?", + "this_event": "Kun denne begivenhed", + "this_and_future": "Denne og fremtidige begivenheder", + "all_events": "Alle begivenheder", + "cancel": "Annuller", + "save": "Gem", + "delete": "Slet" + }, + "alerts": { + "title": "Påmindelse", + "none": "Ingen påmindelse", + "at_time": "På begivenhedstidspunktet", + "minutes_before": "{count, plural, one {# minut før} other {# minutter før}}", + "hours_before": "{count, plural, one {# time før} other {# timer før}}", + "days_before": "{count, plural, one {# dag før} other {# dage før}}" + }, + "settings": { + "title": "Kalenderindstillinger", + "default_view": "Standardvisning", + "week_starts_on": "Ugen starter på", + "time_format": "Tidsformat", + "default_calendar": "Standardkalender", + "default_reminder": "Standardpåmindelse", + "time_format_12h": "12-timers", + "time_format_24h": "24-timers", + "notifications_enabled": "Begivenhedsnotifikationer", + "notifications_enabled_desc": "Vis alarmer for kommende kalenderbegivenheder", + "notification_sound": "Notifikationslyd", + "notification_sound_desc": "Afspil en lyd for kalenderalarmer", + "invitation_parsing": "Pars e-mail-invitationer", + "invitation_parsing_desc": "Registrér kalenderinvitationer i e-mail-vedhæftninger og vis kalenderhandlinger", + "show_time_in_month_view": "Vis tid i månedsvisning", + "show_time_in_month_view_desc": "Vis begivenhedstider i månedskalendervisningen", + "show_week_numbers": "Vis ugenumre", + "show_week_numbers_desc": "Vis ugenumre i minikalenderen", + "enable_tasks": "Aktivér opgaver", + "enable_tasks_desc": "Vis en opgavevisning i kalenderen til styring af gøremål", + "show_tasks_on_calendar": "Vis opgaver på kalender", + "show_tasks_on_calendar_desc": "Vis opgave-chips i dags- og ugekalendervisningerne", + "hover_preview": "Hover-forhåndsvisning", + "hover_preview_desc": "Vis en detalje-popover når du holder musen over kalenderbegivenheder", + "hover_preview_instant": "Øjeblikkeligt", + "hover_preview_delay_500ms": "0,5 sekunds forsinkelse", + "hover_preview_delay_1s": "1 sekunds forsinkelse", + "hover_preview_delay_2s": "2 sekunders forsinkelse", + "hover_preview_off": "Deaktiveret", + "show_birthday_calendar": "Kontaktfødselsdagskalender", + "show_birthday_calendar_desc": "Vis en virtuel kalender med fødselsdage fra dine kontakter" + }, + "days": { + "monday": "Mandag", + "tuesday": "Tirsdag", + "wednesday": "Onsdag", + "thursday": "Torsdag", + "friday": "Fredag", + "saturday": "Lørdag", + "sunday": "Søndag", + "mon": "Man", + "tue": "Tir", + "wed": "Ons", + "thu": "Tor", + "fri": "Fre", + "sat": "Lør", + "sun": "Søn" + }, + "months": { + "jan": "Jan", + "feb": "Feb", + "mar": "Mar", + "apr": "Apr", + "may": "Maj", + "jun": "Jun", + "jul": "Jul", + "aug": "Aug", + "sep": "Sep", + "oct": "Okt", + "nov": "Nov", + "dec": "Dec" + }, + "notifications": { + "event_created": "Begivenhed oprettet", + "event_updated": "Begivenhed opdateret", + "event_deleted": "Begivenhed slettet", + "calendar_created": "Kalender oprettet", + "calendar_deleted": "Kalender slettet", + "event_move_error": "Kunne ikke flytte begivenhed", + "event_resize_error": "Kunne ikke ændre størrelse på begivenhed", + "alert_title": "Kommende begivenhed", + "alert_now": "Starter nu", + "alert_in_minutes": "Om {count} min", + "invitation_sent": "Invitationer sendt", + "rsvp_updated": "Svar opdateret", + "rsvp_error": "Kunne ikke opdatere svar", + "event_duplicated": "Begivenhed duplikeret", + "event_error": "Kunne ikke gemme begivenhed", + "task_due": "Opgave forfalder", + "event_exported": "Begivenhed eksporteret", + "title_copied": "Titel kopieret", + "link_copied": "Link kopieret" + }, + "status": { + "loading_calendars": "Indlæser kalendere...", + "loading_events": "Indlæser begivenheder..." + }, + "quick_create": { + "placeholder": "Ny begivenhedstitel", + "aria_label": "Hurtig oprettelse af begivenhed" + }, + "nav_prev": "Forrige", + "nav_next": "Næste", + "import": { + "title": "Importér kalender", + "tab_file": "Fil", + "tab_url": "URL", + "select_file": "Vælg .ics-fil", + "drop_file": "eller slip fil her", + "supported_formats": "Understøtter iCalendar (.ics) filer", + "url_description": "Indtast URL'en til et eksternt iCalendar (.ics)-feed for at importere begivenheder.", + "url_placeholder": "https://eksempel.dk/kalender.ics", + "url_hint": "Understøtter CalDAV og iCalendar (.ics) URL'er", + "fetch": "Hent", + "invalid_url": "Indtast en gyldig URL", + "url_fetch_failed": "Kunne ikke hente kalender fra URL", + "parsing": "Parser kalenderfil...", + "parsed_events": "{count} begivenheder fundet", + "no_events": "Ingen begivenheder fundet i filen", + "select_all": "Vælg alle", + "deselect_all": "Fravælg alle", + "target_calendar": "Importér til kalender", + "import_button": "Importér valgte", + "importing": "Importerer begivenheder...", + "success": "{count} begivenheder importeret", + "error": "Kunne ikke importere kalender", + "file_too_large": "Filen overstiger 10 MB grænsen", + "invalid_format": "Ugyldigt kalenderfilformat" + }, + "webcal_action": { + "title": "Åbn kalenderlink", + "description": "Hvordan vil du bruge \"{name}\"?", + "import_title": "Importér én gang", + "import_description": "Hent begivenhederne nu og kopier dem til en af dine kalendere.", + "subscribe_title": "Abonnér", + "subscribe_description": "Hold denne kalender synkroniseret automatisk som en separat kalender.", + "cancel": "Annuller" + }, + "management": { + "title": "Kalenderadministration", + "description": "Opret, omdøb og tilpas dine kalendere. Højreklik på en kalender i sidepanelet for hurtigt at ændre dens farve.", + "name": "Navn", + "name_placeholder": "Kalendernavn", + "color": "Farve", + "change_color": "Skift farve", + "add_calendar": "Tilføj kalender", + "edit": "Redigér", + "delete": "Slet", + "save": "Gem", + "create": "Opret", + "cancel": "Annuller", + "default": "Standard", + "confirm_delete": "Slet \"{name}\"? Alle begivenheder i denne kalender fjernes.", + "confirm_clear": "Ryd alle begivenheder fra \"{name}\"? Dette kan ikke fortrydes.", + "clear_events": "Ryd begivenheder", + "events_cleared": "{count} begivenheder ryddet", + "error_clear": "Kunne ikke rydde kalenderbegivenheder", + "calendar_created": "Kalender oprettet", + "calendar_updated": "Kalender opdateret", + "calendar_deleted": "Kalender slettet", + "color_updated": "Kalenderfarve opdateret", + "error_create": "Kunne ikke oprette kalender", + "error_update": "Kunne ikke opdatere kalender", + "error_delete": "Kunne ikke slette kalender", + "caldav_url": "CalDAV-URL", + "copy_url": "Kopier CalDAV-URL", + "url_copied": "CalDAV-URL kopieret til udklipsholder", + "share": "Del kalender", + "new_event_in_calendar": "Ny begivenhed i denne kalender" + }, + "subscription": { + "title": "iCal-abonnement", + "section_title": "iCal-abonnementer", + "description": "Abonnér på et eksternt iCalendar-feed. Begivenheder synkroniseres automatisk til deres egen kalender. Understøtter https:// og webcal:// URL'er.", + "url_label": "Kalender-URL", + "url_placeholder": "https://eksempel.dk/kalender.ics eller webcal://...", + "name_label": "Kalendernavn", + "name_placeholder": "f.eks. Offentlige helligdage", + "color_label": "Farve", + "refresh_interval": "Opdateringsinterval", + "interval_15": "Hvert 15. minut", + "interval_30": "Hvert 30. minut", + "interval_60": "Hver time", + "interval_360": "Hver 6. time", + "interval_1440": "Hver dag", + "subscribe": "Abonnér", + "subscribing": "Abonnerer...", + "save": "Gem ændringer", + "saving": "Gemmer...", + "edit": "Redigér", + "edit_title": "Redigér abonnement", + "updated": "Opdaterede \"{name}\"", + "update_error": "Kunne ikke opdatere abonnement", + "invalid_url": "Indtast en gyldig URL", + "success": "Abonneret på \"{name}\"", + "error": "Kunne ikke tilføje abonnement", + "refresh": "Opdatér nu", + "refresh_success": "Abonnement opdateret", + "refresh_error": "Kunne ikke opdatere abonnement", + "unsubscribe": "Afmeld", + "confirm_delete": "Afmeld \"{name}\"? Kalenderen og alle dens begivenheder fjernes.", + "deleted": "Abonnement fjernet", + "delete_error": "Kunne ikke fjerne abonnement", + "last_refreshed": "Sidst opdateret: {time}" + }, + "tasks": { + "label": "Opgaver", + "no_tasks": "Ingen opgaver", + "no_title": "(Ingen titel)", + "mark_complete": "Markér som fuldført", + "mark_incomplete": "Markér som ikke fuldført", + "filter_all": "Alle", + "filter_pending": "Afventer", + "filter_completed": "Fuldførte", + "filter_overdue": "Forfaldne", + "show_completed": "Vis fuldførte", + "create": "Ny opgave", + "edit": "Redigér opgave", + "title_placeholder": "Opgavetitel", + "description_placeholder": "Tilføj en beskrivelse...", + "due_date": "Forfaldsdato", + "include_time": "Inkludér tid", + "priority": "Prioritet", + "priority_none": "Ingen", + "priority_high": "Høj", + "priority_medium": "Mellem", + "priority_low": "Lav", + "progress": "Status", + "progress_needs_action": "Kræver handling", + "progress_in_process": "I gang", + "progress_completed": "Fuldført", + "progress_cancelled": "Annulleret", + "calendar": "Kalender", + "alert": "Påmindelse", + "alert_none": "Ingen", + "alert_at_time": "På forfaldsdato", + "alert_5min": "5 minutter før", + "alert_15min": "15 minutter før", + "alert_30min": "30 minutter før", + "alert_1hr": "1 time før", + "alert_1day": "1 dag før", + "delete": "Slet", + "cancel": "Annuller", + "save": "Gem", + "quick_add_placeholder": "Tilføj en opgave...", + "due_today": "I dag", + "due_tomorrow": "I morgen", + "overdue": "Forfalden" + } + }, + "sharing": { + "title": "Del \"{name}\"", + "description": "Giv adgang til andre brugere eller grupper på denne server. Ændringer træder i kraft øjeblikkeligt.", + "no_shares": "Ikke delt med nogen endnu.", + "add_person": "Tilføj person eller gruppe", + "search_placeholder": "Søg efter navn eller e-mail…", + "loading_principals": "Indlæser brugere…", + "no_principals": "Ingen andre brugere eller grupper fundet.", + "no_match": "Ingen match.", + "remove": "Fjern adgang", + "group": "Gruppe", + "share_added": "Adgang givet", + "share_updated": "Adgang opdateret", + "share_removed": "Adgang fjernet", + "share_failed": "Kunne ikke opdatere deling", + "preset": { + "freeBusy": "Kun ledig/optaget", + "read": "Kun læsning", + "readWrite": "Læs & skriv", + "manager": "Administrator", + "custom": "Brugerdefineret" + } + }, + "advanced_search": { + "title": "Avanceret søgning", + "from": "Fra", + "from_placeholder": "Afsenders e-mail eller navn", + "to": "Til", + "to_placeholder": "Modtagers e-mail eller navn", + "subject": "Emne", + "subject_placeholder": "Emne indeholder...", + "body": "Brødtekst", + "body_placeholder": "Brødtekst indeholder...", + "folder": "Mappe", + "all_folders": "Alle mapper", + "has_attachment": "Vedhæftninger", + "date_after": "Efter", + "date_before": "Før", + "starred": "Stjernemarkerede", + "unread": "Ulæst", + "read": "Læst", + "yes": "Ja", + "no": "Nej", + "clear": "Ryd", + "clear_all": "Ryd alle", + "filters_active": "{count} filter", + "filters_active_plural": "{count} filtre", + "toggle_filters": "Mere", + "search_hint": "Brug avancerede filtre til præcis søgning", + "advanced_filters_tooltip": "Avancerede søgefiltre", + "results_found": "{count, plural, =0 {Ingen resultater fundet} one {# resultat fundet} other {# resultater fundet}}", + "results_found_more": "{count}+ resultater fundet" + }, + "welcome": { + "title": "Velkommen til din postkasse", + "tip_compose": "Tryk c for at skrive en ny e-mail", + "tip_shortcuts": "Tryk ? for at se alle tastaturgenveje", + "tip_sidebar": "Find Kalender, Kontakter og Indstillinger i sidepanelets menu", + "tip_settings": "Tilpas din oplevelse i Indstillinger", + "got_it": "Forstået", + "settings": "Indstillinger", + "dismiss": "Afvis", + "start_tour": "Start rundvisning" + }, + "demo_welcome": { + "title": "Velkommen til Bulwark Mail", + "description": "Udforsk en fuldt udstyret webmailklient - direkte i din browser. Alle data forbliver på din enhed, så du kan teste alt frit.", + "feature_email": "Læs & skriv e-mail", + "feature_organize": "Tags, stjerner & mapper", + "feature_shortcuts": "Tastaturgenveje", + "feature_privacy": "100% privat demo", + "hint": "Klik på en e-mail til venstre for at komme i gang, eller tag rundvisningen nedenfor." + }, + "files": { + "title": "Filer", + "search_placeholder": "Søg efter filer...", + "empty_state_title": "Ingen filer endnu", + "empty_state_description": "Upload filer eller opret mapper for at komme i gang", + "upload": "Upload", + "upload_files": "Upload filer", + "new_folder": "Ny mappe", + "new_folder_name": "Mappenavn", + "rename": "Omdøb", + "rename_title": "Omdøb", + "new_name": "Nyt navn", + "delete": "Slet", + "delete_confirm_title": "Slet ressource", + "delete_confirm_message": "Er du sikker på, at du vil slette \"{name}\"? Dette kan ikke fortrydes.", + "download": "Download", + "name": "Navn", + "size": "Størrelse", + "modified": "Ændret", + "type": "Type", + "folder": "Mappe", + "file": "Fil", + "parent_directory": "Overordnet mappe", + "breadcrumb_root": "Hjem", + "drop_files_here": "Træk filer eller mapper hertil for at uploade", + "uploading": "Uploader...", + "upload_success": "{count, plural, one {1 fil uploadet} other {# filer uploadet}}", + "upload_error": "Kunne ikke uploade fil", + "create_folder_success": "Mappe oprettet", + "create_folder_error": "Kunne ikke oprette mappe", + "delete_success": "Slettet", + "delete_error": "Kunne ikke slette", + "rename_success": "Omdøbt", + "rename_error": "Kunne ikke omdøbe", + "download_error": "Kunne ikke downloade", + "not_available": "Fillager er ikke tilgængeligt på denne server", + "cancel": "Annuller", + "create": "Opret", + "save": "Gem", + "no_results": "Ingen filer matcher din søgning", + "batch_delete_confirm_message": "Er du sikker på, at du vil slette {count, plural, one {1 element} other {# elementer}}? Dette kan ikke fortrydes.", + "batch_delete_success": "{count, plural, one {1 element slettet} other {# elementer slettet}}", + "grid_view": "Gittervisning", + "list_view": "Listevisning", + "details": "Detaljer", + "path": "Sti", + "preview": "Forhåndsvisning", + "preview_error": "Kunne ikke indlæse forhåndsvisning", + "cut": "Klip", + "copy": "Kopier", + "paste": "Sæt ind", + "move_success": "{count, plural, one {1 element flyttet} other {# elementer flyttet}}", + "move_error": "Kunne ikke flytte", + "paste_success": "Indsat", + "paste_error": "Kunne ikke indsætte", + "new_text_file": "Ny tekstfil", + "file_name": "Filnavn", + "retry": "Prøv igen", + "refresh": "Opdatér", + "toggle_favorite": "Slå favorit til/fra", + "duplicate": "Duplikér", + "duplicate_success": "Duplikeret", + "duplicate_error": "Kunne ikke duplikere", + "create_file_success": "Fil oprettet", + "create_file_error": "Kunne ikke oprette fil", + "favorites": "Favoritter", + "recent": "Seneste", + "properties": "Egenskaber", + "open_folder": "Åbn mappe", + "upload_folder": "Upload mappe", + "file_too_large": "\"{name}\" overstiger den maksimale filstørrelse ({max})", + "undo": "Fortryd", + "undo_success": "Handling fortrydt", + "undo_error": "Kunne ikke fortryde", + "toolbar": "Filhandlinger", + "file_list": "Filer og mapper", + "context_menu": "Handlinger", + "settings_title": "Filindstillinger", + "settings_display": "Visning", + "settings_default_view": "Standardvisning", + "settings_default_view_desc": "Vælg mellem gitter- og listelayout", + "settings_default_sort": "Standardsortering", + "settings_default_sort_desc": "Vælg standardsortering for filer", + "settings_sort_direction": "Sorteringsretning", + "settings_sort_direction_desc": "Vælg stigende eller faldende rækkefølge", + "settings_ascending": "Stigende", + "settings_descending": "Faldende", + "settings_icons": "Ikoner", + "settings_show_icons": "Vis filikoner", + "settings_show_icons_desc": "Vis ikoner ved siden af filer og mapper", + "settings_colored_icons": "Farvede ikoner", + "settings_colored_icons_desc": "Brug farverige ikoner i stedet for monokrome", + "settings_show_thumbnails": "Vis miniaturer", + "settings_show_thumbnails_desc": "Vis billedforhåndsvisninger i stedet for ikoner for billedfiler", + "settings_behavior": "Adfærd", + "settings_show_hidden": "Vis skjulte filer", + "settings_show_hidden_desc": "Vis filer og mapper der begynder med et punktum", + "settings_folder_layout": "Mappenavigation", + "settings_folder_layout_desc": "Vælg hvordan mapper vises: integreret med filer eller i et sidepanel-træ", + "settings_folder_layout_inline": "Integreret", + "settings_folder_layout_sidebar": "Sidepanel", + "disabled_title": "Filer-funktionen er deaktiveret af din administrator", + "disabled_description": "Store filuploads via WebDAV kan forårsage Stalwart/RocksDB-ustabilitet, herunder hukommelsessvigt og uopretteligt diskforbrug. Slettede filer fjernes muligvis ikke straks fra blob-lageret. Denne funktion anbefales ikke til produktionsmiljøer.", + "stability_warning": "Store filuploads kan forårsage serverustabilitet. Slettede filer fjernes muligvis ikke straks fra lageret. Brug med forsigtighed." + }, + "smime": { + "your_certificates": "Dine certifikater", + "your_certificates_desc": "Importér og administrér dine S/MIME-certifikater til signering og kryptering af e-mails", + "recipient_certificates": "Modtagercertifikater", + "recipient_certificates_desc": "Offentlige certifikater til kryptering af e-mails til modtagere", + "identity_bindings": "Identitetsnøglebindinger", + "identity_bindings_desc": "Bind S/MIME-certifikater til dine e-mail-identiteter", + "defaults_title": "Standarder", + "defaults_desc": "Konfigurér standard signerings- og krypteringsadfærd", + "import_pkcs12": "Importér PKCS#12 (.p12/.pfx)", + "import_public_cert": "Importér certifikat", + "no_certificates": "Ingen certifikater importeret endnu", + "no_recipient_certs": "Ingen modtagercertifikater", + "expires": "Udløber", + "expired": "Udløbet", + "bound_to": "Bundet til", + "no_key_bound": "Ingen", + "lock": "Lås nøgle", + "unlock": "Lås nøgle op", + "details": "Vis detaljer", + "delete": "Slet", + "encrypt_by_default": "Kryptér som standard", + "encrypt_by_default_desc": "Kryptér automatisk e-mails når alle modtagere har certifikater", + "remember_unlocked": "Husk oplåste nøgler", + "remember_unlocked_desc": "Hold nøgler oplåst i denne browsersessions varighed", + "sign_default_for": "Signér som standard for", + "enter_p12_passphrase": "Indtast PKCS#12-adgangssætning", + "p12_passphrase_desc": "Indtast adgangssætningen der beskytter denne certifikatfil", + "enter_storage_passphrase": "Angiv lager-adgangssætning", + "storage_passphrase_desc": "Vælg en adgangssætning for at beskytte denne nøgle på din enhed", + "next": "Næste", + "import": "Importér", + "unlock_key": "Lås nøgle op", + "unlock_key_desc": "Indtast lager-adgangssætningen for at låse denne nøgle op til signering eller dekryptering", + "passphrase_placeholder": "Indtast adgangssætning", + "confirm_passphrase_placeholder": "Bekræft adgangssætning", + "passphrase_mismatch": "Adgangssætningerne er ikke ens", + "cancel": "Annuller", + "processing": "Behandler…", + "close": "Luk", + "certificate_details": "Certifikatdetaljer", + "cert_subject": "Emne", + "cert_issuer": "Udsteder", + "cert_email": "E-mail", + "cert_serial": "Serienummer", + "cert_validity": "Gyldighed", + "cert_fingerprint": "Fingeraftryk (SHA-256)", + "cert_algorithm": "Algoritme", + "cert_capabilities": "Funktioner", + "cert_source": "Kilde", + "cert_expired": "Dette certifikat er udløbet", + "cert_not_yet_valid": "Dette certifikat er endnu ikke gyldigt", + "cap_sign": "Signering", + "cap_encrypt": "Kryptering", + "cap_none": "Ingen", + "show_passphrase": "Vis adgangssætning", + "hide_passphrase": "Skjul adgangssætning", + "sign_toggle": "Signér", + "encrypt_toggle": "Kryptér", + "missing_recipient_certs": "Manglende certifikater for: {emails}", + "missing_sender_cert": "Intet certifikat bundet til denne identitet", + "status_encrypted_ok": "Denne besked blev krypteret", + "status_encrypted_no_key": "Denne besked er krypteret, men ingen matchende nøgle blev fundet", + "status_encrypted_failed": "Kunne ikke dekryptere denne besked", + "status_signed_valid": "Signatur bekræftet", + "status_signed_invalid": "Signaturbekræftelse mislykkedes", + "status_signed_expired_cert": "Signeret med et udløbet certifikat", + "status_signed_self_signed": "Signatur gyldig, men certifikatet er selvsigneret (ikke betroet)", + "status_signed_mismatch": "Signatur gyldig, men underskriver matcher ikke afsender", + "status_unsupported": "Ikke-understøttet S/MIME-format", + "auto_import_signer_certs": "Auto-importér underskriver-certifikater", + "auto_import_signer_certs_desc": "Gem automatisk certifikater fra bekræftede signerede e-mails til fremtidig kryptering", + "export": "Eksportér", + "enter_export_passphrase": "Angiv eksport-adgangssætning", + "export_passphrase_desc": "Vælg en adgangssætning for at beskytte den eksporterede PKCS#12-fil", + "export_storage_desc": "Indtast lager-adgangssætningen for at dekryptere nøglen til eksport", + "incorrect_passphrase": "Forkert adgangssætning" + }, + "tour": { + "step_counter": "Trin {current} af {total}", + "skip": "Spring rundvisning over", + "back": "Tilbage", + "next": "Næste", + "finish": "Afslut", + "take_a_tour": "Tag en rundvisning af brugergrænsefladen", + "restart_title": "Introduktionsrundvisning", + "restart_desc": "Afspil den guidede gennemgang af brugergrænsefladen", + "restart_button": "Start rundvisning igen", + "sidebar_title": "Dine postkasser", + "sidebar_desc": "Dette er dit mappesidepanel. Klik på en postkasse for at se dens e-mails. Du kan oprette mapper, trække e-mails mellem dem og se ulæste antal på et øjeblik.", + "compose_title": "Skriv en e-mail", + "compose_desc": "Klik her for at skrive en ny e-mail. Du kan tilføje modtagere, vedhæftninger og bruge rig tekstformatering.", + "search_title": "Søg i din mail", + "search_desc": "Søg efter afsender, emne eller indhold. Klik på filterikonet for avancerede muligheder som datoperiode, vedhæftninger og stjernemarkerede beskeder.", + "email_list_title": "Din e-mail-liste", + "email_list_desc": "E-mails vises her. Klik på en for at læse den til højre. Brug afkrydsningsfeltet til at vælge flere, og masseflyt, -slet eller -tag dem.", + "email_viewer_title": "Læserude", + "email_viewer_desc": "Den valgte e-mail åbnes her. Svar, videresend, arkivér eller slet med værktøjslinjeknapperne. Du kan også stjernemarkere e-mails eller tilføje farvetags.", + "keywords_title": "Farvetags", + "keywords_desc": "Organisér din e-mail med farvekodede tags. Træk en e-mail til et tag for at mærke den, eller højreklik på en e-mail for at tildele tags.", + "calendar_title": "Kalender", + "calendar_desc": "Skift til kalenderen for at administrere dine begivenheder. Opret begivenheder, sæt påmindelser, og se dag-, uge- eller månedslayout.", + "contacts_title": "Kontakter", + "contacts_desc": "Din adressebog bor her. Importér kontakter, opret grupper, og klik på en kontakt for at se deres fulde detaljer.", + "settings_title": "Indstillinger", + "settings_desc": "Tilpas alt: tema, tæthed, signaturer, filtre, tastaturgenveje, kalenderstandarder og meget mere.", + "shortcuts_title": "Tastaturgenveje", + "shortcuts_desc": "Strømbrugere elsker dette. Tryk ? når som helst for at se alle tilgængelige genveje. Du kan navigere, skrive og administrere e-mails uden at røre musen.", + "compose_open_title": "Komponisten", + "compose_open_desc": "Dette er e-mail-komponisten. Tilføj modtagere, skriv din besked, vedhæft filer, og brug rig tekstformatering. Du kan også gemme kladder og bruge skabeloner.", + "calendar_view_title": "Din kalender", + "calendar_view_desc": "Her er din kalender med eksempelbegivenheder. Du kan skifte mellem dag-, uge-, måneds- og agendavisning ved hjælp af værktøjslinjen.", + "create_event_title": "Opret en begivenhed", + "create_event_desc": "Klik på denne knap for at oprette en ny kalenderbegivenhed. Du kan angive en titel, dato, tid og tilføje deltagere.", + "event_modal_title": "Begivenhedsdetaljer", + "event_modal_desc": "Her er begivenhedsformularen. Udfyld titlen, vælg dato og tidspunkt, tilføj et sted eller deltagere. Tryk gem, når du er færdig - eller luk den og gå videre.", + "contacts_list_title": "Dine kontakter", + "contacts_list_desc": "Her er dine kontakter. Klik på en kontakt for at se fulde detaljer til højre. Du kan også oprette nye kontakter, importere vCards eller organisere kontakter i grupper.", + "settings_tabs_title": "Indstillingsmenu", + "settings_tabs_desc": "Her er alle indstillingskategorier. Tilpas dit udseende, administrér identiteter, opsæt e-mail-filtre, konfigurér din kalender og meget mere.", + "files_title": "Fillager", + "files_desc": "Din filbrowser lader dig uploade, organisere og dele filer - som en personlig cloud-drev indbygget i din mail.", + "demo_banner_title": "Demo-kontroller", + "demo_banner_desc": "Du er i demo-tilstand - alt forbliver i din browser. Tryk 'Nulstil demo' når som helst for at starte forfra med rent eksempeldata.", + "quota_title": "Lagerforbrug", + "quota_desc": "Spor din postkassestørrelse her. Cirklen fyldes op, efterhånden som du bruger mere plads." + }, + "unified_mailbox": { + "search_unavailable": "Søgning er ikke tilgængelig i den samlede visning" + } +} \ No newline at end of file From cf993c10363ca751d56c685b44ae7de4b8c9d828 Mon Sep 17 00:00:00 2001 From: Jesper Ordrup Date: Fri, 15 May 2026 11:39:57 +0200 Subject: [PATCH 052/133] adjust flag --- components/ui/flag-icons.tsx | 3 ++- components/ui/language-switcher.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/components/ui/flag-icons.tsx b/components/ui/flag-icons.tsx index 03d57b30..417bf1f6 100644 --- a/components/ui/flag-icons.tsx +++ b/components/ui/flag-icons.tsx @@ -202,7 +202,7 @@ export function FlagCS(props: FlagProps) { } /** Danish flag - Red with a white cross */ -export function FlagDa(props: FlagProps) { +export function FlagDA(props: FlagProps) { return ( @@ -229,4 +229,5 @@ export const flagComponents: Record ReactElement> uk: FlagUA, zh: FlagCN, cs: FlagCS, + da: FlagDA }; diff --git a/components/ui/language-switcher.tsx b/components/ui/language-switcher.tsx index 0cb7ce9c..912d571a 100644 --- a/components/ui/language-switcher.tsx +++ b/components/ui/language-switcher.tsx @@ -13,7 +13,7 @@ const languages = [ { value: 'fr', label: 'Français' }, { value: 'ja', label: '日本語' }, { value: 'ko', label: '한국어' }, - { value: 'da', label: 'Danish' }, + { value: 'da', label: 'Dansk' }, { value: 'de', label: 'Deutsch' }, { value: 'es', label: 'Español' }, { value: 'it', label: 'Italiano' }, From e700e4fd04c2de97d1d7a84c54267051f6437a48 Mon Sep 17 00:00:00 2001 From: Jesper Ordrup Date: Fri, 15 May 2026 12:42:46 +0200 Subject: [PATCH 053/133] match any translation --- locales/da/common.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locales/da/common.json b/locales/da/common.json index 60431992..2ecf8653 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1507,7 +1507,7 @@ "rule_name": "Regelnavn", "rule_name_placeholder": "f.eks. Sorter nyhedsbreve", "match_all": "Match ALLE betingelser", - "match_any": "Match ENHVER betingelse", + "match_any": "Match en af betingelserne", "conditions": "Betingelser", "add_condition": "Tilføj betingelse", "actions": "Handlinger", From d1a0667c79640eaea1e5e77ea9647c329c6c2e09 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 15 May 2026 14:31:24 +0200 Subject: [PATCH 054/133] i18n: clean up Danish locale wiring and sort language lists 286 --- FEATURES.md | 2 +- components/providers/intl-provider.tsx | 16 ++++++++-------- components/ui/flag-icons.tsx | 18 +++++++++--------- components/ui/language-switcher.tsx | 10 +++++----- i18n/request.ts | 8 ++++---- i18n/routing.ts | 2 +- 6 files changed, 28 insertions(+), 28 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index a7cf5459..fb6e2f3e 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -98,7 +98,7 @@ ## Internationalization -15 languages: English · Français · 日本語 · Español · Italiano · Danish · Deutsch · Nederlands · Português · Русский · Türkçe · 한국어 · Polski · Latviešu · 简体中文 · Українська +17 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文 Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`. diff --git a/components/providers/intl-provider.tsx b/components/providers/intl-provider.tsx index 8f4598e7..60193c61 100644 --- a/components/providers/intl-provider.tsx +++ b/components/providers/intl-provider.tsx @@ -4,13 +4,14 @@ import { useEffect, useState } from 'react'; import { NextIntlClientProvider } from 'next-intl'; import { useLocaleStore } from '@/stores/locale-store'; import csMessages from '@/locales/cs/common.json'; +import daMessages from '@/locales/da/common.json'; +import deMessages from '@/locales/de/common.json'; import enMessages from '@/locales/en/common.json'; +import esMessages from '@/locales/es/common.json'; import frMessages from '@/locales/fr/common.json'; +import itMessages from '@/locales/it/common.json'; import jaMessages from '@/locales/ja/common.json'; import koMessages from '@/locales/ko/common.json'; -import esMessages from '@/locales/es/common.json'; -import itMessages from '@/locales/it/common.json'; -import deMessages from '@/locales/de/common.json'; import lvMessages from '@/locales/lv/common.json'; import nlMessages from '@/locales/nl/common.json'; import plMessages from '@/locales/pl/common.json'; @@ -19,18 +20,18 @@ import ruMessages from '@/locales/ru/common.json'; import trMessages from '@/locales/tr/common.json'; import ukMessages from '@/locales/uk/common.json'; import zhMessages from '@/locales/zh/common.json'; -import daMessages from '@/locales/da/common.json'; // Pre-loaded translations (loaded at build time, not runtime) const ALL_MESSAGES = { cs: csMessages, + da: daMessages, + de: deMessages, en: enMessages, + es: esMessages, fr: frMessages, + it: itMessages, ja: jaMessages, ko: koMessages, - es: esMessages, - it: itMessages, - de: deMessages, lv: lvMessages, nl: nlMessages, pl: plMessages, @@ -39,7 +40,6 @@ const ALL_MESSAGES = { tr: trMessages, uk: ukMessages, zh: zhMessages, - da: daMessages }; interface IntlProviderProps { diff --git a/components/ui/flag-icons.tsx b/components/ui/flag-icons.tsx index 417bf1f6..75790cda 100644 --- a/components/ui/flag-icons.tsx +++ b/components/ui/flag-icons.tsx @@ -201,25 +201,27 @@ export function FlagCS(props: FlagProps) { ); } -/** Danish flag - Red with a white cross */ -export function FlagDA(props: FlagProps) { +/** Denmark – Red with a white Nordic cross */ +export function FlagDK(props: FlagProps) { return ( - - + + ); } /** Map locale codes to flag components */ export const flagComponents: Record ReactElement> = { + cs: FlagCS, + da: FlagDK, + de: FlagDE, en: FlagGB, + es: FlagES, fr: FlagFR, + it: FlagIT, ja: FlagJP, ko: FlagKR, - es: FlagES, - it: FlagIT, - de: FlagDE, lv: FlagLV, nl: FlagNL, pl: FlagPL, @@ -228,6 +230,4 @@ export const flagComponents: Record ReactElement> tr: FlagTR, uk: FlagUA, zh: FlagCN, - cs: FlagCS, - da: FlagDA }; diff --git a/components/ui/language-switcher.tsx b/components/ui/language-switcher.tsx index 912d571a..c0b197d6 100644 --- a/components/ui/language-switcher.tsx +++ b/components/ui/language-switcher.tsx @@ -9,21 +9,21 @@ import { flagComponents } from './flag-icons'; const languages = [ { value: 'cs', label: 'Česky' }, - { value: 'en', label: 'English' }, - { value: 'fr', label: 'Français' }, - { value: 'ja', label: '日本語' }, - { value: 'ko', label: '한국어' }, { value: 'da', label: 'Dansk' }, { value: 'de', label: 'Deutsch' }, + { value: 'en', label: 'English' }, { value: 'es', label: 'Español' }, + { value: 'fr', label: 'Français' }, { value: 'it', label: 'Italiano' }, { value: 'lv', label: 'Latviešu' }, { value: 'nl', label: 'Nederlands' }, { value: 'pl', label: 'Polski' }, { value: 'pt', label: 'Português' }, - { value: 'ru', label: 'Русский' }, { value: 'tr', label: 'Türkçe' }, + { value: 'ru', label: 'Русский' }, { value: 'uk', label: 'Українська' }, + { value: 'ko', label: '한국어' }, + { value: 'ja', label: '日本語' }, { value: 'zh', label: '简体中文' }, ]; diff --git a/i18n/request.ts b/i18n/request.ts index 2fe7fa26..24ad0008 100644 --- a/i18n/request.ts +++ b/i18n/request.ts @@ -14,18 +14,18 @@ export default getRequestConfig(async ({ requestLocale }) => { case 'cs': messages = (await import('../locales/cs/common.json')).default; break; - case 'fr': - messages = (await import('../locales/fr/common.json')).default; - break; case 'da': messages = (await import('../locales/da/common.json')).default; - break; + break; case 'de': messages = (await import('../locales/de/common.json')).default; break; case 'es': messages = (await import('../locales/es/common.json')).default; break; + case 'fr': + messages = (await import('../locales/fr/common.json')).default; + break; case 'it': messages = (await import('../locales/it/common.json')).default; break; diff --git a/i18n/routing.ts b/i18n/routing.ts index fae1da1f..8bea497e 100644 --- a/i18n/routing.ts +++ b/i18n/routing.ts @@ -13,7 +13,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as | 'as-needed'; export const routing = defineRouting({ - locales: ['cs', 'en', 'fr', 'da', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'], + locales: ['cs', 'da', 'de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'], defaultLocale: 'en', localePrefix }); From d5dddba6df9e7507378e07b9479c6e1cc61a4c64 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 15 May 2026 14:41:57 +0200 Subject: [PATCH 055/133] fix: hide Files settings/nav when filesEnabled policy is off #291 --- app/[locale]/settings/page.tsx | 2 +- app/admin/layout.tsx | 36 ++++++++++++++++++++-------------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index da3de6fa..99f4af15 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -590,7 +590,7 @@ export default function SettingsPage() { // Apps ...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []), { id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' }, - ...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []), + ...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []), ...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []), // Advanced diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 687fd4be..a792703a 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -27,6 +27,7 @@ import { } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useConfig } from '@/hooks/use-config'; +import { usePolicyStore } from '@/stores/policy-store'; import { useThemeStore } from '@/stores/theme-store'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; @@ -87,6 +88,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false); const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig(); + const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl) @@ -300,13 +302,15 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) > - - - + {filesEnabled && ( + + + + )}
@@ -440,14 +444,16 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) Contacts - - - Files - + {filesEnabled && ( + + + Files + + )}
Date: Fri, 15 May 2026 14:05:17 +0200 Subject: [PATCH 056/133] feat: allow img in HTML identity signatures - Restricts src to https: URLs or base64-embedded raster data: URIs (png/jpeg/gif/webp). - SVG is excluded for safety reasons. - Images with a disallowed src are removed entirely so they don't render as broken-image icons. --- lib/__tests__/email-sanitization.test.ts | 73 +++++++++++++++++++++--- lib/email-sanitization.ts | 27 +++++++-- 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/lib/__tests__/email-sanitization.test.ts b/lib/__tests__/email-sanitization.test.ts index f36c5b50..1f9c2cb2 100644 --- a/lib/__tests__/email-sanitization.test.ts +++ b/lib/__tests__/email-sanitization.test.ts @@ -78,11 +78,67 @@ describe('email-sanitization', () => { expect(clean).toContain('John Doe'); }); - it('should remove images from signatures', () => { - const signature = '

John

Logo'; + it('should allow img with https src', () => { + const signature = '

John

Logo'; const clean = sanitizeSignatureHtml(signature); + expect(clean).toContain(' { + const dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/AAAZ4gk3AAAAAXRSTlPM0jRW/QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAA1JREFUCNdjYGBgAAAABAABc7Rs9wAAAABJRU5ErkJggg=='; + const signature = `Logo`; + const clean = sanitizeSignatureHtml(signature); + expect(clean).toContain(' { + const cases = ['data:image/jpeg;base64,AAA', 'data:image/jpg;base64,AAA', 'data:image/gif;base64,AAA', 'data:image/webp;base64,AAA']; + for (const src of cases) { + const clean = sanitizeSignatureHtml(`x`); + expect(clean).toContain(' { + const signature = 'Logo'; + const clean = sanitizeSignatureHtml(signature); + expect(clean).not.toContain('http://insecure.example.com'); expect(clean).not.toContain(' { + const signature = 'x'; + const clean = sanitizeSignatureHtml(signature); + expect(clean).not.toContain('javascript:'); + expect(clean).not.toContain(' { + const signature = 'x'; + const clean = sanitizeSignatureHtml(signature); + expect(clean).not.toContain('data:image/svg'); + expect(clean).not.toContain(' { + const signature = 'x'; + const clean = sanitizeSignatureHtml(signature); + expect(clean).not.toContain('data:text/html'); + expect(clean).not.toContain(' { + const signature = 'x'; + const clean = sanitizeSignatureHtml(signature); + expect(clean).not.toContain('onerror'); + expect(clean).not.toContain('onload'); + expect(clean).toContain('https://cdn.example.com/logo.png'); }); it('should remove video and audio tags', () => { @@ -113,16 +169,17 @@ describe('email-sanitization', () => { }); it('should be stricter than email sanitization', () => { - const html = '

Text

Data
'; + const html = '

Text

Data
'; const emailClean = sanitizeEmailHtml(html); const signatureClean = sanitizeSignatureHtml(html); - // Email allows img and table - expect(emailClean).toContain(''); - // Signature blocks img but may allow some tables (verify in implementation) - expect(signatureClean).not.toContain(' for company logos */ export const SIGNATURE_SANITIZE_CONFIG = { - ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div'], - ALLOWED_ATTR: ['href', 'style', 'class'], + ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div', 'img'], + ALLOWED_ATTR: ['href', 'style', 'class', 'src', 'alt', 'width', 'height', 'title'], ALLOW_DATA_ATTR: false, - FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'img', 'video', 'audio'], + FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'video', 'audio'], FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'], }; /** - * Sanitize HTML signature for storage and display + * Sanitize HTML signature for storage and display. + * img src is restricted to https: or base64-embedded raster data: URIs + * (png/jpeg/gif/webp). SVG is excluded because DOMPurify cannot inspect + * bytes inside a data: URI. Images with a disallowed src are removed + * entirely so they don't render as broken-image icons. * @param html - User-provided HTML signature * @returns Sanitized signature (no scripts, no external resources) */ export function sanitizeSignatureHtml(html: string): string { if (!html?.trim()) return ''; - return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG); + DOMPurify.addHook('afterSanitizeAttributes', (node) => { + if (node.tagName !== 'IMG') return; + const src = node.getAttribute('src'); + if (!src || !/^(?:https:\/\/|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(src)) { + node.remove(); + } + }); + try { + return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG); + } finally { + DOMPurify.removeAllHooks(); + } } /** From 4b7009dfc2d77343c4f6c96aab5505b02d6250a4 Mon Sep 17 00:00:00 2001 From: Timo Streule <48442147+tstreule@users.noreply.github.com> Date: Fri, 15 May 2026 14:05:38 +0200 Subject: [PATCH 057/133] feat: raise HTML signature length cap to 50000 chars 5000 chars is too tight for signatures containing base64-embedded images (even a small PNG can run a few thousand chars). --- components/identity/identity-form.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/identity/identity-form.tsx b/components/identity/identity-form.tsx index 25e7dc55..71cd5313 100644 --- a/components/identity/identity-form.tsx +++ b/components/identity/identity-form.tsx @@ -263,7 +263,7 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)