Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
090399a308 | ||
|
|
c31a58af1a | ||
|
|
65aabb943c | ||
|
|
9c8739c4bb | ||
|
|
48f72be209 | ||
|
|
92fb0c63e9 | ||
|
|
55596556ef | ||
|
|
abd63d124f | ||
|
|
562080b7a3 | ||
|
|
41c9f4926c | ||
|
|
e7e78072d4 | ||
|
|
cd363b4840 | ||
|
|
3a350c14a6 | ||
|
|
b0765bf085 | ||
|
|
9225ba0790 | ||
|
|
3b36738192 | ||
|
|
3edd35ab57 | ||
|
|
a86a96e390 | ||
|
|
5f464d4ee2 |
@@ -50,3 +50,5 @@ next-env.d.ts
|
|||||||
|
|
||||||
# Sibling repos
|
# Sibling repos
|
||||||
/repos/
|
/repos/
|
||||||
|
# benchmark
|
||||||
|
benchmark/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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> </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>)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export default [
|
|||||||
"*.config.mjs",
|
"*.config.mjs",
|
||||||
"e2e/**",
|
"e2e/**",
|
||||||
"local-data/**/*.mjs",
|
"local-data/**/*.mjs",
|
||||||
|
"benchmark/**",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+40
-2
@@ -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,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
@@ -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
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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() + "...";
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "フォルダーを削除",
|
||||||
|
|||||||
@@ -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": "폴더 삭제",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Удалить папку",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Видалити папку",
|
||||||
|
|||||||
@@ -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": "删除文件夹",
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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 (0–4) 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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user