Compare commits

...
19 Commits
Author SHA1 Message Date
Linus Rath 090399a308 chore: update version to 1.6.3 2026-05-08 21:10:21 +02:00
Linus Rath c31a58af1a i18n: add missing translation keys across 15 locales 2026-05-08 21:07:14 +02:00
Linus Rath 65aabb943c fix: fall back when only truncation indicator remains in email preview 2026-05-08 20:29:16 +02:00
Linus Rath 9c8739c4bb fix: preserve emoji colors in dark mode email viewer 2026-05-08 20:27:03 +02:00
Linus Rath 48f72be209 fix: preserve emoji colors in dark mode email viewer 2026-05-08 20:26:17 +02:00
Linus Rath 92fb0c63e9 fix: trim leading whitespace from email list preview 2026-05-08 20:19:25 +02:00
Linus Rath 55596556ef feat: import .eml files via folder right-click menu 2026-05-08 20:12:33 +02:00
Linus Rath abd63d124f fix: add benchmark directory to ESLint ignore list 2026-05-08 19:56:21 +02:00
Linus Rath 562080b7a3 fix: request shareWith explicitly so calendar/address book shares survive a re-login #257 2026-05-08 19:53:32 +02:00
Linus Rath 41c9f4926c fix: prevent white-on-white in dark mode for nested bgcolor containers 2026-05-08 02:49:58 +02:00
Linus Rath e7e78072d4 fix: render plain-text-only emails as text, not HTML 2026-05-07 17:49:39 +02:00
Linus Rath cd363b4840 fix: strip leading punctuation when computing avatar initials 2026-05-07 17:43:02 +02:00
Linus Rath 3a350c14a6 fix: hide files/contacts nav items when JMAP server lacks support 2026-05-07 17:21:47 +02:00
Linus Rath b0765bf085 style: redesign quick reply to match sender/banner layout 2026-05-07 17:02:06 +02:00
Linus Rath 9225ba0790 fix: render HTML-only emails and redesign external content prompt 2026-05-07 16:10:26 +02:00
Linus Rath 3b36738192 fix: pad Word/Outlook HTML email rendering 2026-05-07 15:42:47 +02:00
Linus Rath 3edd35ab57 fix: hide email hover actions on mobile 2026-05-07 15:23:05 +02:00
Linus Rath a86a96e390 fix: disable StarterKit's bundled link/underline to avoid duplicate extensions 2026-05-07 12:33:51 +02:00
Linus Rath 5f464d4ee2 feat: lift 5-account cap on HTTP/2 2026-05-07 12:28:33 +02:00
46 changed files with 431 additions and 100 deletions
+3 -1
View File
@@ -49,4 +49,6 @@ next-env.d.ts
/local-data/ /local-data/
# Sibling repos # Sibling repos
/repos/ /repos/
# benchmark
benchmark/
+27
View File
@@ -1,5 +1,32 @@
# Changelog # 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) ## 1.6.2 (2026-05-06)
### Features ### Features
+1 -1
View File
@@ -1 +1 @@
1.6.2 1.6.3
+38
View File
@@ -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 () => { const handleRefreshMailboxes = async () => {
if (!client) return; if (!client) return;
try { try {
@@ -1894,6 +1931,7 @@ export default function Home() {
onCreateFolder={handleCreateFolderFromContextMenu} onCreateFolder={handleCreateFolderFromContextMenu}
onRenameFolder={handleRenameFolderFromContextMenu} onRenameFolder={handleRenameFolderFromContextMenu}
onDeleteFolder={handleDeleteFolderFromContextMenu} onDeleteFolder={handleDeleteFolderFromContextMenu}
onImportEmail={handleImportEmailFromContextMenu}
onRefreshMailboxes={handleRefreshMailboxes} onRefreshMailboxes={handleRefreshMailboxes}
onCompose={() => { onCompose={() => {
setComposerMode('compose'); setComposerMode('compose');
+5 -4
View File
@@ -13,6 +13,7 @@ import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker'; import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers'; import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
const COOKIE_OPTIONS = { const COOKIE_OPTIONS = {
...getCookieOptions(), ...getCookieOptions(),
@@ -23,7 +24,7 @@ function getSlot(request: NextRequest): number {
const raw = request.nextUrl.searchParams.get('slot'); const raw = request.nextUrl.searchParams.get('slot');
if (raw === null) return 0; if (raw === null) return 0;
const slot = parseInt(raw, 10); const slot = parseInt(raw, 10);
if (isNaN(slot) || slot < 0 || slot > 4) return 0; if (isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS) return 0;
return slot; return slot;
} }
@@ -70,7 +71,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 }); return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
} }
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request); const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request);
const cookieName = sessionCookieName(slot); const cookieName = sessionCookieName(slot);
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted }); const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
@@ -185,8 +186,8 @@ export async function DELETE(request: NextRequest) {
const all = request.nextUrl.searchParams.get('all') === 'true'; const all = request.nextUrl.searchParams.get('all') === 'true';
if (all) { if (all) {
// Delete all session cookies (slots 0-4) // Delete all session cookies across every slot.
for (let i = 0; i <= 4; i++) { for (let i = 0; i < MAX_ACCOUNT_SLOTS; i++) {
cookieStore.delete(sessionCookieName(i)); cookieStore.delete(sessionCookieName(i));
clearStalwartAuthContextInStore(cookieStore, i); clearStalwartAuthContextInStore(cookieStore, i);
} }
+3 -2
View File
@@ -6,9 +6,10 @@ import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker'; import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers'; import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
function getSlot(request: NextRequest, bodySlot: unknown): number { function getSlot(request: NextRequest, bodySlot: unknown): number {
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) { if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS) {
return bodySlot; return bodySlot;
} }
@@ -16,7 +17,7 @@ function getSlot(request: NextRequest, bodySlot: unknown): number {
if (raw === null) return 0; if (raw === null) return 0;
const slot = parseInt(raw, 10); const slot = parseInt(raw, 10);
return Number.isNaN(slot) || slot < 0 || slot > 4 ? 0 : slot; return Number.isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS ? 0 : slot;
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
+5 -4
View File
@@ -4,12 +4,13 @@ import { logger } from '@/lib/logger';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens'; import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange'; import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
function getSlot(request: NextRequest): number { function getSlot(request: NextRequest): number {
const raw = request.nextUrl.searchParams.get('slot'); const raw = request.nextUrl.searchParams.get('slot');
if (raw === null) return 0; if (raw === null) return 0;
const slot = parseInt(raw, 10); const slot = parseInt(raw, 10);
if (isNaN(slot) || slot < 0 || slot > 4) return 0; if (isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS) return 0;
return slot; return slot;
} }
@@ -21,7 +22,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
} }
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request); const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request);
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null; const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId); const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId);
@@ -112,9 +113,9 @@ export async function DELETE(request: NextRequest) {
const all = request.nextUrl.searchParams.get('all') === 'true'; const all = request.nextUrl.searchParams.get('all') === 'true';
if (all) { if (all) {
// Revoke and delete all refresh token cookies (slots 0-4) // Revoke and delete all refresh token cookies across every slot.
const cookieStore = await cookies(); const cookieStore = await cookies();
for (let i = 0; i <= 4; i++) { for (let i = 0; i < MAX_ACCOUNT_SLOTS; i++) {
const name = refreshTokenCookieName(i); const name = refreshTokenCookieName(i);
const serverCookieName = refreshTokenServerCookieName(i); const serverCookieName = refreshTokenServerCookieName(i);
const token = cookieStore.get(name)?.value; const token = cookieStore.get(name)?.value;
+2 -1
View File
@@ -9,6 +9,7 @@ import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker'; import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers'; import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
/** /**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens. * Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
@@ -85,7 +86,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
} }
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0; const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0;
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null; const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
// Pin the upstream URL to a configured JMAP server. The list of allowed // Pin the upstream URL to a configured JMAP server. The list of allowed
+3 -2
View File
@@ -7,6 +7,7 @@ import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync'; import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env'; import { readFileEnv } from '@/lib/read-file-env';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
function classifyError(error: unknown): { message: string; status: number } { function classifyError(error: unknown): { message: string; status: number } {
const code = (error as NodeJS.ErrnoException).code; const code = (error as NodeJS.ErrnoException).code;
@@ -59,7 +60,7 @@ function normalizeUrl(url: string): string {
/** /**
* Verify identity against session cookies across all account slots. * Verify identity against session cookies across all account slots.
* With multi-account, the requesting account may be on any slot (0-4). * With multi-account, the requesting account may be on any slot.
* Checks both basic-auth session cookies and stalwart auth context cookies * Checks both basic-auth session cookies and stalwart auth context cookies
* (used by OAuth/SSO and TOTP-upgraded sessions). * (used by OAuth/SSO and TOTP-upgraded sessions).
* Returns true only if a matching cookie is found. * Returns true only if a matching cookie is found.
@@ -68,7 +69,7 @@ async function verifyIdentity(username: string, serverUrl: string): Promise<bool
const cookieStore = await cookies(); const cookieStore = await cookies();
const normalizedServerUrl = normalizeUrl(serverUrl); const normalizedServerUrl = normalizeUrl(serverUrl);
for (let slot = 0; slot <= 4; slot++) { for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
// Check basic-auth session cookie // Check basic-auth session cookie
const token = cookieStore.get(sessionCookieName(slot))?.value; const token = cookieStore.get(sessionCookieName(slot))?.value;
if (token) { if (token) {
+3
View File
@@ -6,6 +6,7 @@ import type { HoverAction } from "@/stores/settings-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react"; import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useIsMobile } from "@/hooks/use-media-query";
interface EmailHoverActionsProps { interface EmailHoverActionsProps {
email: Email; email: Email;
@@ -76,11 +77,13 @@ export function EmailHoverActions({
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode); const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
const hoverActionsCorner = useSettingsStore((state) => state.hoverActionsCorner); const hoverActionsCorner = useSettingsStore((state) => state.hoverActionsCorner);
const t = useTranslations("settings.email_behavior.hover_actions"); const t = useTranslations("settings.email_behavior.hover_actions");
const isMobile = useIsMobile();
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const hoverBackgroundClassName = backgroundClassName; const hoverBackgroundClassName = backgroundClassName;
if (isMobile) return null;
if (hoverActions.length === 0) return null; if (hoverActions.length === 0) return null;
const handleAction = (e: React.MouseEvent, action: HoverAction) => { const handleAction = (e: React.MouseEvent, action: HoverAction) => {
+4 -3
View File
@@ -2,7 +2,7 @@
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback } from "react"; import { useCallback } from "react";
import { formatDate } from "@/lib/utils"; import { formatDate, stripInvisibleLeading } from "@/lib/utils";
import { Email } from "@/lib/jmap/types"; import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
@@ -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 sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk; const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray // Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
const colorTagIds = getEmailColorTags(email.keywords); const colorTagIds = getEmailColorTags(email.keywords);
@@ -295,7 +296,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
? "text-muted-foreground" ? "text-muted-foreground"
: "text-muted-foreground/80" : "text-muted-foreground/80"
)}> )}>
{email.preview || "No preview available"} {trimmedPreview || "No preview available"}
</p> </p>
)} )}
</> </>
+159 -44
View File
@@ -2294,13 +2294,24 @@ export function EmailViewer({
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) { if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
htmlContent = email.bodyValues[email.htmlBody[0].partId].value; htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
// Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting). // Per RFC 8621 § 4.1.4, when a message has only one alternative the server
// Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines. // exposes the same part in both htmlBody and textBody. The shared part may
const hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]; // actually be text/plain (plain-text-only mail) — rendering that as HTML
if (hasTextBody && htmlContent) { // collapses newlines and skips linkification, so route by the part's type.
useHtmlVersion = hasMeaningfulHtmlBody(htmlContent); const htmlPart = email.htmlBody[0];
if (htmlPart.type && htmlPart.type.toLowerCase() !== 'text/html') {
useHtmlVersion = false;
} else { } else {
useHtmlVersion = !!htmlContent; // Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting).
// Server-generated HTML from text/plain emails often lacks <br> 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;
}
} }
} }
@@ -2678,6 +2689,11 @@ export function EmailViewer({
// Re-invert leaf media elements so they appear normal. // Re-invert leaf media elements so they appear normal.
// Container selectors (bgcolor, background, etc.) use :not(:has(...)) to avoid // Container selectors (bgcolor, background, etc.) use :not(:has(...)) to avoid
// double re-inverting images nested inside those containers. // 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 ? ` const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
html { background: #1a1a1a; } html { background: #1a1a1a; }
body { filter: invert(1) hue-rotate(180deg); } body { filter: invert(1) hue-rotate(180deg); }
@@ -2692,13 +2708,28 @@ export function EmailViewer({
table[background]:not(:has(img, video, svg, canvas, object, embed)) { table[background]:not(:has(img, video, svg, canvas, object, embed)) {
filter: invert(1) hue-rotate(180deg); 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'; const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light';
// Bare HTML emails (no <style>) tend to be plain prose without their own // Bare HTML emails (no <style>) tend to be plain prose without their own
// layout - give them the same padding as plain-text mails (.email-content-text). // layout - give them the same padding as plain-text mails (.email-content-text).
const bodyPadding = effectiveEmailContent.hasStyleTag ? '0' : '1rem 1.25rem'; // Word/Outlook HTML emails ship a <style> block but put their gutter in
// @page margins (print-only), so they need a fallback body padding too.
const isWordHtml = /class=["']?(?:Mso|WordSection)|<o:p[\s>/]|urn:schemas-microsoft-com:office:office/i.test(effectiveEmailContent.html);
const bodyPadding = (effectiveEmailContent.hasStyleTag && !isWordHtml) ? '0' : '1rem 1.25rem';
// Word emails rely on empty <p class=MsoNormal>&nbsp;</p> spacers for vertical
// rhythm. With our default line-height: 1.6 these stack into oversized gaps;
// tighten to match how Outlook/Gmail render the same source.
const wordHtmlCSS = isWordHtml ? `
body { line-height: 1.15; }
p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0 0 6px; }
` : '';
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> <html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
@@ -2709,6 +2740,7 @@ export function EmailViewer({
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; } table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
td, th { word-break: break-word; } td, th { word-break: break-word; }
pre { white-space: pre-wrap; word-wrap: break-word; } pre { white-space: pre-wrap; word-wrap: break-word; }
${wordHtmlCSS}
${darkModeCSS} ${darkModeCSS}
</style></head><body>${effectiveEmailContent.html}</body></html>`; </style></head><body>${effectiveEmailContent.html}</body></html>`;
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]); }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]);
@@ -2852,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);
});
} }
} }
} }
@@ -4550,34 +4651,50 @@ export function EmailViewer({
<div className="flex flex-col gap-3 isolate"> <div className="flex flex-col gap-3 isolate">
{/* External Content Controls */} {/* External Content Controls */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && ( {hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
<div className="flex items-center gap-3 flex-wrap md:justify-center rounded-md px-3 py-1 bg-muted/50 dark:bg-muted/30"> <div className="flex items-start gap-3 py-1">
{externalContentPolicy === 'ask' && ( <div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
<button <Image className="w-5 h-5" />
onClick={() => setAllowExternalContent(true)} </div>
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0" <div className="flex-1 min-w-0 space-y-2">
> <div>
<Image className="w-3.5 h-3.5" /> <div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{t('load_external_content')} External Content
</button> </div>
)} <div className="text-sm font-medium text-foreground break-words">
{email.from?.[0]?.email && ( {t('external_content_warning')}
<button </div>
onClick={() => { </div>
const senderEmail = email.from?.[0]?.email; <div className="flex flex-wrap items-center gap-1.5 pt-0.5">
if (senderEmail) { {externalContentPolicy === 'ask' && (
if (trustedSendersAddressBook && client) { <button
addToTrustedSendersBook(client, senderEmail).catch(console.error); onClick={() => setAllowExternalContent(true)}
} else { className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px]"
addTrustedSender(senderEmail); >
} <Image className="w-3.5 h-3.5" />
setAllowExternalContent(true); {t('load_external_content')}
} </button>
}} )}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0" {email.from?.[0]?.email && (
> <button
{t('trust_sender')} onClick={() => {
</button> const senderEmail = email.from?.[0]?.email;
)} if (senderEmail) {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
setAllowExternalContent(true);
}
}}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px]"
>
<ShieldCheck className="w-3.5 h-3.5" />
{t('trust_sender')}
</button>
)}
</div>
</div>
</div> </div>
)} )}
@@ -4933,18 +5050,17 @@ export function EmailViewer({
<PluginSlot name="email-footer" /> <PluginSlot name="email-footer" />
{/* Quick Reply Section - hidden for drafts and while loading a new email */} {/* Quick Reply Section - hidden for drafts and while loading a new email */}
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className={cn( {!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all", <div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
isQuickReplyFocused || quickReplyText ? "border-primary" : "border-border" <div className="flex-shrink-0">
)}>
<div className="p-4">
<div className="flex items-start gap-3">
<Avatar <Avatar
name={currentUserName || "You"} name={currentUserName || "You"}
email={currentUserEmail || ""} email={currentUserEmail || ""}
size="sm" size="lg"
className="shadow-sm w-10 h-10"
/> />
<div className="flex-1 space-y-3"> </div>
<div className="flex-1 min-w-0 space-y-3">
<textarea <textarea
value={quickReplyText} value={quickReplyText}
onChange={(e) => setQuickReplyText(e.target.value)} onChange={(e) => setQuickReplyText(e.target.value)}
@@ -5024,7 +5140,6 @@ export function EmailViewer({
</div> </div>
</div> </div>
)} )}
</div>
</div> </div>
</div> </div>
</div>)} </div>)}
+2
View File
@@ -139,6 +139,8 @@ export function RichTextEditor({
extensions: [ extensions: [
StarterKit.configure({ StarterKit.configure({
heading: { levels: [1, 2] }, heading: { levels: [1, 2] },
link: false,
underline: false,
}), }),
Underline, Underline,
Link.configure({ Link.configure({
@@ -331,8 +331,12 @@ function EmailCard({
htmlContent = email.bodyValues[email.htmlBody[0].partId].value; htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
// Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting). // Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting).
// Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines. // Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines.
const hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]; // Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody —
if (hasTextBody && htmlContent) { // 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); useHtmlVersion = hasMeaningfulHtmlBody(htmlContent);
} else { } else {
useHtmlVersion = !!htmlContent; useHtmlVersion = !!htmlContent;
+7 -5
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { formatDate } from "@/lib/utils"; import { formatDate, stripInvisibleLeading } from "@/lib/utils";
import { Email, ThreadGroup } from "@/lib/jmap/types"; import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
@@ -71,7 +71,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined; const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
const isChecked = selectedEmailIds.has(email.id); const isChecked = selectedEmailIds.has(email.id);
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
// Resolve color tags using keyword definitions; unknown tags fall back to gray // Resolve color tags using keyword definitions; unknown tags fall back to gray
const tagIds = getEmailColorTags(email.keywords); const tagIds = getEmailColorTags(email.keywords);
@@ -316,7 +317,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
? "text-muted-foreground" ? "text-muted-foreground"
: "text-muted-foreground/80" : "text-muted-foreground/80"
)}> )}>
{email.preview || "No preview available"} {trimmedPreview || "No preview available"}
</p> </p>
)} )}
</> </>
@@ -366,7 +367,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const isMobile = useUIStore((state) => state.isMobile); const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : ''; const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore(); const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
const getAccountById = useAccountStore((state) => state.getAccountById); const getAccountById = useAccountStore((state) => state.getAccountById);
@@ -722,7 +724,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
? "text-muted-foreground" ? "text-muted-foreground"
: "text-muted-foreground/80" : "text-muted-foreground/80"
)}> )}>
{latestEmail.preview || "No preview available"} {trimmedPreview || "No preview available"}
</p> </p>
)} )}
</> </>
+2 -2
View File
@@ -6,7 +6,7 @@ import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-reac
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useAccountStore, type AccountEntry } from "@/stores/account-store"; import { useAccountStore, type AccountEntry } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils"; import { getInitials, getMaxAccounts } from "@/lib/account-utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useRouter } from "@/i18n/navigation"; import { useRouter } from "@/i18n/navigation";
@@ -220,7 +220,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
</div> </div>
{/* Separator + Add Account */} {/* Separator + Add Account */}
{accounts.length < MAX_ACCOUNTS && ( {accounts.length < getMaxAccounts() && (
<div className="border-t border-border"> <div className="border-t border-border">
<button <button
onClick={handleAddAccount} onClick={handleAddAccount}
@@ -20,6 +20,7 @@ import {
Pencil, Pencil,
FolderX, FolderX,
RefreshCw, RefreshCw,
Upload,
} from "lucide-react"; } from "lucide-react";
interface Position { interface Position {
@@ -84,6 +85,7 @@ interface MailboxContextMenuProps {
onCreateFolder?: () => void; onCreateFolder?: () => void;
onRenameFolder?: (mailboxId: string) => void; onRenameFolder?: (mailboxId: string) => void;
onDeleteFolder?: (mailboxId: string) => void; onDeleteFolder?: (mailboxId: string) => void;
onImportEmail?: (mailboxId: string) => void;
onRefresh?: () => void; onRefresh?: () => void;
} }
@@ -102,6 +104,7 @@ export function MailboxContextMenu({
onCreateFolder, onCreateFolder,
onRenameFolder, onRenameFolder,
onDeleteFolder, onDeleteFolder,
onImportEmail,
onRefresh, onRefresh,
}: MailboxContextMenuProps) { }: MailboxContextMenuProps) {
const t = useTranslations("mailbox_context_menu"); const t = useTranslations("mailbox_context_menu");
@@ -149,6 +152,7 @@ export function MailboxContextMenu({
const canCreateChild = mailbox.myRights?.mayCreateChild !== false; const canCreateChild = mailbox.myRights?.mayCreateChild !== false;
const canSetSeen = mailbox.myRights?.maySetSeen !== false; const canSetSeen = mailbox.myRights?.maySetSeen !== false;
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false; const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
const canAddItems = mailbox.myRights?.mayAddItems !== false;
const fullPath = getMailboxPath(mailbox, mailboxes); const fullPath = getMailboxPath(mailbox, mailboxes);
@@ -190,6 +194,15 @@ export function MailboxContextMenu({
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem
icon={Upload}
label={t("import_email")}
onClick={() => handleAction(() => onImportEmail?.(mailbox.id))}
disabled={!onImportEmail || !canAddItems}
/>
<ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
icon={FolderX} icon={FolderX}
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")} label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
+7 -6
View File
@@ -11,14 +11,13 @@ import { usePathname, Link, useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useWebDAVStore } from "@/stores/webdav-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store"; import { usePolicyStore } from "@/stores/policy-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store"; import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot"; import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils"; import { getInitials, getMaxAccounts } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
@@ -168,7 +167,9 @@ export function NavigationRail({
const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const { supportsCalendar } = useCalendarStore(); const { supportsCalendar } = useCalendarStore();
const { mailboxes } = useEmailStore(); const { mailboxes } = useEmailStore();
const { supportsWebDAV } = useWebDAVStore(); const client = useAuthStore((s) => s.client);
const supportsFiles = client?.supportsFiles() ?? false;
const supportsContacts = client?.supportsContacts() ?? false;
const sidebarApps = useSettingsStore((s) => s.sidebarApps); const sidebarApps = useSettingsStore((s) => s.sidebarApps);
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList); const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled')); const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
@@ -252,8 +253,8 @@ export function NavigationRail({
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread }, { id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar }, { id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" }, { id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts", hidden: !supportsContacts },
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false || !filesEnabled }, { id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: !supportsFiles || !filesEnabled },
]; ];
const isSettingsActive = !activeAppId && pathname.startsWith("/settings"); const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
@@ -634,7 +635,7 @@ export function NavigationRail({
</button> </button>
); );
})} })}
{accounts.length < MAX_ACCOUNTS && ( {accounts.length < getMaxAccounts() && (
<button <button
onClick={() => router.push(`/login?mode=add-account` as never)} onClick={() => router.push(`/login?mode=add-account` as never)}
className="flex items-center justify-center w-8 h-8 rounded-full border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-foreground hover:text-foreground hover:bg-muted transition-colors flex-shrink-0" className="flex items-center justify-center w-8 h-8 rounded-full border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-foreground hover:text-foreground hover:bg-muted transition-colors flex-shrink-0"
+3
View File
@@ -66,6 +66,7 @@ interface SidebarProps {
onCreateFolder?: () => void; onCreateFolder?: () => void;
onRenameFolder?: (mailboxId: string) => void; onRenameFolder?: (mailboxId: string) => void;
onDeleteFolder?: (mailboxId: string) => void; onDeleteFolder?: (mailboxId: string) => void;
onImportEmail?: (mailboxId: string) => void;
onRefreshMailboxes?: () => void; onRefreshMailboxes?: () => void;
className?: string; className?: string;
} }
@@ -636,6 +637,7 @@ export function Sidebar({
onCreateFolder, onCreateFolder,
onRenameFolder, onRenameFolder,
onDeleteFolder, onDeleteFolder,
onImportEmail,
onRefreshMailboxes, onRefreshMailboxes,
className, className,
}: SidebarProps) { }: SidebarProps) {
@@ -1037,6 +1039,7 @@ export function Sidebar({
onCreateFolder={onCreateFolder} onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder} onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder} onDeleteFolder={onDeleteFolder}
onImportEmail={onImportEmail}
onRefresh={onRefreshMailboxes} onRefresh={onRefreshMailboxes}
/> />
</div> </div>
+8 -1
View File
@@ -189,10 +189,17 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
const getInitials = () => { const getInitials = () => {
if (name) { if (name) {
const parts = name.trim().split(/\s+/); const parts = name
.trim()
.split(/\s+/)
.map((p) => p.replace(/^[^\p{L}\p{N}]+/u, ""))
.filter((p) => p.length > 0);
if (parts.length >= 2) { if (parts.length >= 2) {
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
} }
if (parts.length === 1) {
return parts[0].slice(0, 2).toUpperCase();
}
return name.slice(0, 2).toUpperCase(); return name.slice(0, 2).toUpperCase();
} }
if (email) { if (email) {
+1
View File
@@ -77,6 +77,7 @@ export default [
"*.config.mjs", "*.config.mjs",
"e2e/**", "e2e/**",
"local-data/**/*.mjs", "local-data/**/*.mjs",
"benchmark/**",
], ],
}, },
]; ];
+40 -2
View File
@@ -62,5 +62,43 @@ export function getAccountScopedKey(baseKey: string, accountId: string): string
return `${baseKey}::${accountId}`; return `${baseKey}::${accountId}`;
} }
/** Maximum number of accounts allowed */ /**
export const MAX_ACCOUNTS = 5; * 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.
*/
export const MAX_ACCOUNT_SLOTS = 50;
/**
* UX cap for browsers using HTTP/1.1. Each account holds one persistent
* SSE connection for JMAP push; HTTP/1.1 caps origins at 6 concurrent
* connections, so 5 accounts leave one connection free for normal traffic.
* On HTTP/2+ this cap doesn't apply because streams are multiplexed.
*/
export const MAX_ACCOUNTS_HTTP1 = 5;
/**
* Detect whether the page has observed any HTTP/2 or HTTP/3 traffic.
*
* 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.
*/
export function isHttp2Available(): boolean {
if (typeof performance === 'undefined') return false;
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
for (let i = entries.length - 1; i >= 0; i--) {
const proto = entries[i].nextHopProtocol;
if (proto === 'h2' || proto === 'h3') return true;
}
return false;
}
/**
* Effective per-browser account cap. Lifts to {@link MAX_ACCOUNT_SLOTS}
* once HTTP/2+ is observed, otherwise returns {@link MAX_ACCOUNTS_HTTP1}.
*/
export function getMaxAccounts(): number {
return isHttp2Available() ? MAX_ACCOUNT_SLOTS : MAX_ACCOUNTS_HTTP1;
}
+1 -1
View File
@@ -1,7 +1,7 @@
export const SESSION_COOKIE = 'jmap_session'; export const SESSION_COOKIE = 'jmap_session';
export const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60; export const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */ /** Get the cookie name for a given account slot. Slot 0 uses the legacy name. */
export function sessionCookieName(slot: number): string { export function sessionCookieName(slot: number): string {
return slot === 0 ? SESSION_COOKIE : `${SESSION_COOKIE}_${slot}`; return slot === 0 ? SESSION_COOKIE : `${SESSION_COOKIE}_${slot}`;
} }
+40 -5
View File
@@ -95,6 +95,41 @@ const EMAIL_LIST_PROPERTIES = [
"hasAttachment", "hasAttachment",
] as const; ] 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 * Detect whether a calendar object returned by the server is actually a
* task (VTODO) rather than an event (VEVENT). CalDAV clients like * task (VTODO) rather than an event (VEVENT). CalDAV clients like
@@ -3143,7 +3178,7 @@ export class JMAPClient implements IJMAPClient {
try { try {
const accountId = this.getContactsAccountId(); const accountId = this.getContactsAccountId();
const response = await this.request([ const response = await this.request([
["AddressBook/get", { accountId }, "0"] ["AddressBook/get", { accountId, properties: ADDRESS_BOOK_PROPERTIES }, "0"]
], this.contactUsing()); ], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "AddressBook/get") { if (response.methodResponses?.[0]?.[0] === "AddressBook/get") {
@@ -3168,7 +3203,7 @@ export class JMAPClient implements IJMAPClient {
try { try {
const response = await this.request([ const response = await this.request([
["AddressBook/get", { accountId }, "0"] ["AddressBook/get", { accountId, properties: ADDRESS_BOOK_PROPERTIES }, "0"]
], this.contactUsing()); ], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "AddressBook/get") { if (response.methodResponses?.[0]?.[0] === "AddressBook/get") {
@@ -3612,7 +3647,7 @@ export class JMAPClient implements IJMAPClient {
try { try {
const accountId = this.getCalendarsAccountId(); const accountId = this.getCalendarsAccountId();
const response = await this.request([ const response = await this.request([
["Calendar/get", { accountId }, "0"] ["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "0"]
], this.calendarUsing()); ], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "Calendar/get") { if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
@@ -3637,7 +3672,7 @@ export class JMAPClient implements IJMAPClient {
try { try {
const response = await this.request([ const response = await this.request([
["Calendar/get", { accountId }, "0"] ["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "0"]
], this.calendarUsing()); ], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "Calendar/get") { 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 // Fetch from the target account to find the created calendar
const fetchAccountId = targetAccountId || this.getCalendarsAccountId(); const fetchAccountId = targetAccountId || this.getCalendarsAccountId();
const fetchResponse = await this.request([ const fetchResponse = await this.request([
["Calendar/get", { accountId: fetchAccountId, ids: [createdId] }, "0"] ["Calendar/get", { accountId: fetchAccountId, ids: [createdId], properties: CALENDAR_PROPERTIES }, "0"]
], this.calendarUsing()); ], this.calendarUsing());
if (fetchResponse.methodResponses?.[0]?.[0] === "Calendar/get") { if (fetchResponse.methodResponses?.[0]?.[0] === "Calendar/get") {
const list = fetchResponse.methodResponses[0][1].list || []; const list = fetchResponse.methodResponses[0][1].list || [];
+1 -1
View File
@@ -4,7 +4,7 @@ export const OAUTH_SCOPES = process.env.OAUTH_SCOPES || (EXTRA_SCOPES ? `${DEFAU
export const REFRESH_TOKEN_COOKIE = 'jmap_rt'; export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
export const REFRESH_TOKEN_SERVER_COOKIE = 'jmap_rts'; export const REFRESH_TOKEN_SERVER_COOKIE = 'jmap_rts';
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */ /** Get the cookie name for a given account slot. Slot 0 uses the legacy name. */
export function refreshTokenCookieName(slot: number): string { export function refreshTokenCookieName(slot: number): string {
return slot === 0 ? REFRESH_TOKEN_COOKIE : `${REFRESH_TOKEN_COOKIE}_${slot}`; return slot === 0 ? REFRESH_TOKEN_COOKIE : `${REFRESH_TOKEN_COOKIE}_${slot}`;
} }
+5 -2
View File
@@ -2,6 +2,7 @@ import { cookies } from 'next/headers';
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { sessionCookieName } from '@/lib/auth/session-cookie'; import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
export interface StalwartCredentials { export interface StalwartCredentials {
/** URL of the JMAP server (used for JMAP + management method calls) */ /** URL of the JMAP server (used for JMAP + management method calls) */
@@ -15,14 +16,16 @@ export interface StalwartCredentials {
function parseSlot(raw: string | null): number | null { function parseSlot(raw: string | null): number | null {
if (raw === null) return null; if (raw === null) return null;
const slot = parseInt(raw, 10); const slot = parseInt(raw, 10);
return Number.isNaN(slot) || slot < 0 || slot > 4 ? null : slot; return Number.isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS ? null : slot;
} }
const ALL_SLOTS = Array.from({ length: MAX_ACCOUNT_SLOTS }, (_, i) => i);
function getCandidateSlots(request: NextRequest): number[] { function getCandidateSlots(request: NextRequest): number[] {
const requestedSlot = parseSlot(request.headers.get('X-JMAP-Cookie-Slot')) const requestedSlot = parseSlot(request.headers.get('X-JMAP-Cookie-Slot'))
?? parseSlot(request.nextUrl.searchParams.get('slot')); ?? parseSlot(request.nextUrl.searchParams.get('slot'));
return requestedSlot === null ? [0, 1, 2, 3, 4] : [requestedSlot]; return requestedSlot === null ? ALL_SLOTS : [requestedSlot];
} }
export async function getStalwartCredentials(request: NextRequest): Promise<StalwartCredentials | null> { export async function getStalwartCredentials(request: NextRequest): Promise<StalwartCredentials | null> {
+15
View File
@@ -78,6 +78,21 @@ export function formatDateTime(
return d.toLocaleString(undefined, localeOptions); return d.toLocaleString(undefined, localeOptions);
} }
// 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 {
const stripped = text.replace(LEADING_INVISIBLE_RE, '');
if (ONLY_PUNCTUATION_RE.test(stripped)) return '';
return stripped;
}
export function truncateText(text: string, maxLength: number): string { export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text; if (text.length <= maxLength) return text;
return text.substring(0, maxLength).trim() + "..."; return text.substring(0, maxLength).trim() + "...";
+1
View File
@@ -1684,6 +1684,7 @@
"new_subfolder": "Nová podsložka...", "new_subfolder": "Nová podsložka...",
"new_folder": "Nová složka...", "new_folder": "Nová složka...",
"rename": "Přejmenovat...", "rename": "Přejmenovat...",
"import_email": "Importovat .eml...",
"empty_folder": "Vyprázdnit složku", "empty_folder": "Vyprázdnit složku",
"empty_folder_generic": "Vyprázdnit složku", "empty_folder_generic": "Vyprázdnit složku",
"delete_folder": "Smazat složku", "delete_folder": "Smazat složku",
+1
View File
@@ -1684,6 +1684,7 @@
"new_subfolder": "Neuer Unterordner...", "new_subfolder": "Neuer Unterordner...",
"new_folder": "Neuer Ordner...", "new_folder": "Neuer Ordner...",
"rename": "Umbenennen...", "rename": "Umbenennen...",
"import_email": ".eml importieren...",
"empty_folder": "Ordner leeren", "empty_folder": "Ordner leeren",
"empty_folder_generic": "Ordner leeren", "empty_folder_generic": "Ordner leeren",
"delete_folder": "Ordner löschen", "delete_folder": "Ordner löschen",
+1
View File
@@ -1684,6 +1684,7 @@
"new_subfolder": "New subfolder...", "new_subfolder": "New subfolder...",
"new_folder": "New folder...", "new_folder": "New folder...",
"rename": "Rename...", "rename": "Rename...",
"import_email": "Import .eml...",
"empty_folder": "Empty folder", "empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder", "empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder", "delete_folder": "Delete folder",
+1
View File
@@ -1684,6 +1684,7 @@
"new_subfolder": "Nueva subcarpeta...", "new_subfolder": "Nueva subcarpeta...",
"new_folder": "Nueva carpeta...", "new_folder": "Nueva carpeta...",
"rename": "Renombrar...", "rename": "Renombrar...",
"import_email": "Importar .eml...",
"empty_folder": "Vaciar carpeta", "empty_folder": "Vaciar carpeta",
"empty_folder_generic": "Vaciar carpeta", "empty_folder_generic": "Vaciar carpeta",
"delete_folder": "Eliminar carpeta", "delete_folder": "Eliminar carpeta",
+1
View File
@@ -1684,6 +1684,7 @@
"new_subfolder": "Nouveau sous-dossier...", "new_subfolder": "Nouveau sous-dossier...",
"new_folder": "Nouveau dossier...", "new_folder": "Nouveau dossier...",
"rename": "Renommer...", "rename": "Renommer...",
"import_email": "Importer un .eml...",
"empty_folder": "Vider le dossier", "empty_folder": "Vider le dossier",
"empty_folder_generic": "Vider le dossier", "empty_folder_generic": "Vider le dossier",
"delete_folder": "Supprimer le dossier", "delete_folder": "Supprimer le dossier",
+1
View File
@@ -1684,6 +1684,7 @@
"new_subfolder": "Nuova sottocartella...", "new_subfolder": "Nuova sottocartella...",
"new_folder": "Nuova cartella...", "new_folder": "Nuova cartella...",
"rename": "Rinomina...", "rename": "Rinomina...",
"import_email": "Importa .eml...",
"empty_folder": "Svuota cartella", "empty_folder": "Svuota cartella",
"empty_folder_generic": "Svuota cartella", "empty_folder_generic": "Svuota cartella",
"delete_folder": "Elimina cartella", "delete_folder": "Elimina cartella",
+1
View File
@@ -1684,6 +1684,7 @@
"new_subfolder": "新しいサブフォルダー...", "new_subfolder": "新しいサブフォルダー...",
"new_folder": "新しいフォルダー...", "new_folder": "新しいフォルダー...",
"rename": "名前を変更...", "rename": "名前を変更...",
"import_email": ".eml をインポート...",
"empty_folder": "フォルダーを空にする", "empty_folder": "フォルダーを空にする",
"empty_folder_generic": "フォルダーを空にする", "empty_folder_generic": "フォルダーを空にする",
"delete_folder": "フォルダーを削除", "delete_folder": "フォルダーを削除",
+1
View File
@@ -1684,6 +1684,7 @@
"new_subfolder": "새 하위 폴더...", "new_subfolder": "새 하위 폴더...",
"new_folder": "새 폴더...", "new_folder": "새 폴더...",
"rename": "이름 바꾸기...", "rename": "이름 바꾸기...",
"import_email": ".eml 가져오기...",
"empty_folder": "폴더 비우기", "empty_folder": "폴더 비우기",
"empty_folder_generic": "폴더 비우기", "empty_folder_generic": "폴더 비우기",
"delete_folder": "폴더 삭제", "delete_folder": "폴더 삭제",
+1
View File
@@ -1684,6 +1684,7 @@
"new_subfolder": "Jauna apakšmape...", "new_subfolder": "Jauna apakšmape...",
"new_folder": "Jauna mape...", "new_folder": "Jauna mape...",
"rename": "Pārsaukt...", "rename": "Pārsaukt...",
"import_email": "Importēt .eml...",
"empty_folder": "Iztukšot mapi", "empty_folder": "Iztukšot mapi",
"empty_folder_generic": "Iztukšot mapi", "empty_folder_generic": "Iztukšot mapi",
"delete_folder": "Dzēst mapi", "delete_folder": "Dzēst mapi",
+1
View File
@@ -1684,6 +1684,7 @@
"new_subfolder": "Nieuwe submap...", "new_subfolder": "Nieuwe submap...",
"new_folder": "Nieuwe map...", "new_folder": "Nieuwe map...",
"rename": "Hernoemen...", "rename": "Hernoemen...",
"import_email": ".eml importeren...",
"empty_folder": "Map leegmaken", "empty_folder": "Map leegmaken",
"empty_folder_generic": "Map leegmaken", "empty_folder_generic": "Map leegmaken",
"delete_folder": "Map verwijderen", "delete_folder": "Map verwijderen",
+1
View File
@@ -1684,6 +1684,7 @@
"new_subfolder": "Nowy podfolder...", "new_subfolder": "Nowy podfolder...",
"new_folder": "Nowy folder...", "new_folder": "Nowy folder...",
"rename": "Zmień nazwę...", "rename": "Zmień nazwę...",
"import_email": "Importuj .eml...",
"empty_folder": "Opróżnij folder", "empty_folder": "Opróżnij folder",
"empty_folder_generic": "Opróżnij folder", "empty_folder_generic": "Opróżnij folder",
"delete_folder": "Usuń folder", "delete_folder": "Usuń folder",
+1
View File
@@ -1684,6 +1684,7 @@
"new_subfolder": "Nova subpasta...", "new_subfolder": "Nova subpasta...",
"new_folder": "Nova pasta...", "new_folder": "Nova pasta...",
"rename": "Renomear...", "rename": "Renomear...",
"import_email": "Importar .eml...",
"empty_folder": "Esvaziar pasta", "empty_folder": "Esvaziar pasta",
"empty_folder_generic": "Esvaziar pasta", "empty_folder_generic": "Esvaziar pasta",
"delete_folder": "Excluir pasta", "delete_folder": "Excluir pasta",
+1
View File
@@ -1684,6 +1684,7 @@
"new_subfolder": "Новая вложенная папка...", "new_subfolder": "Новая вложенная папка...",
"new_folder": "Новая папка...", "new_folder": "Новая папка...",
"rename": "Переименовать...", "rename": "Переименовать...",
"import_email": "Импортировать .eml...",
"empty_folder": "Очистить папку", "empty_folder": "Очистить папку",
"empty_folder_generic": "Очистить папку", "empty_folder_generic": "Очистить папку",
"delete_folder": "Удалить папку", "delete_folder": "Удалить папку",
+1
View File
@@ -1684,6 +1684,7 @@
"new_subfolder": "Yeni alt klasör...", "new_subfolder": "Yeni alt klasör...",
"new_folder": "Yeni klasör...", "new_folder": "Yeni klasör...",
"rename": "Yeniden adlandır...", "rename": "Yeniden adlandır...",
"import_email": ".eml içe aktar...",
"empty_folder": "Klasörü boşalt", "empty_folder": "Klasörü boşalt",
"empty_folder_generic": "Klasörü boşalt", "empty_folder_generic": "Klasörü boşalt",
"delete_folder": "Klasörü sil", "delete_folder": "Klasörü sil",
+1
View File
@@ -1684,6 +1684,7 @@
"new_subfolder": "Нова вкладена папка...", "new_subfolder": "Нова вкладена папка...",
"new_folder": "Нова папка...", "new_folder": "Нова папка...",
"rename": "Перейменувати...", "rename": "Перейменувати...",
"import_email": "Імпортувати .eml...",
"empty_folder": "Очистити папку", "empty_folder": "Очистити папку",
"empty_folder_generic": "Очистити папку", "empty_folder_generic": "Очистити папку",
"delete_folder": "Видалити папку", "delete_folder": "Видалити папку",
+1
View File
@@ -1684,6 +1684,7 @@
"new_subfolder": "新建子文件夹...", "new_subfolder": "新建子文件夹...",
"new_folder": "新建文件夹...", "new_folder": "新建文件夹...",
"rename": "重命名...", "rename": "重命名...",
"import_email": "导入 .eml...",
"empty_folder": "清空文件夹", "empty_folder": "清空文件夹",
"empty_folder_generic": "清空文件夹", "empty_folder_generic": "清空文件夹",
"delete_folder": "删除文件夹", "delete_folder": "删除文件夹",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.6.2", "version": "1.6.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.6.2", "version": "1.6.3",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@tanstack/react-virtual": "^3.13.24", "@tanstack/react-virtual": "^3.13.24",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.6.2", "version": "1.6.3",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server", "description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>", "author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
+8 -8
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { generateAccountId, generateAvatarColor, MAX_ACCOUNTS } from '@/lib/account-utils'; import { generateAccountId, generateAvatarColor, getMaxAccounts } from '@/lib/account-utils';
export interface AccountEntry { export interface AccountEntry {
/** Unique key: `${username}@${serverHostname}` */ /** Unique key: `${username}@${serverHostname}` */
@@ -13,7 +13,7 @@ export interface AccountEntry {
username: string; username: string;
/** Authentication mode */ /** Authentication mode */
authMode: 'basic' | 'oauth'; authMode: 'basic' | 'oauth';
/** Cookie slot index (04) for session/token cookies */ /** Cookie slot index for session/token cookies (0 ≤ slot < MAX_ACCOUNT_SLOTS) */
cookieSlot: number; cookieSlot: number;
/** Whether "Remember Me" was checked (basic auth only) */ /** Whether "Remember Me" was checked (basic auth only) */
rememberMe: boolean; rememberMe: boolean;
@@ -80,8 +80,9 @@ export const useAccountStore = create<AccountState>()(
return id; return id;
} }
if (state.accounts.length >= MAX_ACCOUNTS) { const max = getMaxAccounts();
throw new Error(`Maximum of ${MAX_ACCOUNTS} accounts reached`); if (state.accounts.length >= max) {
throw new Error(`Maximum of ${max} accounts reached`);
} }
const cookieSlot = state.getNextCookieSlot(); const cookieSlot = state.getNextCookieSlot();
@@ -178,10 +179,9 @@ export const useAccountStore = create<AccountState>()(
getNextCookieSlot: () => { getNextCookieSlot: () => {
const used = new Set(get().accounts.map((a) => a.cookieSlot)); const used = new Set(get().accounts.map((a) => a.cookieSlot));
for (let i = 0; i < MAX_ACCOUNTS; i++) { let i = 0;
if (!used.has(i)) return i; while (used.has(i)) i++;
} return i;
return 0; // fallback, shouldn't happen if max is enforced
}, },
hasAccount: (username, serverUrl) => { hasAccount: (username, serverUrl) => {