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) => (
+
+ ))}
+
+
+ );
+}
+
+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 (
+
+ );
+}
+
+function ErrorBanner({ error, onDismiss }: { error: string; onDismiss: () => void }) {
+ return (
+
+ {error}
+
+ dismiss
+
+
+ );
+}
+
+// ─── 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 (
+
+ );
+}
+
+// ─── 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 (
+
+ );
+}
+
+// ─── 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 (
+
+ );
+}
+
+// ─── 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 (
+
+ );
+}
+
+// ─── 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 (
+
+ );
+}
+
+// ─── 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 (
+
+ );
+}
+
+// ─── 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 (
+
+ );
+}
+
+// ─── 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 (
+
+
{label}
+ {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 (
+ onChange(e.target.value)}
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ >
+ {options.map((o) => (
+
+ {o.label}
+
+ ))}
+
+ );
+}
+
+function Toggle({
+ checked,
+ onChange,
+ label,
+ hint,
+ disabled,
+}: {
+ checked: boolean;
+ onChange: (v: boolean) => void;
+ label: string;
+ hint?: string;
+ disabled?: boolean;
+}) {
+ return (
+
+ onChange(e.target.checked)}
+ className="mt-1 h-4 w-4"
+ />
+
+
{label}
+ {hint &&
{hint}
}
+
+
+ );
+}
+
+function Footer({ children }: { children: ReactNode }) {
+ return {children}
;
+}
+
+function PrimaryButton({ children, ...rest }: React.ButtonHTMLAttributes) {
+ return (
+
+ {children}
+
+ );
+}
+
+function SecondaryButton({ children, onClick, disabled }: { children: ReactNode; onClick: () => void; disabled?: boolean }) {
+ return (
+
+ {children}
+
+ );
+}
+
+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"
/>
{ void testJmap(); }}
disabled={!config.jmapServerUrl || probing}
className="px-3 py-2 text-sm border border-border rounded-md hover:bg-muted disabled:opacity-50"
>
{probing ? 'Testing…' : 'Test'}
- {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.
+
+
+ setConfirmedNonJmap(e.target.checked)}
+ />
+ I'm sure this is the right URL — continue anyway.
+
+
+ ) : 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.
-
-
+ probe.status === 'jmap_detected' ? (
+
+ ) : probe.status === 'reachable_no_jmap' ? (
+
+
+
+
+
{probe.message}
+
+ This can happen when a reverse proxy routes JMAP separately on the same domain. Otherwise, it usually means the URL is wrong.
+
+
+
+
setConfirmedNonJmap(e.target.checked)}
+ className="h-4 w-4"
/>
- I'm sure this is the right URL — continue anyway.
+ I'm sure this is the right URL — continue anyway.
- ) : probe.status === 'jmap_detected' ? (
- ✓ {probe.message}
) : (
- {probe.message}
+
)
)}
From 876ea370e4ca325928bff739375ab1ffa10f136e Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 9 May 2026 21:40:39 +0200
Subject: [PATCH 019/133] feat: allow file uploads on the wizard branding step
---
app/api/admin/branding/[filename]/route.ts | 11 +-
app/api/admin/branding/route.ts | 13 +-
app/api/setup/branding/route.ts | 167 +++++++++++++++
app/setup/page.tsx | 229 +++++++++++++++++++--
proxy.ts | 3 +
5 files changed, 392 insertions(+), 31 deletions(-)
create mode 100644 app/api/setup/branding/route.ts
diff --git a/app/api/admin/branding/[filename]/route.ts b/app/api/admin/branding/[filename]/route.ts
index d4994c50..1e05bc64 100644
--- a/app/api/admin/branding/[filename]/route.ts
+++ b/app/api/admin/branding/[filename]/route.ts
@@ -1,8 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
+import { getConfigDir } from '@/lib/admin/paths';
-const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding');
+function getBrandingDir(): string {
+ return path.join(getConfigDir(), 'branding');
+}
const MIME_TYPES: Record = {
'.svg': 'image/svg+xml',
@@ -38,11 +41,11 @@ export async function GET(
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 });
}
- const filePath = path.join(BRANDING_DIR, safe);
+ const filePath = path.join(getBrandingDir(), safe);
- // Ensure resolved path is still within BRANDING_DIR
+ // Ensure resolved path is still within getBrandingDir()
const resolved = path.resolve(filePath);
- if (!resolved.startsWith(path.resolve(BRANDING_DIR))) {
+ if (!resolved.startsWith(path.resolve(getBrandingDir()))) {
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
}
diff --git a/app/api/admin/branding/route.ts b/app/api/admin/branding/route.ts
index a538e565..9057bdc1 100644
--- a/app/api/admin/branding/route.ts
+++ b/app/api/admin/branding/route.ts
@@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { configManager } from '@/lib/admin/config-manager';
+import { getConfigDir } from '@/lib/admin/paths';
import { logger } from '@/lib/logger';
import { writeFile, unlink, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
-const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding');
+function getBrandingDir(): string {
+ return path.join(getConfigDir(), 'branding');
+}
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
const ALLOWED_MIME_TYPES = new Set([
'image/svg+xml',
@@ -79,11 +82,11 @@ export async function POST(request: NextRequest) {
};
const ext = extMap[file.type] || '.png';
const safeName = sanitizeFilename(`${slot}${ext}`);
- const filePath = path.join(BRANDING_DIR, safeName);
+ const filePath = path.join(getBrandingDir(), safeName);
// Ensure branding directory exists
- if (!existsSync(BRANDING_DIR)) {
- await mkdir(BRANDING_DIR, { recursive: true });
+ if (!existsSync(getBrandingDir())) {
+ await mkdir(getBrandingDir(), { recursive: true });
}
// Write file to disk
@@ -125,7 +128,7 @@ export async function DELETE(request: NextRequest) {
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
let removed = false;
for (const ext of possibleExts) {
- const filePath = path.join(BRANDING_DIR, `${slot}${ext}`);
+ const filePath = path.join(getBrandingDir(), `${slot}${ext}`);
if (existsSync(filePath)) {
await unlink(filePath);
removed = true;
diff --git a/app/api/setup/branding/route.ts b/app/api/setup/branding/route.ts
new file mode 100644
index 00000000..f8ddb2ce
--- /dev/null
+++ b/app/api/setup/branding/route.ts
@@ -0,0 +1,167 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { writeFile, unlink, mkdir } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import path from 'node:path';
+import { detectSetupState } from '@/lib/setup/state';
+import { authenticateWizardRequest } from '@/lib/setup/session';
+import { configManager } from '@/lib/admin/config-manager';
+import { getConfigDir, assertWritable } from '@/lib/admin/paths';
+import { logger } from '@/lib/logger';
+
+export const dynamic = 'force-dynamic';
+
+const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
+
+const ALLOWED_MIME_TYPES = new Set([
+ 'image/svg+xml',
+ 'image/png',
+ 'image/jpeg',
+ 'image/webp',
+ 'image/x-icon',
+ 'image/vnd.microsoft.icon',
+]);
+
+const VALID_SLOTS = new Set([
+ 'faviconUrl',
+ 'appLogoLightUrl',
+ 'appLogoDarkUrl',
+ 'loginLogoLightUrl',
+ 'loginLogoDarkUrl',
+]);
+
+const EXT_BY_MIME: Record = {
+ 'image/svg+xml': '.svg',
+ 'image/png': '.png',
+ 'image/jpeg': '.jpg',
+ 'image/webp': '.webp',
+ 'image/x-icon': '.ico',
+ 'image/vnd.microsoft.icon': '.ico',
+};
+
+function getBrandingDir(): string {
+ return path.join(getConfigDir(), 'branding');
+}
+
+function sanitizeFilename(name: string): string {
+ return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
+}
+
+/**
+ * POST /api/setup/branding — wizard branding upload.
+ *
+ * Multipart form fields:
+ * file — the image (SVG/PNG/JPEG/WebP/ICO, max 2 MB)
+ * slot — which branding key (faviconUrl, loginLogoLightUrl, etc.)
+ *
+ * Mirrors /api/admin/branding but authenticates via the wizard cookie
+ * instead of admin session — admin auth doesn't exist yet during bootstrap.
+ * Files land in the same directory; the public read endpoint at
+ * /api/admin/branding/ serves both wizard- and admin-uploaded
+ * assets after setup.
+ */
+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 });
+ }
+
+ try {
+ assertWritable('upload branding asset');
+
+ const formData = await request.formData();
+ const file = formData.get('file');
+ const slot = formData.get('slot');
+
+ if (!(file instanceof File) || typeof slot !== 'string') {
+ return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
+ }
+ if (!VALID_SLOTS.has(slot)) {
+ return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
+ }
+ if (file.size > MAX_FILE_SIZE) {
+ return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
+ }
+ if (!ALLOWED_MIME_TYPES.has(file.type)) {
+ return NextResponse.json(
+ { error: `Unsupported file type: ${file.type}. Allowed: SVG, PNG, JPEG, WebP, ICO` },
+ { status: 400 },
+ );
+ }
+
+ const ext = EXT_BY_MIME[file.type] ?? '.png';
+ const safeName = sanitizeFilename(`${slot}${ext}`);
+
+ const dir = getBrandingDir();
+ if (!existsSync(dir)) {
+ await mkdir(dir, { recursive: true });
+ }
+
+ // Remove any existing file for this slot with a different extension so
+ // the wizard doesn't leave orphan files behind on re-upload.
+ for (const otherExt of Object.values(EXT_BY_MIME)) {
+ if (otherExt === ext) continue;
+ const oldPath = path.join(dir, `${slot}${otherExt}`);
+ if (existsSync(oldPath)) {
+ try { await unlink(oldPath); } catch { /* ignore */ }
+ }
+ }
+
+ const buffer = Buffer.from(await file.arrayBuffer());
+ const filePath = path.join(dir, safeName);
+ await writeFile(filePath, buffer);
+
+ const servedUrl = `/api/admin/branding/${safeName}`;
+ await configManager.ensureLoaded();
+ await configManager.setAdminConfig({ [slot]: servedUrl });
+
+ return NextResponse.json({ url: servedUrl, filename: safeName });
+ } catch (error) {
+ logger.error('Wizard branding upload failed', {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
+ }
+}
+
+/**
+ * DELETE /api/setup/branding — remove an uploaded asset and clear the
+ * config override so the slot falls back to the system default.
+ *
+ * Body: { slot: string }
+ */
+export async function DELETE(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 });
+ }
+
+ try {
+ assertWritable('remove branding asset');
+ const { slot } = (await request.json()) as { slot?: string };
+ if (!slot || !VALID_SLOTS.has(slot)) {
+ return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
+ }
+
+ const dir = getBrandingDir();
+ for (const ext of Object.values(EXT_BY_MIME)) {
+ const filePath = path.join(dir, `${slot}${ext}`);
+ if (existsSync(filePath)) {
+ try { await unlink(filePath); } catch { /* ignore */ }
+ }
+ }
+
+ await configManager.ensureLoaded();
+ await configManager.removeAdminOverride(slot);
+
+ return NextResponse.json({ ok: true });
+ } catch (error) {
+ logger.error('Wizard branding delete failed', {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return NextResponse.json({ error: 'Delete failed' }, { status: 500 });
+ }
+}
diff --git a/app/setup/page.tsx b/app/setup/page.tsx
index 77c7da5c..1db6ebfd 100644
--- a/app/setup/page.tsx
+++ b/app/setup/page.tsx
@@ -1010,17 +1010,23 @@ function LoggingStep({ 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.
+ // Only send fields with a value. Empty strings would create an admin
+ // override that shadows the system default and suppress the bundled
+ // Bulwark logo on the login page.
const allFields = {
faviconUrl: config.faviconUrl,
appLogoLightUrl: config.appLogoLightUrl,
@@ -1041,29 +1047,57 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick
-
+
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, faviconUrl: v })}
+ />
+ setConfig({ ...config, loginLogoLightUrl: v })}
+ />
+ setConfig({ ...config, loginLogoDarkUrl: v })}
+ previewBg="dark"
+ />
+ setConfig({ ...config, appLogoLightUrl: v })}
+ />
+ setConfig({ ...config, appLogoDarkUrl: v })}
+ previewBg="dark"
+ />
+
setConfig({ ...config, loginWebsiteUrl: v })} type="url" />
@@ -1083,6 +1117,157 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick
void;
+ previewBg?: 'light' | 'dark';
+}) {
+ const [uploading, setUploading] = useState(false);
+ const [uploadError, setUploadError] = useState(null);
+ const [showUrlField, setShowUrlField] = useState(false);
+ const [dragOver, setDragOver] = useState(false);
+
+ async function handleFile(file: File) {
+ setUploadError(null);
+ setUploading(true);
+ try {
+ const fd = new FormData();
+ fd.append('file', file);
+ fd.append('slot', slot);
+ const res = await apiFetch('/api/setup/branding', {
+ method: 'POST',
+ body: fd,
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ setUploadError(data?.error ?? `Upload failed (HTTP ${res.status})`);
+ return;
+ }
+ onChange(data.url);
+ } catch (e) {
+ setUploadError(humanError(e));
+ } finally {
+ setUploading(false);
+ }
+ }
+
+ async function clearAsset() {
+ setUploadError(null);
+ try {
+ await apiFetch('/api/setup/branding', {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ slot }),
+ }).catch(() => null);
+ } finally {
+ onChange('');
+ }
+ }
+
+ const previewClasses =
+ 'shrink-0 w-16 h-16 rounded-md border border-border flex items-center justify-center overflow-hidden transition-colors ' +
+ (previewBg === 'dark' ? 'bg-zinc-900' : 'bg-muted/40') +
+ (dragOver ? ' ring-2 ring-primary border-primary' : '');
+
+ return (
+
+
+
{ e.preventDefault(); setDragOver(true); }}
+ onDragLeave={() => setDragOver(false)}
+ onDrop={(e) => {
+ e.preventDefault();
+ setDragOver(false);
+ const f = e.dataTransfer.files?.[0];
+ if (f) void handleFile(f);
+ }}
+ >
+ {
+ const f = e.target.files?.[0];
+ if (f) void handleFile(f);
+ e.target.value = '';
+ }}
+ />
+ {value ? (
+
+ ) : (
+ click or drop
+ )}
+
+
+
+
{label}
+ {value && (
+
+ Remove
+
+ )}
+
+ {hint &&
{hint}
}
+
+ {uploading ? (
+ Uploading…
+ ) : value ? (
+
+ {value.startsWith('/api/') ? 'Uploaded file' : value}
+
+ ) : (
+ SVG, PNG, JPEG, WebP or ICO · max 2 MB
+ )}
+ setShowUrlField((v) => !v)}
+ className="text-muted-foreground hover:text-foreground underline shrink-0"
+ >
+ {showUrlField ? 'Hide URL' : 'Use URL'}
+
+
+
+
+
+ {showUrlField && (
+
+
+
+ )}
+
+ {uploadError && (
+
{uploadError}
+ )}
+
+ );
+}
+
// ─── Review / finish step ─────────────────────────────────────────────────
function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack: () => void; onFinish: () => void }) {
diff --git a/proxy.ts b/proxy.ts
index f4b69f68..92a29aa0 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -37,6 +37,9 @@ export async function proxy(request: NextRequest) {
pathname === "/api/health" ||
pathname.startsWith("/_next/") ||
pathname.startsWith("/branding/") ||
+ // 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);
if (!allowed) {
From fe937403f3401c75f07758a215d85515539f213e Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 9 May 2026 21:43:04 +0200
Subject: [PATCH 020/133] style: consistent notice cards for server probe
results
---
app/setup/page.tsx | 46 ++++++++++++++++++++++++++++++++++++++++++----
1 file changed, 42 insertions(+), 4 deletions(-)
diff --git a/app/setup/page.tsx b/app/setup/page.tsx
index 1db6ebfd..5e67d8a5 100644
--- a/app/setup/page.tsx
+++ b/app/setup/page.tsx
@@ -227,7 +227,15 @@ export default function SetupWizardPage() {
await saveStep(step, values);
setStepIndex((i) => Math.min(i + 1, STEPS.length - 1));
} catch (e) {
- setError(humanError(e));
+ const msg = humanError(e);
+ setError(msg);
+ // Session expired mid-flow — kick the user back to the
+ // welcome step so they can re-enter the token without
+ // having to refresh.
+ if (/wizard session required/i.test(msg)) {
+ setAuthenticated(false);
+ setStepIndex(0);
+ }
}
}}
onBack={() => setStepIndex((i) => Math.max(i - 1, 1))}
@@ -392,15 +400,45 @@ function CenteredCard({ children }: { children: ReactNode }) {
function ErrorBanner({ error, onDismiss }: { error: string; onDismiss: () => void }) {
return (
-
-
{error}
-
+
+
+
+
{friendlyError(error)}
+
+
dismiss
);
}
+/**
+ * Translate raw API error strings into user-facing copy. Matches the friendly
+ * tone of the JMAP probe cards.
+ */
+function friendlyError(raw: string): string {
+ const lower = raw.toLowerCase();
+ if (lower.includes('invalid or expired token')) {
+ return "That setup token isn't valid anymore. Restart the container to get a fresh one from the logs.";
+ }
+ if (lower.includes('wizard session required')) {
+ return 'Your wizard session expired. Paste the setup token again to continue.';
+ }
+ if (lower.includes('token required')) {
+ return 'Paste the setup token printed in the container logs to continue.';
+ }
+ if (lower.includes('setup is not active')) {
+ return 'Setup has already finished. Reload to sign in.';
+ }
+ return raw;
+}
+
// ─── Welcome / token step ────────────────────────────────────────────────
function WelcomeStep({ tokenFromUrl, onSubmit }: { tokenFromUrl: string; onSubmit: (t: string) => Promise }) {
From 5b30bacf10f329a612e9931296fd9bf5c4c149d8 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 10 May 2026 01:09:00 +0200
Subject: [PATCH 021/133] feat: redesign review step with grouped summary and
advanced toggle
---
app/setup/page.tsx | 208 ++++++++++++++++++++++++++++++++++++---------
1 file changed, 167 insertions(+), 41 deletions(-)
diff --git a/app/setup/page.tsx b/app/setup/page.tsx
index 5e67d8a5..bc6fb665 100644
--- a/app/setup/page.tsx
+++ b/app/setup/page.tsx
@@ -2,7 +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 { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
type State = 'bootstrap' | 'configured' | 'env-managed';
@@ -675,6 +675,21 @@ function ServerStep({ config, setConfig, onNext }: Pick
+ {isInsecureHttpUrl(config.jmapServerUrl) && (
+
+
+
+
+ This URL uses plain HTTP.
+
+
+ Passwords and email contents will travel unencrypted between users and your server. Use https:// in production — terminate TLS on the mail server or a reverse proxy in front of it.
+
+
+
+ )}
{probe && probe.url === config.jmapServerUrl && (
probe.status === 'jmap_detected' ? (
@@ -1346,50 +1361,143 @@ function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack
}
}
+ const passwordsMatch = adminConfirm.length > 0 && adminPassword === adminConfirm;
+ const passwordTooShort = adminPassword.length > 0 && adminPassword.length < 8;
+ const canSubmit =
+ !submitting &&
+ adminPassword.length >= 8 &&
+ passwordsMatch;
+
return (
-