Compare commits

...
33 Commits
Author SHA1 Message Date
Linus Rath 8c5aec9ca4 chore: update version to 1.6.7 2026-05-17 18:17:13 +02:00
Linus Rath 375220298d i18n: add missing translation keys across 16 locales 2026-05-17 18:12:17 +02:00
Linus Rath 452976ed95 fix: apply dark background to email content wrapper in dark mode 2026-05-17 17:40:35 +02:00
Linus Rath 243a2adfbf fix: improve dark mode background colors in email viewer 2026-05-17 17:39:49 +02:00
Linus Rath 1ba4a13353 fix: show "no body content" instead of infinite skeleton for bodyless emails 2026-05-17 17:33:06 +02:00
Linus Rath 5de12dfb79 perf: speed up calendar invitation banner load
Parallelize ICS parse with raw blob fetch, render the banner as soon
as parsing returns instead of awaiting the existing-event lookup, and
filter that lookup by UID server-side instead of fetching every event
on the calendar.
2026-05-17 17:28:10 +02:00
Linus Rath 689d646c57 fix: show contact popup when clicking sender name in email header 2026-05-17 17:19:43 +02:00
Linus Rath 49cd7f8130 feat: show details toggle and panel on mobile sender info 2026-05-17 17:15:19 +02:00
Linus Rath 4545e212f4 fix: align quick reply with mobile bottom toolbar 2026-05-17 17:06:30 +02:00
Linus Rath b1f4f6eae0 fix: pin quick reply to bottom for short emails 2026-05-17 16:56:54 +02:00
Linus Rath 9a431a873b fix: close attachment preview when clicking outside content 2026-05-17 16:49:13 +02:00
Linus Rath bb7e1c4538 fix: per-account push subscriptions so multi-account notifications work #298 2026-05-16 22:50:01 +02:00
Linus Rath 356abcfc2d fix: redact sensitive config secrets from admin API response 2026-05-16 22:48:06 +02:00
Linus Rath 3099b4801e fix: sandbox thread email HTML in srcDoc iframe with CSP meta 2026-05-16 20:49:59 +02:00
Linus Rath fc641e94ac fix: carry configSchema + settingsSchema through marketplace install 2026-05-16 19:51:59 +02:00
Linus Rath 0e758409ee fix: prevent long addresses from overflowing email details columns #297 2026-05-16 19:47:03 +02:00
Linus Rath 8c93941d8d feat: render app-top-banner slot on every authenticated page 2026-05-16 19:39:45 +02:00
Linus Rath 4221c9a50f fix: strip Stalwart master-user '%' suffix from displayed account 2026-05-16 19:07:10 +02:00
Linus Rath 3a559479bd fix: make impersonation cookies session-only 2026-05-16 18:59:46 +02:00
Linus Rath 482493a10d fix: register app-top-banner in plugin-store SLOT_NAMES 2026-05-16 18:53:06 +02:00
Linus Rath 0e1036eb49 fix: adopt orphan session cookie on first SPA load 2026-05-16 18:45:30 +02:00
Linus Rath 349406723c fix: use relative Location header in redirect 2026-05-16 18:33:44 +02:00
Linus Rath 997bedc91b feat: allow admin password overwrite during setup recovery 2026-05-16 18:21:48 +02:00
Linus Rath 307e6d5d34 fix: warn + block install when app version is below plugin's minAppVersion 2026-05-16 18:11:52 +02:00
Linus Rath ca1108f455 feat: master-user impersonation route + app-top-banner plugin slot 2026-05-16 17:59:07 +02:00
Linus Rath 0ff88f36ed Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-16 16:35:43 +02:00
Linus Rath 285b4e349c fix: add outputFileTracingExcludes to optimize Turbopack memory tracing 2026-05-16 16:35:06 +02:00
Linus Rath 2b4ebb1fbb feat: add HTTPS requirement warning in setup wizard 2026-05-16 16:31:12 +02:00
Timo StreuleandLinus Rath a829c2818f fix: pad safe-area-inset-top 2026-05-16 00:55:24 +02:00
Timo StreuleandLinus Rath c54cf73c3a fix: respect safe-area insets on mobile bottom bars 2026-05-15 23:50:29 +02:00
Timo StreuleandLinus Rath c45ef86924 fix: add viewport export with 'initialScale: 1' 2026-05-15 23:01:39 +02:00
Linus Rath f39366b470 fix: read OAUTH_SCOPES at runtime instead of build time 2026-05-15 20:37:00 +02:00
Linus Rath b725000f4d feat: implement vCard 4.0 parsing and generation support 2026-05-15 20:10:18 +02:00
73 changed files with 2297 additions and 511 deletions
+47
View File
@@ -1,5 +1,52 @@
# Changelog
## 1.6.7 (2026-05-17)
### Features
- **Contacts**: vCard 4.0 parsing and generation support
- **Admin**: Master-user impersonation route with `app-top-banner` plugin slot rendered on every authenticated page
- **Admin**: Allow admin password overwrite during setup recovery
- **Setup**: HTTPS requirement warning in the setup wizard
- **Mobile**: Show details toggle and expandable panel for sender info
### Performance
- **Calendar**: Speed up calendar invitation banner load
### Security
- **Mail**: Sandbox thread email HTML in `srcDoc` iframe with a CSP `<meta>` tag
- **Admin**: Redact sensitive config secrets from the admin API response
- **Admin**: Make impersonation cookies session-only
### Fixes
- **Auth**: Read `OAUTH_SCOPES` at runtime instead of build time
- **Auth**: Use a relative `Location` header in redirects
- **Auth**: Adopt orphan session cookie on first SPA load
- **Mail**: Per-account push subscriptions so multi-account notifications work (#298)
- **Mail**: Close attachment preview when clicking outside the content area
- **Mail**: Pin quick reply to the bottom for short emails
- **Mail**: Show "no body content" instead of an infinite skeleton for bodyless emails
- **Mail**: Show contact popup when clicking the sender name in the email header
- **Mail**: Prevent long addresses from overflowing email details columns (#297)
- **Mobile**: Align quick reply with the mobile bottom toolbar
- **Mobile**: Respect safe-area insets on mobile bottom bars
- **Mobile**: Pad `safe-area-inset-top`
- **UI**: Apply dark background to the email content wrapper in dark mode
- **UI**: Improve dark mode background colors in the email viewer
- **UI**: Add viewport export with `initialScale: 1`
- **UI**: Strip the Stalwart master-user `%` suffix from the displayed account
- **Plugins**: Warn and block install when the app version is below the plugin's `minAppVersion`
- **Plugins**: Register `app-top-banner` in plugin-store `SLOT_NAMES`
- **Plugins**: Carry `configSchema` + `settingsSchema` through marketplace install
- **Build**: Add `outputFileTracingExcludes` to reduce Turbopack memory tracing
### i18n
- Add missing translation keys across 16 locales
## 1.6.6 (2026-05-15)
### Features
+1 -1
View File
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.6.6-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.6.7-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
[![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/)
+5 -1
View File
@@ -32,6 +32,7 @@ import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-pan
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
import { EventContextMenu } from "@/components/calendar/event-context-menu";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { EmptySpaceContextMenu } from "@/components/calendar/empty-space-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
@@ -1216,7 +1217,9 @@ export default function CalendarPage() {
};
return (
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<AppTopBannerSlot />
<div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
{/* Left Navigation Rail */}
{!isMobile && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
@@ -1580,6 +1583,7 @@ export default function CalendarPage() {
/>
);
})()}
</div>
</div>
);
}
+5 -1
View File
@@ -15,6 +15,7 @@ import { ContactsSidebar, type ContactCategory } from "@/components/contacts/con
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { RenameDialog } from "@/components/files/rename-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
@@ -649,7 +650,9 @@ export default function ContactsPage() {
};
return (
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<AppTopBannerSlot />
<div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
{/* Navigation Rail - desktop only */}
{!isMobile && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
@@ -882,6 +885,7 @@ export default function ContactsPage() {
/>
);
})()}
</div>
</div>
);
}
+5 -1
View File
@@ -24,6 +24,7 @@ import { ImagePreviewModal } from "@/components/files/image-preview-modal";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { AlertTriangle } from "lucide-react";
export default function FilesPage() {
@@ -374,7 +375,9 @@ export default function FilesPage() {
if (!isAuthenticated) return null;
return (
<div className="flex h-dvh bg-background overflow-hidden">
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<AppTopBannerSlot />
<div className="flex flex-1 min-h-0 overflow-hidden">
{!isMobile && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
@@ -514,6 +517,7 @@ export default function FilesPage() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} />
</div>
</div>
);
}
+2 -3
View File
@@ -16,7 +16,6 @@ import { cn } from "@/lib/utils";
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
import { useUpdateStore, selectBanner } from "@/stores/update-store";
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
@@ -117,7 +116,7 @@ export default function LoginPage() {
const isAddAccountMode = searchParams.get("mode") === "add-account";
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const [formData, setFormData] = useState({
@@ -532,7 +531,7 @@ export default function LoginPage() {
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", effectiveOauthClientId);
authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("scope", OAUTH_SCOPES);
authUrl.searchParams.set("scope", oauthScopes || "openid email profile");
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");
+5 -3
View File
@@ -60,6 +60,7 @@ import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button";
import { useConfig } from "@/hooks/use-config";
import { usePluginStore } from "@/stores/plugin-store";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { useThemeStore } from "@/stores/theme-store";
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
@@ -1959,7 +1960,8 @@ export default function Home() {
return (
<DragDropProvider>
<div className="flex flex-col h-dvh bg-background overflow-hidden">
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<AppTopBannerSlot />
{isRateLimited && rateLimitSecondsLeft !== null && (
<div className="flex items-center justify-center gap-2 bg-amber-500/10 border-b border-amber-500/30 text-amber-700 dark:text-amber-300 text-sm py-1.5 px-4 flex-shrink-0">
<AlertTriangle className="h-3.5 w-3.5" />
@@ -2009,7 +2011,7 @@ export default function Home() {
"flex-shrink-0 h-full z-50",
!isResizing && "transition-[width] duration-300",
// Mobile/Tablet: fixed overlay
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72",
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72 max-lg:pt-[env(safe-area-inset-top)]",
"max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
!sidebarOpen && "max-lg:-translate-x-full",
// Desktop: normal flow
@@ -2418,7 +2420,7 @@ export default function Home() {
isHorizontalMailLayout ? "min-h-0" : "h-full",
// Mobile: full screen overlay when active
"max-md:fixed max-md:inset-0 max-md:z-30",
"max-md:h-full",
"max-md:h-full max-md:pt-[env(safe-area-inset-top)]",
isMobile && activeView !== "viewer" && "max-md:hidden",
// Tablet/Desktop: relative
"md:relative",
+9 -3
View File
@@ -39,6 +39,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { AppTopBannerSlot } from '@/components/plugins/app-top-banner-slot';
import { LayoutSettings } from '@/components/settings/layout-settings';
import { LanguageSettings } from '@/components/settings/language-settings';
import { ReadingSettings } from '@/components/settings/reading-settings';
@@ -686,7 +687,8 @@ export default function SettingsPage() {
if (!isDesktop) {
if (mobileShowContent) {
return (
<div className="flex flex-col h-dvh bg-background">
<div className="flex flex-col h-dvh bg-background pt-[env(safe-area-inset-top)]">
<AppTopBannerSlot />
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
variant="ghost"
@@ -716,7 +718,8 @@ export default function SettingsPage() {
}
return (
<div className="flex flex-col h-dvh bg-background">
<div className="flex flex-col h-dvh bg-background pt-[env(safe-area-inset-top)]">
<AppTopBannerSlot />
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
variant="ghost"
@@ -826,7 +829,9 @@ export default function SettingsPage() {
// Desktop layout
return (
<div className="flex h-dvh bg-background">
<div className="flex flex-col h-dvh bg-background pt-[env(safe-area-inset-top)]">
<AppTopBannerSlot />
<div className="flex flex-1 min-h-0">
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
@@ -958,6 +963,7 @@ export default function SettingsPage() {
</>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div>
</div>
);
}
+6 -2
View File
@@ -5,8 +5,12 @@ import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry {
value: unknown;
// Sensitive keys (sessionSecret, oauthClientSecret) come back with
// `value` omitted and `hasValue` set instead — the server never echoes
// the raw secret to the client.
value?: unknown;
source: 'admin' | 'env' | 'default';
hasValue?: boolean;
}
export function AuthTab() {
@@ -267,7 +271,7 @@ export function AuthTab() {
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" />
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved — type to replace)' : undefined} />
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
</Section>
+2 -1
View File
@@ -5,8 +5,9 @@ import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-reac
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry {
value: unknown;
value?: unknown;
source: 'admin' | 'env' | 'default';
hasValue?: boolean;
}
const IMAGE_FIELDS = [
+4 -2
View File
@@ -26,7 +26,7 @@ export function DashboardTab() {
const [status, setStatus] = useState<AdminStatus | null>(null);
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
const [config, setConfig] = useState<ConfigData | null>(null);
const [, setConfigSources] = useState<Record<string, { value: unknown; source: string }> | null>(null);
const [, setConfigSources] = useState<Record<string, { value?: unknown; source: string; hasValue?: boolean }> | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
const [pluginCount, setPluginCount] = useState(0);
const [themeCount, setThemeCount] = useState(0);
@@ -96,7 +96,9 @@ export function DashboardTab() {
const sources = await adminConfigRes.json();
setConfigSources(sources);
const sessionSecret = sources?.sessionSecret;
if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') {
// Server redacts the raw value for sensitive keys; rely on hasValue,
// which is false when unset or matching a known placeholder default.
if (!sessionSecret?.hasValue) {
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
}
const adminPassword = sources?.adminPassword;
+22 -2
View File
@@ -2,8 +2,11 @@
import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
import { isVersionSatisfied } from '@/lib/version-compare';
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
interface Extension {
slug: string;
@@ -94,6 +97,13 @@ export function MarketplaceTab() {
}, [searchInput]);
async function handleInstall(ext: Extension) {
if (ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion)) {
setMessage({
type: 'error',
text: `"${ext.name}" requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`,
});
return;
}
setInstalling(ext.slug);
setMessage(null);
@@ -258,6 +268,8 @@ function ExtensionCard({
}) {
const isPlugin = extension.type === 'plugin';
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
const versionMismatch = !!extension.minAppVersion
&& !isVersionSatisfied(CURRENT_APP_VERSION, extension.minAppVersion);
return (
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
@@ -346,12 +358,20 @@ function ExtensionCard({
</div>
</Link>
<div className="px-4 pb-4 -mt-1">
<div className="px-4 pb-4 -mt-1 flex items-center gap-2 flex-wrap">
{extension.installed ? (
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
<Check className="w-3 h-3" />
Installed
</span>
) : versionMismatch ? (
<span
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300 text-xs font-medium"
title={`Requires app v${extension.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`}
>
<AlertTriangle className="w-3 h-3" />
Requires v{extension.minAppVersion}+
</span>
) : (
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
+2 -1
View File
@@ -7,8 +7,9 @@ import { JmapServersSection } from './_jmap-servers-section';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
interface ConfigEntry {
value: unknown;
value?: unknown;
source: 'admin' | 'env' | 'default';
hasValue?: boolean;
}
export function SettingsTab() {
+21 -2
View File
@@ -21,6 +21,9 @@ import {
ChevronUp,
} from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
import { isVersionSatisfied } from '@/lib/version-compare';
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
interface PreviewData {
extension: {
@@ -200,6 +203,7 @@ export default function MarketplacePreviewPage() {
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || [];
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
const versionMismatch = !!ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion);
return (
<div className="space-y-6 max-w-4xl">
@@ -294,8 +298,11 @@ export default function MarketplacePreviewPage() {
) : (
<button
onClick={handleInstall}
disabled={installing || !!bundle.error}
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
disabled={installing || !!bundle.error || versionMismatch}
title={versionMismatch
? `Requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}. Update Bulwark to install.`
: undefined}
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
Install
@@ -310,6 +317,18 @@ export default function MarketplacePreviewPage() {
</div>
)}
{versionMismatch && (
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div>
<p className="font-medium">Update Bulwark to install this extension</p>
<p className="text-xs mt-0.5 opacity-90">
Requires app v{ext.minAppVersion}+. You are running v{CURRENT_APP_VERSION}.
</p>
</div>
</div>
)}
{bundle.error && (
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
+25 -2
View File
@@ -2,12 +2,23 @@ import { NextRequest, NextResponse } from 'next/server';
import { configManager } from '@/lib/admin/config-manager';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
import { parseJmapServers } from '@/lib/admin/jmap-servers';
import { logger } from '@/lib/logger';
// Strings that count as "no real secret configured" — used so the dashboard
// can warn about a placeholder session secret without us ever returning the
// raw value to the client.
const SENSITIVE_PLACEHOLDERS = new Set(['your-secret-key-here']);
/**
* GET /api/admin/config - Get full config with sources (admin-protected)
*
* Sensitive keys (sessionSecret, oauthClientSecret) are returned with
* `value` omitted and a `hasValue` boolean instead. An admin session is
* enough to read every other config knob; the secrets themselves stay on
* the server so that an XSS or session-theft can't lift them in one
* request and forge admin/user session cookies offline.
*/
export async function GET() {
try {
@@ -17,7 +28,19 @@ export async function GET() {
await configManager.ensureLoaded();
const config = configManager.getAllWithSources();
return NextResponse.json(config, {
const safe: Record<string, { value?: unknown; source: 'admin' | 'env' | 'default'; hasValue?: boolean }> = {};
for (const [key, entry] of Object.entries(config)) {
if (SENSITIVE_CONFIG_KEYS.has(key)) {
const v = entry.value;
const hasValue =
typeof v === 'string' && v.length > 0 && !SENSITIVE_PLACEHOLDERS.has(v);
safe[key] = { source: entry.source, hasValue };
} else {
safe[key] = entry;
}
}
return NextResponse.json(safe, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
+6
View File
@@ -278,6 +278,12 @@ export async function POST(request: NextRequest) {
enabled: true,
installedAt: now,
updatedAt: now,
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
: {}),
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
: {}),
...(declaredFrameOrigins.length > 0
? { frameOrigins: declaredFrameOrigins }
: {}),
+141
View File
@@ -0,0 +1,141 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptSession } from '@/lib/auth/crypto';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { normalizeJmapServerUrl } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
import { recordLogin } from '@/lib/telemetry/login-tracker';
import {
ImpersonationJwtError,
impersonationReplayCache,
verifyImpersonationJwt,
} from '@/lib/impersonation/jwt';
import {
readImpersonationConfig,
resolveImpersonationServerUrl,
} from '@/lib/impersonation/master-config';
export const runtime = 'nodejs';
const IMPERSONATION_SLOT = 0;
/**
* Impersonation cookies deliberately omit Max-Age so the browser treats
* them as session cookies the impersonated session ends when the user
* closes the browser, not 30 days later. Impersonation is a temporary
* support handoff; a normal password login is the only thing that should
* survive a browser restart.
*/
function impersonationCookieOptions() {
const { maxAge: _maxAge, ...rest } = getCookieOptions();
return rest;
}
/**
* GET /api/auth/impersonate?token=<jwt>
*
* Master-user impersonation via signed JWT. The token carries the target
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
* master credentials from env, then mints the same session cookies the
* password-login path produces. The browser is redirected to "/" and the
* SPA hydrates as if the user had just logged in with master@target%master.
*
* Returns 404 when the feature is not configured so an unconfigured
* deployment does not advertise the endpoint.
*/
export async function GET(request: NextRequest) {
const config = readImpersonationConfig();
if (!config) {
// Not configured — behave exactly like an unknown route.
return new NextResponse('Not found', { status: 404 });
}
const token = request.nextUrl.searchParams.get('token');
if (!token) {
return NextResponse.json({ error: 'Missing token' }, { status: 400 });
}
let claims;
try {
claims = verifyImpersonationJwt(token, config.jwtSecret, {
expectedIssuer: config.expectedIssuer,
});
} catch (err) {
if (err instanceof ImpersonationJwtError) {
logger.warn('Impersonation JWT rejected', { code: err.code });
return NextResponse.json({ error: err.message }, { status: err.status });
}
logger.error('Impersonation JWT error', {
error: err instanceof Error ? err.message : 'Unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
if (!impersonationReplayCache.consume(claims.jti, claims.exp)) {
logger.warn('Impersonation JWT replay rejected', { jti: claims.jti });
return NextResponse.json({ error: 'Token already used' }, { status: 401 });
}
const serverUrl = await resolveImpersonationServerUrl();
if (!serverUrl) {
logger.error('Impersonation requested but jmapServerUrl is not configured');
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
}
let normalizedServerUrl: string;
try {
normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
} catch {
return NextResponse.json({ error: 'Invalid JMAP server URL' }, { status: 500 });
}
// Stalwart master-user impersonation: username = "<target>%<master>",
// password = <master_password>. Per Stalwart docs:
// https://stalw.art/docs/auth/authorization/administrator/
const impersonatedUsername = `${claims.mailbox}%${config.masterUser}`;
const authHeader = `Basic ${Buffer.from(
`${impersonatedUsername}:${config.masterPassword}`,
).toString('base64')}`;
const cookieStore = await cookies();
const sessionToken = encryptSession(
normalizedServerUrl,
impersonatedUsername,
config.masterPassword,
);
cookieStore.set(sessionCookieName(IMPERSONATION_SLOT), sessionToken, impersonationCookieOptions());
setStalwartAuthContextInStore(cookieStore, IMPERSONATION_SLOT, {
serverUrl: normalizedServerUrl,
username: impersonatedUsername,
authHeader,
});
// Structured audit log — operators rely on this for security review.
logger.info('Impersonation session granted', {
event: 'impersonation_granted',
jti: claims.jti,
mailbox: claims.mailbox,
tenant_id: claims.tenant_id,
actor_user_id: claims.actor_user_id,
iss: claims.iss,
ip:
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
null,
referer: request.headers.get('referer'),
user_agent: request.headers.get('user-agent'),
});
void recordLogin(impersonatedUsername, normalizedServerUrl);
// Use a relative Location header so the browser resolves it against the
// public request URL. NextResponse.redirect(new URL('/', request.url))
// would absolutise to the container's internal bind (http://0.0.0.0:3000)
// when running behind a reverse proxy that doesn't set X-Forwarded-Host.
return new NextResponse(null, {
status: 303,
headers: { Location: '/' },
});
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { encryptPayload } from '@/lib/auth/crypto';
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
import { getOauthScopes } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { hasSessionSecret } from '@/lib/auth/session-secret';
@@ -73,7 +73,7 @@ export async function POST(request: NextRequest) {
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', clientId);
authUrl.searchParams.set('redirect_uri', redirect_uri);
authUrl.searchParams.set('scope', OAUTH_SCOPES);
authUrl.searchParams.set('scope', getOauthScopes());
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
+2
View File
@@ -3,6 +3,7 @@ import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { getOauthScopes } from '@/lib/oauth/tokens';
/**
* Runtime configuration endpoint
@@ -35,6 +36,7 @@ export async function GET() {
oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
oauthScopes: getOauthScopes(),
rememberMeEnabled: hasSessionSecret(),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
stalwartFeaturesEnabled,
+91 -21
View File
@@ -1,10 +1,73 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import {
getStalwartCredentials,
type StalwartCredentials,
} from '@/lib/stalwart/credentials';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
interface ResolvedTarget {
authHeader: string;
apiUrl: string;
accountId: string;
}
// When the SW passes ?accountId=, we need the slot whose JMAP session owns
// that account - not just "the first signed-in slot", which is what
// getStalwartCredentials() defaults to. Probe each candidate's session in
// parallel and return the first match.
async function resolveTargetForAccount(accountId: string): Promise<ResolvedTarget | null> {
const cookieStore = await cookies();
const probes: Promise<ResolvedTarget | null>[] = [];
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
if (!ctx) continue;
const serverUrl = ctx.serverUrl.replace(/\/+$/, '');
probes.push(
(async () => {
try {
const res = await fetch(`${serverUrl}/.well-known/jmap`, {
headers: { Authorization: ctx.authHeader },
});
if (!res.ok) return null;
const session = (await res.json()) as {
apiUrl?: string;
primaryAccounts?: Record<string, string>;
};
const mailAccountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!session.apiUrl || !mailAccountId) return null;
if (mailAccountId !== accountId) return null;
return { authHeader: ctx.authHeader, apiUrl: session.apiUrl, accountId: mailAccountId };
} catch {
return null;
}
})(),
);
}
const results = await Promise.all(probes);
return results.find((r): r is ResolvedTarget => r !== null) ?? null;
}
async function resolveDefaultTarget(creds: StalwartCredentials): Promise<ResolvedTarget | null> {
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
headers: { Authorization: creds.authHeader },
});
if (!sessionRes.ok) return null;
const session = (await sessionRes.json()) as {
apiUrl?: string;
primaryAccounts?: Record<string, string>;
};
const apiUrl = session.apiUrl;
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!apiUrl || !accountId) return null;
return { authHeader: creds.authHeader, apiUrl, accountId };
}
/**
* GET /api/push/preview
*
@@ -19,31 +82,38 @@ export const dynamic = 'force-dynamic';
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
// SW passes ?accountId=<jmap-account-id> derived from the push payload's
// StateChange so multi-account browsers fetch from the right slot. Older
// clients (and the manual /api/push/preview probe) omit it and fall back
// to the first signed-in slot.
const requestedAccountId = request.nextUrl.searchParams.get('accountId');
let target: ResolvedTarget | null = null;
let authHeader: string;
if (requestedAccountId) {
target = await resolveTargetForAccount(requestedAccountId);
if (!target) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
authHeader = target.authHeader;
} else {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
target = await resolveDefaultTarget(creds);
if (!target) {
return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 });
}
authHeader = creds.authHeader;
}
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
headers: { Authorization: creds.authHeader },
});
if (!sessionRes.ok) {
return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 });
}
const session = (await sessionRes.json()) as {
apiUrl?: string;
primaryAccounts?: Record<string, string>;
};
const apiUrl = session.apiUrl;
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!apiUrl || !accountId) {
return NextResponse.json({ error: 'Incomplete JMAP session' }, { status: 502 });
}
const { apiUrl, accountId } = target;
const inboxRes = await fetch(apiUrl, {
method: 'POST',
headers: {
Authorization: creds.authHeader,
Authorization: authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify({
@@ -119,7 +189,7 @@ export async function GET(request: NextRequest) {
const jmapRes = await fetch(apiUrl, {
method: 'POST',
headers: {
Authorization: creds.authHeader,
Authorization: authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
+8 -5
View File
@@ -58,13 +58,16 @@ export async function POST(request: NextRequest) {
}
try {
// 1. Provision the admin account. Aborts cleanly if one already exists
// (defence in depth - should be impossible in bootstrap state).
const created = await setInitialAdminPassword(adminPassword);
// 1. Provision the admin account. An admin.json file may already exist
// from a previous ADMIN_PASSWORD env var or an aborted earlier wizard
// run while setupComplete is still false — accept the wizard's
// password as authoritative in that case. The finish route is gated
// by the bootstrap state + one-time setup token, so this is safe.
const created = await setInitialAdminPassword(adminPassword, { allowOverwrite: true });
if (!created) {
return NextResponse.json(
{ error: 'Admin account already exists; cannot finish setup again' },
{ status: 409 },
{ error: 'Failed to write admin credentials' },
{ status: 500 },
);
}
+7 -1
View File
@@ -1,4 +1,4 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import { getLocale } from "next-intl/server";
@@ -17,6 +17,12 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
export async function generateMetadata(): Promise<Metadata> {
await configManager.ensureLoaded();
const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
+67 -2
View File
@@ -2,7 +2,7 @@
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock } from 'lucide-react';
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
type State = 'bootstrap' | 'configured' | 'env-managed';
@@ -101,9 +101,20 @@ export default function SetupWizardPage() {
const [config, setConfig] = useState<WizardConfig>(EMPTY_CONFIG);
const [stepIndex, setStepIndex] = useState(0);
const [completed, setCompleted] = useState(false);
// Detect synchronously on first client render so we don't flash the loading
// screen before the warning appears. The session cookie is set with the
// Secure flag in production, which browsers silently drop over plain HTTP -
// every subsequent step call then 401s with "Wizard session required".
const [insecureContext] = useState<boolean>(detectInsecureContext);
// ─── Initial status load ────────────────────────────────────────────────
useEffect(() => {
// Skip the status fetch entirely when we're going to render the HTTPS
// notice - the wizard cookie can't survive an HTTP origin anyway.
if (insecureContext) {
setBootstrapping(false);
return;
}
let cancelled = false;
(async () => {
try {
@@ -141,7 +152,7 @@ export default function SetupWizardPage() {
return () => {
cancelled = true;
};
}, [router]);
}, [router, insecureContext]);
// ─── Token submit (welcome step) ────────────────────────────────────────
async function submitToken(token: string) {
@@ -173,6 +184,10 @@ export default function SetupWizardPage() {
}
// ─── Render shell ───────────────────────────────────────────────────────
if (insecureContext) {
return <InsecureContextScreen />;
}
if (bootstrapping) {
return <CenteredCard><p className="text-muted-foreground">Loading</p></CenteredCard>;
}
@@ -347,6 +362,44 @@ function CompletedScreen() {
);
}
function InsecureContextScreen() {
const httpsUrl =
typeof window !== 'undefined'
? `https://${window.location.host}${window.location.pathname}${window.location.search}`
: '';
return (
<CenteredCard>
<div className="text-center">
<div className="mx-auto h-12 w-12 rounded-full bg-warning/15 text-warning flex items-center justify-center mb-4">
<ShieldAlert className="h-6 w-6" />
</div>
<h1 className="text-xl font-semibold">HTTPS required for setup</h1>
<p className="text-sm text-muted-foreground mt-2">
The setup wizard signs you in with a <code className="font-mono text-xs">Secure</code> cookie,
which your browser will only accept over HTTPS. Loading this page over plain HTTP causes every
step to fail with <em>Wizard session required</em>.
</p>
</div>
<div className="mt-5 text-left text-sm text-muted-foreground space-y-2">
<p className="font-medium text-foreground">To continue, do one of the following:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Reach this page over HTTPS (terminate TLS on the container or a reverse proxy in front of it).</li>
<li>If you already have a reverse proxy, make sure it forwards to the webmail and forwards the
<code className="font-mono text-xs"> X-Forwarded-Proto</code> header.</li>
</ul>
</div>
{httpsUrl && (
<a
href={httpsUrl}
className="mt-6 block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
>
Open over HTTPS
</a>
)}
</CenteredCard>
);
}
function AlreadyConfiguredScreen() {
return (
<CenteredCard>
@@ -1729,6 +1782,18 @@ function isInsecureHttpUrl(url: string): boolean {
return /^http:\/\//i.test(url.trim());
}
function detectInsecureContext(): boolean {
if (typeof window === 'undefined') return false;
if (window.location.protocol !== 'http:') return false;
// Browsers treat localhost/loopback as "potentially trustworthy" and accept
// Secure cookies even without TLS, so the wizard still works there.
const host = window.location.hostname;
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') {
return false;
}
return true;
}
function humanError(e: unknown): string {
if (e instanceof Error) return e.message;
if (typeof e === 'string') return e;
+47 -32
View File
@@ -388,41 +388,56 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
setActionNotice(null);
setActionError(null);
try {
const events = await client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId);
if (events.length > 0) {
const parsed = events[0];
setParsedEvent(parsed);
// JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback.
try {
const blob = await client.fetchBlob(attachment.blobId, 'invite.ics', 'text/calendar');
const rawText = await blob.text();
const icsMethod = extractMethodFromRawIcs(rawText);
if (icsMethod !== 'unknown') {
setRawIcsMethod(icsMethod);
// JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback — in
// parallel with parsing to save a roundtrip.
const [events, rawText] = await Promise.all([
client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId),
(async () => {
try {
const blob = await client.fetchBlob(attachment.blobId, 'invite.ics', 'text/calendar');
return await blob.text();
} catch {
return null;
}
} catch { /* ignore - fall back to heuristic detection */ }
})(),
]);
if (parsed.uid && supportsCalendar) {
const storeHasIt = useCalendarStore.getState().events.some((e) => e.uid === parsed.uid);
if (!storeHasIt) {
try {
const serverEvents = await client.queryCalendarEvents({});
const matching = serverEvents.filter((e) => e.uid === parsed.uid);
if (matching.length > 0) {
useCalendarStore.setState((s) => {
const existingIds = new Set(s.events.map((e) => e.id));
const newEvents = matching.filter((e) => !existingIds.has(e.id));
return newEvents.length > 0 ? { events: [...s.events, ...newEvents] } : s;
});
}
} catch { /* ignore lookup failure */ }
}
}
setState('parsed');
} else {
if (events.length === 0) {
setState('error');
return;
}
const parsed = events[0];
setParsedEvent(parsed);
if (rawText) {
const icsMethod = extractMethodFromRawIcs(rawText);
if (icsMethod !== 'unknown') {
setRawIcsMethod(icsMethod);
}
}
setState('parsed');
// Hydrate the calendar store with the matching event in the background —
// only needed for the "already in calendar" pill, must not block the banner.
// Filter by UID server-side; the previous unfiltered query fetched up to
// 1000 events plus multiple /get batches just to find one match.
if (parsed.uid && supportsCalendar) {
const storeHasIt = useCalendarStore.getState().events.some((e) => e.uid === parsed.uid);
if (!storeHasIt) {
client.queryCalendarEvents({ uid: parsed.uid })
.then((matching) => {
if (matching.length === 0) return;
useCalendarStore.setState((s) => {
const existingIds = new Set(s.events.map((e) => e.id));
const newEvents = matching.filter((e) => !existingIds.has(e.id));
return newEvents.length > 0 ? { events: [...s.events, ...newEvents] } : s;
});
})
.catch(() => { /* ignore lookup failure */ });
}
}
} catch {
setState('error');
+1 -1
View File
@@ -1793,7 +1793,7 @@ export function EmailComposer({
)}
{/* Bottom toolbar */}
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0">
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0 pb-[calc(env(safe-area-inset-bottom)/2)]">
{/* Left side actions */}
<div className="flex items-center gap-1">
<input
+1 -1
View File
@@ -296,7 +296,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{trimmedPreview || "No preview available"}
{trimmedPreview || t('no_preview_available')}
</p>
)}
</>
+368 -328
View File
@@ -2489,7 +2489,7 @@ export function EmailViewer({
}
return {
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
html: `<p style="color: var(--color-muted-foreground); font-style: italic;">${t('no_body_content')}</p>`,
isHtml: false,
hasStyleTag: false,
};
@@ -2497,7 +2497,7 @@ export function EmailViewer({
// toggling permission imperatively unblocks content via restoreBlockedContent
// in an effect below, so the iframe srcDoc stays stable and doesn't reload/flash.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email, externalContentPolicy, cidBlobUrls]);
}, [email, externalContentPolicy, cidBlobUrls, t]);
// Override email content with S/MIME decrypted content when available
const effectiveEmailContent = useMemo(() => {
@@ -2740,8 +2740,8 @@ export function EmailViewer({
// i.e. light-on-light. The second rule disables filter on bgcolor-like
// elements that are descendants of another bgcolor-like element.
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
html { background: #1a1a1a; }
body { filter: invert(1) hue-rotate(180deg); }
html { background: #121212; }
body { filter: invert(1) hue-rotate(180deg); background: #ededed; }
img, video, svg, canvas, object, embed, input[type="image"] {
filter: invert(1) hue-rotate(180deg);
}
@@ -2776,8 +2776,16 @@ export function EmailViewer({
p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0 0 6px; }
` : '';
// Defense-in-depth CSP inside srcDoc: even if the sanitizer ever lets a
// <script> tag through, the iframe document forbids script execution
// (default-src 'none'). img/style/font remain permissive to match what the
// sanitizer is allowed to emit and what the host already permits when
// external content is loaded.
const iframeCsp = "default-src 'none'; img-src data: blob: http: https:; style-src 'unsafe-inline'; font-src data: http: https:; media-src data: blob: http: https:; base-uri 'none'; form-action 'none'; frame-src 'none'";
return `<!DOCTYPE html>
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${iframeCsp}">
<style>
body { margin: 0; padding: ${bodyPadding}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
img { max-width: 100% !important; height: auto !important; }
@@ -2854,7 +2862,10 @@ export function EmailViewer({
// True while the new email's body is still being fetched. Catches the
// window between selectedEmail changing and isLoading flipping true, so the
// quick reply / body don't flicker through a partial render.
const isBodyLoading = isLoading || !email?.bodyValues || Object.keys(email.bodyValues).length === 0;
// An empty bodyValues with no referenced parts means the email has no body
// (e.g. calendar-only invites) — not "still loading".
const hasBodyParts = (email?.textBody?.length ?? 0) > 0 || (email?.htmlBody?.length ?? 0) > 0;
const isBodyLoading = isLoading || (hasBodyParts && (!email?.bodyValues || Object.keys(email.bodyValues).length === 0));
// Gates the quick reply on the iframe having loaded the current srcDoc, so
// it doesn't flash in below a still-resizing iframe.
@@ -4028,7 +4039,8 @@ export function EmailViewer({
)}
{/* Email Content Area */}
<div className={cn("flex-1 overflow-auto overscroll-contain bg-muted/30", isMobile && "pb-16")}>
<div className={cn("flex-1 overflow-auto overscroll-contain bg-muted/30", isMobile && "pb-[calc(3.25rem+env(safe-area-inset-bottom)/2)] sm:pb-0")}>
<div className="min-h-full flex flex-col">
{/* === SENDER INFO (Desktop) === */}
<div className="hidden lg:block bg-background border-b border-border px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
@@ -4052,13 +4064,16 @@ export function EmailViewer({
<div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
className="font-semibold text-foreground hover:text-primary hover:underline transition-colors cursor-pointer text-left"
title={t('view_contact')}
>
{sender?.name || sender?.email || t('unknown_sender')}
</button>
{sender?.email ? (
<RecipientPopover
name={sender?.name}
email={sender.email}
onViewContact={handleViewContactSidebar}
className="font-semibold text-left"
/>
) : (
<span className="font-semibold text-foreground">{t('unknown_sender')}</span>
)}
<EmailIdentityBadge email={email} identities={identities} />
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
@@ -4134,310 +4149,6 @@ export function EmailViewer({
</button>
</div>
{/* Expandable Details */}
{showFullHeaders && (() => {
const translateAuthResult = (result?: string) => {
const r = (result || '').toLowerCase();
switch (r) {
case 'pass': return t('authentication.result.pass');
case 'fail': return t('authentication.result.fail');
case 'softfail': return t('authentication.result.softfail');
case 'neutral': return t('authentication.result.neutral');
case 'permerror': return t('authentication.result.permerror');
case 'temperror': return t('authentication.result.temperror');
case 'none': return t('authentication.result.none');
default: return result || '';
}
};
const replyToDifferent = !!email.replyTo?.length &&
(!email.from || email.replyTo[0].email !== email.from[0]?.email);
const deliveryDeltaMs = email.sentAt && email.receivedAt
? Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime())
: 0;
const formatDelta = (diff: number) => {
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const dayUnit = days > 1 ? t('time.days') : t('time.day');
const hourUnit = (hours % 24) > 1 ? t('time.hours') : t('time.hour');
const minuteUnit = (minutes % 60) > 1 ? t('time.minutes') : t('time.minute');
const minuteUnitSingle = minutes > 1 ? t('time.minutes') : t('time.minute');
if (days > 0) return `${days} ${dayUnit} ${hours % 24} ${hourUnit}`;
if (hours > 0) return `${hours} ${hourUnit} ${minutes % 60} ${minuteUnit}`;
return `${minutes} ${minuteUnitSingle}`;
};
const fullDate = (iso?: string) => iso
? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' })
: '-';
const auth = email.authenticationResults;
const totalAttachmentSize = effectiveAttachments.reduce((s, a) => s + (a.size || 0), 0);
const topMimeType = email.bodyStructure?.type;
const SectionHeader = ({ children }: { children: React.ReactNode }) => (
<div className="text-[10px] font-semibold tracking-wider text-muted-foreground uppercase mb-1.5">
{children}
</div>
);
const Row = ({ label, children, mono }: { label: string; children: React.ReactNode; mono?: boolean }) => (
<>
<dt className="text-muted-foreground text-xs pt-1">{label}</dt>
<dd className={cn(
"text-sm text-foreground min-w-0 break-words",
mono && "font-mono text-xs",
)}>{children}</dd>
</>
);
const AuthChip = ({ name, result, extra, tooltip }: { name: string; result?: string; extra?: React.ReactNode; tooltip?: string }) => {
if (!result) return null;
const status = getSecurityStatus(result);
const Icon = status.icon === 'check' ? Check
: status.icon === 'x' ? X
: status.icon === 'alert' ? AlertTriangle
: Minus;
return (
<span
className={cn(
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
tooltip && "cursor-help",
status.icon === 'check' && "bg-green-500/[0.07] border-green-500/30",
status.icon === 'x' && "bg-red-500/[0.07] border-red-500/30",
status.icon === 'alert' && "bg-amber-500/[0.07] border-amber-500/30",
status.icon === 'minus' && "bg-muted/40 border-border",
)}
title={tooltip}
>
<Icon className={cn("w-3.5 h-3.5 flex-shrink-0", status.color)} />
<span className="font-medium text-foreground">{name}</span>
<span className={cn("text-[10px] uppercase tracking-wider", status.color)}>
{translateAuthResult(result)}
</span>
{extra && (
<>
<span className="text-muted-foreground/50">·</span>
<span className="text-muted-foreground">{extra}</span>
</>
)}
</span>
);
};
const hasIdentifiers = !!(email.messageId || email.inReplyTo?.length || email.references?.length || email.threadId);
const hasListInfo = !!(listHeaders?.listId || listHeaders?.listUnsubscribe || listHeaders?.listHelp || listHeaders?.listPost);
const hasAuthSection = !!(auth?.spf || auth?.dkim || auth?.dmarc || auth?.iprev || email.spamScore !== undefined || email.spamLLM);
return (
<div className="mt-3 pt-3 border-t border-border grid grid-cols-1 lg:grid-cols-2 gap-x-10 gap-y-5">
<section>
<SectionHeader>{t('details.recipients_routing')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
<Row label={t('from')}>
<div className="flex flex-wrap items-center gap-1">
<RecipientPopover
name={sender?.name}
email={sender?.email || ''}
displayLabel={sender?.name && sender?.email ? `${sender.name} <${sender.email}>` : undefined}
onViewContact={handleViewContactSidebar}
className="text-sm text-left"
/>
</div>
</Row>
{replyToDifferent && (
<Row label={t('reply_to_label').replace(':', '')}>
<div className="flex flex-wrap items-center gap-1">
{email.replyTo!.map((r, i) => (
<RecipientPopover key={r.email + i} name={r.name} email={r.email} onViewContact={handleViewContactSidebar} className="text-sm" />
))}
</div>
</Row>
)}
{email.to && email.to.length > 0 && (
<Row label={t('to')}>
<div className="flex flex-wrap items-center gap-1">
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar, 100)}
</div>
</Row>
)}
{email.cc && email.cc.length > 0 && (
<Row label={t('cc')}>
<div className="flex flex-wrap items-center gap-1">
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar, 100)}
</div>
</Row>
)}
{email.bcc && email.bcc.length > 0 && (
<Row label={t('bcc')}>
<div className="flex flex-wrap items-center gap-1">
{renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar, 100)}
</div>
</Row>
)}
{email.sentAt && (
<Row label={t('details.sent')}>{fullDate(email.sentAt)}</Row>
)}
<Row label={t('details.received')}>
{fullDate(email.receivedAt)}
{deliveryDeltaMs > 60000 && (
<span className="text-muted-foreground"> · {formatDelta(deliveryDeltaMs)} {t('details.delivery_time').toLowerCase()}</span>
)}
</Row>
</dl>
</section>
{hasAuthSection && (
<section>
<SectionHeader>{t('details.authentication_security')}</SectionHeader>
<div className="flex flex-wrap gap-1.5">
{auth?.spf && (
<AuthChip name="SPF" result={auth.spf.result} extra={auth.spf.domain} tooltip={t('authentication.tooltip_spf')} />
)}
{auth?.dkim && (
<AuthChip name="DKIM" result={auth.dkim.result} extra={auth.dkim.domain} tooltip={t('authentication.tooltip_dkim')} />
)}
{auth?.dmarc && (
<AuthChip name="DMARC" result={auth.dmarc.result} extra={auth.dmarc.policy ? `${t('authentication.policy').toLowerCase()}: ${auth.dmarc.policy}` : undefined} tooltip={t('authentication.tooltip_dmarc')} />
)}
{auth?.iprev && (
<AuthChip name={t('details.iprev')} result={auth.iprev.result} extra={auth.iprev.ip} />
)}
{email.spamScore !== undefined && (
<span className={cn(
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
email.spamScore > 5 ? "bg-red-500/[0.07] border-red-500/30" :
email.spamScore > 2 ? "bg-amber-500/[0.07] border-amber-500/30" :
"bg-green-500/[0.07] border-green-500/30",
)}>
<Shield className={cn(
"w-3.5 h-3.5",
email.spamScore > 5 ? "text-red-700 dark:text-red-400" :
email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" :
"text-green-700 dark:text-green-400",
)} />
<span className="font-medium text-foreground">{t('authentication.spam_score')}</span>
<span className={cn(
"text-[10px] uppercase tracking-wider",
email.spamScore > 5 ? "text-red-700 dark:text-red-400" :
email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" :
"text-green-700 dark:text-green-400",
)}>
{email.spamScore.toFixed(1)}
</span>
{email.spamStatus && (
<>
<span className="text-muted-foreground/50">·</span>
<span className="text-muted-foreground">{email.spamStatus}</span>
</>
)}
</span>
)}
</div>
{email.spamLLM && (
<div className="mt-2 flex items-start gap-2 text-sm">
{email.spamLLM.verdict === 'LEGITIMATE' ? <Brain className="w-4 h-4 mt-0.5 flex-shrink-0 text-green-700 dark:text-green-400" /> :
email.spamLLM.verdict === 'SPAM' ? <ShieldAlert className="w-4 h-4 mt-0.5 flex-shrink-0 text-red-700 dark:text-red-400" /> :
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0 text-amber-700 dark:text-amber-400" />}
<div className="min-w-0">
<span className={cn(
"font-medium",
email.spamLLM.verdict === 'LEGITIMATE' ? "text-green-700 dark:text-green-400" :
email.spamLLM.verdict === 'SPAM' ? "text-red-700 dark:text-red-400" :
"text-amber-700 dark:text-amber-400",
)}>
{email.spamLLM.verdict}
</span>
<span className="text-muted-foreground"> · {email.spamLLM.explanation}</span>
</div>
</div>
)}
</section>
)}
{hasIdentifiers && (
<section>
<SectionHeader>{t('details.identifiers_threading')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
{email.messageId && (
<Row label={t('headers.message_id')} mono>{email.messageId}</Row>
)}
{email.inReplyTo && email.inReplyTo.length > 0 && (
<Row label={t('details.in_reply_to')} mono>
<div className="space-y-0.5">
{email.inReplyTo.map((id, i) => <div key={i} className="break-all">{id}</div>)}
</div>
</Row>
)}
{email.references && email.references.length > 0 && (
<Row label={t('details.references')}>
<details className="group">
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground transition-colors list-none flex items-center gap-1">
<ChevronDown className="w-3 h-3 group-open:rotate-180 transition-transform" />
{t(email.references.length === 1 ? 'previous_messages' : 'previous_messages_plural', { count: email.references.length })}
</summary>
<div className="mt-1 space-y-0.5 font-mono text-xs">
{email.references.map((id, i) => <div key={i} className="break-all">{id}</div>)}
</div>
</details>
</Row>
)}
{email.threadId && (
<Row label={t('details.thread_id')} mono>{email.threadId}</Row>
)}
</dl>
</section>
)}
<section>
<SectionHeader>{t('details.message_properties')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
{email.subject !== undefined && (
<Row label={t('subject')}>{email.subject || <span className="italic text-muted-foreground">{t('details.no_subject')}</span>}</Row>
)}
<Row label={t('details.size')}>
{formatFileSize(email.size)}
{topMimeType && (
<span className="text-muted-foreground"> · <span className="font-mono text-xs">{topMimeType}</span></span>
)}
</Row>
{effectiveAttachments.length > 0 && (
<Row label={t('attachments')}>
{t('details.attachments_summary', {
count: effectiveAttachments.length,
size: formatFileSize(totalAttachmentSize),
})}
</Row>
)}
{email.accountLabel && (
<Row label={t('details.account')}>{email.accountLabel}</Row>
)}
</dl>
</section>
{hasListInfo && (
<section className="lg:col-span-2">
<SectionHeader>{t('details.mailing_list')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
{listHeaders?.listId && (
<Row label={t('details.list_id')} mono>{listHeaders.listId}</Row>
)}
{listHeaders?.listUnsubscribe?.preferred && (
<Row label={t('details.list_unsubscribe')}>
<span className="break-all">
{listHeaders.listUnsubscribe.preferred === 'http'
? listHeaders.listUnsubscribe.http
: listHeaders.listUnsubscribe.mailto}
</span>
</Row>
)}
{listHeaders?.listHelp && (
<Row label={t('details.list_help')}><span className="break-all">{listHeaders.listHelp}</span></Row>
)}
{listHeaders?.listPost && (
<Row label={t('details.list_post')}><span className="break-all">{listHeaders.listPost}</span></Row>
)}
</dl>
</section>
)}
</div>
);
})()}
</div>
{/* Attachments on the right (beside-sender mode) */}
@@ -4597,12 +4308,16 @@ export function EmailViewer({
<div className="flex-1 min-w-0">
{/* Row 1: Sender name + badges */}
<div className="flex items-center gap-1.5 flex-wrap">
<button
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
className="text-sm font-semibold text-foreground hover:text-primary hover:underline transition-colors cursor-pointer text-left"
>
{sender?.name || sender?.email || t('unknown_sender')}
</button>
{sender?.email ? (
<RecipientPopover
name={sender?.name}
email={sender.email}
onViewContact={handleViewContactSidebar}
className="text-sm font-semibold text-left"
/>
) : (
<span className="text-sm font-semibold text-foreground">{t('unknown_sender')}</span>
)}
<EmailIdentityBadge email={email} identities={identities} />
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
@@ -4639,6 +4354,22 @@ export function EmailViewer({
)}
</>
)}
<button
onClick={() => setShowFullHeaders(!showFullHeaders)}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors ml-1"
>
{showFullHeaders ? (
<>
<ChevronUp className="w-3 h-3" />
{t('hide_details')}
</>
) : (
<>
<ChevronDown className="w-3 h-3" />
{t('show_details')}
</>
)}
</button>
</div>
</div>
{/* Date/time + size on the right (mobile) */}
@@ -4655,6 +4386,313 @@ export function EmailViewer({
</div>
</div>
{/* Expandable Details (shared across mobile/tablet/desktop) */}
{showFullHeaders && (() => {
const translateAuthResult = (result?: string) => {
const r = (result || '').toLowerCase();
switch (r) {
case 'pass': return t('authentication.result.pass');
case 'fail': return t('authentication.result.fail');
case 'softfail': return t('authentication.result.softfail');
case 'neutral': return t('authentication.result.neutral');
case 'permerror': return t('authentication.result.permerror');
case 'temperror': return t('authentication.result.temperror');
case 'none': return t('authentication.result.none');
default: return result || '';
}
};
const replyToDifferent = !!email.replyTo?.length &&
(!email.from || email.replyTo[0].email !== email.from[0]?.email);
const deliveryDeltaMs = email.sentAt && email.receivedAt
? Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime())
: 0;
const formatDelta = (diff: number) => {
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const dayUnit = days > 1 ? t('time.days') : t('time.day');
const hourUnit = (hours % 24) > 1 ? t('time.hours') : t('time.hour');
const minuteUnit = (minutes % 60) > 1 ? t('time.minutes') : t('time.minute');
const minuteUnitSingle = minutes > 1 ? t('time.minutes') : t('time.minute');
if (days > 0) return `${days} ${dayUnit} ${hours % 24} ${hourUnit}`;
if (hours > 0) return `${hours} ${hourUnit} ${minutes % 60} ${minuteUnit}`;
return `${minutes} ${minuteUnitSingle}`;
};
const fullDate = (iso?: string) => iso
? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' })
: '-';
const auth = email.authenticationResults;
const totalAttachmentSize = effectiveAttachments.reduce((s, a) => s + (a.size || 0), 0);
const topMimeType = email.bodyStructure?.type;
const SectionHeader = ({ children }: { children: React.ReactNode }) => (
<div className="text-[10px] font-semibold tracking-wider text-muted-foreground uppercase mb-1.5">
{children}
</div>
);
const Row = ({ label, children, mono }: { label: string; children: React.ReactNode; mono?: boolean }) => (
<>
<dt className="text-muted-foreground text-xs pt-1">{label}</dt>
<dd className={cn(
"text-sm text-foreground min-w-0 break-words",
mono && "font-mono text-xs",
)}>{children}</dd>
</>
);
const AuthChip = ({ name, result, extra, tooltip }: { name: string; result?: string; extra?: React.ReactNode; tooltip?: string }) => {
if (!result) return null;
const status = getSecurityStatus(result);
const Icon = status.icon === 'check' ? Check
: status.icon === 'x' ? X
: status.icon === 'alert' ? AlertTriangle
: Minus;
return (
<span
className={cn(
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
tooltip && "cursor-help",
status.icon === 'check' && "bg-green-500/[0.07] border-green-500/30",
status.icon === 'x' && "bg-red-500/[0.07] border-red-500/30",
status.icon === 'alert' && "bg-amber-500/[0.07] border-amber-500/30",
status.icon === 'minus' && "bg-muted/40 border-border",
)}
title={tooltip}
>
<Icon className={cn("w-3.5 h-3.5 flex-shrink-0", status.color)} />
<span className="font-medium text-foreground">{name}</span>
<span className={cn("text-[10px] uppercase tracking-wider", status.color)}>
{translateAuthResult(result)}
</span>
{extra && (
<>
<span className="text-muted-foreground/50">·</span>
<span className="text-muted-foreground">{extra}</span>
</>
)}
</span>
);
};
const hasIdentifiers = !!(email.messageId || email.inReplyTo?.length || email.references?.length || email.threadId);
const hasListInfo = !!(listHeaders?.listId || listHeaders?.listUnsubscribe || listHeaders?.listHelp || listHeaders?.listPost);
const hasAuthSection = !!(auth?.spf || auth?.dkim || auth?.dmarc || auth?.iprev || email.spamScore !== undefined || email.spamLLM);
return (
<div className="bg-background border-b border-border px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-10 gap-y-5">
<section className="min-w-0">
<SectionHeader>{t('details.recipients_routing')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
<Row label={t('from')}>
<div className="flex flex-wrap items-center gap-1">
<RecipientPopover
name={sender?.name}
email={sender?.email || ''}
displayLabel={sender?.name && sender?.email ? `${sender.name} <${sender.email}>` : undefined}
onViewContact={handleViewContactSidebar}
className="text-sm text-left"
/>
</div>
</Row>
{replyToDifferent && (
<Row label={t('reply_to_label').replace(':', '')}>
<div className="flex flex-wrap items-center gap-1">
{email.replyTo!.map((r, i) => (
<RecipientPopover key={r.email + i} name={r.name} email={r.email} onViewContact={handleViewContactSidebar} className="text-sm" />
))}
</div>
</Row>
)}
{email.to && email.to.length > 0 && (
<Row label={t('to')}>
<div className="flex flex-wrap items-center gap-1">
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar, 100)}
</div>
</Row>
)}
{email.cc && email.cc.length > 0 && (
<Row label={t('cc')}>
<div className="flex flex-wrap items-center gap-1">
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar, 100)}
</div>
</Row>
)}
{email.bcc && email.bcc.length > 0 && (
<Row label={t('bcc')}>
<div className="flex flex-wrap items-center gap-1">
{renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar, 100)}
</div>
</Row>
)}
{email.sentAt && (
<Row label={t('details.sent')}>{fullDate(email.sentAt)}</Row>
)}
<Row label={t('details.received')}>
{fullDate(email.receivedAt)}
{deliveryDeltaMs > 60000 && (
<span className="text-muted-foreground"> · {formatDelta(deliveryDeltaMs)} {t('details.delivery_time').toLowerCase()}</span>
)}
</Row>
</dl>
</section>
{hasAuthSection && (
<section className="min-w-0">
<SectionHeader>{t('details.authentication_security')}</SectionHeader>
<div className="flex flex-wrap gap-1.5">
{auth?.spf && (
<AuthChip name="SPF" result={auth.spf.result} extra={auth.spf.domain} tooltip={t('authentication.tooltip_spf')} />
)}
{auth?.dkim && (
<AuthChip name="DKIM" result={auth.dkim.result} extra={auth.dkim.domain} tooltip={t('authentication.tooltip_dkim')} />
)}
{auth?.dmarc && (
<AuthChip name="DMARC" result={auth.dmarc.result} extra={auth.dmarc.policy ? `${t('authentication.policy').toLowerCase()}: ${auth.dmarc.policy}` : undefined} tooltip={t('authentication.tooltip_dmarc')} />
)}
{auth?.iprev && (
<AuthChip name={t('details.iprev')} result={auth.iprev.result} extra={auth.iprev.ip} />
)}
{email.spamScore !== undefined && (
<span className={cn(
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
email.spamScore > 5 ? "bg-red-500/[0.07] border-red-500/30" :
email.spamScore > 2 ? "bg-amber-500/[0.07] border-amber-500/30" :
"bg-green-500/[0.07] border-green-500/30",
)}>
<Shield className={cn(
"w-3.5 h-3.5",
email.spamScore > 5 ? "text-red-700 dark:text-red-400" :
email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" :
"text-green-700 dark:text-green-400",
)} />
<span className="font-medium text-foreground">{t('authentication.spam_score')}</span>
<span className={cn(
"text-[10px] uppercase tracking-wider",
email.spamScore > 5 ? "text-red-700 dark:text-red-400" :
email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" :
"text-green-700 dark:text-green-400",
)}>
{email.spamScore.toFixed(1)}
</span>
{email.spamStatus && (
<>
<span className="text-muted-foreground/50">·</span>
<span className="text-muted-foreground">{email.spamStatus}</span>
</>
)}
</span>
)}
</div>
{email.spamLLM && (
<div className="mt-2 flex items-start gap-2 text-sm">
{email.spamLLM.verdict === 'LEGITIMATE' ? <Brain className="w-4 h-4 mt-0.5 flex-shrink-0 text-green-700 dark:text-green-400" /> :
email.spamLLM.verdict === 'SPAM' ? <ShieldAlert className="w-4 h-4 mt-0.5 flex-shrink-0 text-red-700 dark:text-red-400" /> :
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0 text-amber-700 dark:text-amber-400" />}
<div className="min-w-0">
<span className={cn(
"font-medium",
email.spamLLM.verdict === 'LEGITIMATE' ? "text-green-700 dark:text-green-400" :
email.spamLLM.verdict === 'SPAM' ? "text-red-700 dark:text-red-400" :
"text-amber-700 dark:text-amber-400",
)}>
{email.spamLLM.verdict}
</span>
<span className="text-muted-foreground"> · {email.spamLLM.explanation}</span>
</div>
</div>
)}
</section>
)}
{hasIdentifiers && (
<section className="min-w-0">
<SectionHeader>{t('details.identifiers_threading')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
{email.messageId && (
<Row label={t('headers.message_id')} mono>{email.messageId}</Row>
)}
{email.inReplyTo && email.inReplyTo.length > 0 && (
<Row label={t('details.in_reply_to')} mono>
<div className="space-y-0.5">
{email.inReplyTo.map((id, i) => <div key={i} className="break-all">{id}</div>)}
</div>
</Row>
)}
{email.references && email.references.length > 0 && (
<Row label={t('details.references')}>
<details className="group">
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground transition-colors list-none flex items-center gap-1">
<ChevronDown className="w-3 h-3 group-open:rotate-180 transition-transform" />
{t(email.references.length === 1 ? 'previous_messages' : 'previous_messages_plural', { count: email.references.length })}
</summary>
<div className="mt-1 space-y-0.5 font-mono text-xs">
{email.references.map((id, i) => <div key={i} className="break-all">{id}</div>)}
</div>
</details>
</Row>
)}
{email.threadId && (
<Row label={t('details.thread_id')} mono>{email.threadId}</Row>
)}
</dl>
</section>
)}
<section className="min-w-0">
<SectionHeader>{t('details.message_properties')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
{email.subject !== undefined && (
<Row label={t('subject')}>{email.subject || <span className="italic text-muted-foreground">{t('details.no_subject')}</span>}</Row>
)}
<Row label={t('details.size')}>
{formatFileSize(email.size)}
{topMimeType && (
<span className="text-muted-foreground"> · <span className="font-mono text-xs">{topMimeType}</span></span>
)}
</Row>
{effectiveAttachments.length > 0 && (
<Row label={t('attachments')}>
{t('details.attachments_summary', {
count: effectiveAttachments.length,
size: formatFileSize(totalAttachmentSize),
})}
</Row>
)}
{email.accountLabel && (
<Row label={t('details.account')}>{email.accountLabel}</Row>
)}
</dl>
</section>
{hasListInfo && (
<section className="lg:col-span-2 min-w-0">
<SectionHeader>{t('details.mailing_list')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
{listHeaders?.listId && (
<Row label={t('details.list_id')} mono>{listHeaders.listId}</Row>
)}
{listHeaders?.listUnsubscribe?.preferred && (
<Row label={t('details.list_unsubscribe')}>
<span className="break-all">
{listHeaders.listUnsubscribe.preferred === 'http'
? listHeaders.listUnsubscribe.http
: listHeaders.listUnsubscribe.mailto}
</span>
</Row>
)}
{listHeaders?.listHelp && (
<Row label={t('details.list_help')}><span className="break-all">{listHeaders.listHelp}</span></Row>
)}
{listHeaders?.listPost && (
<Row label={t('details.list_post')}><span className="break-all">{listHeaders.listPost}</span></Row>
)}
</dl>
</section>
)}
</div>
</div>
);
})()}
{/* S/MIME Status Banner */}
{smimeStatus && (
<div className="border-b border-border bg-muted/30">
@@ -5091,13 +5129,14 @@ export function EmailViewer({
</div>
)}
<div>
<div className="grow shrink-0 flex flex-col">
{/* Email Body */}
<div className={cn(
"email-content-wrapper overflow-x-auto",
!isDark && resolvedTheme === 'dark' ? "bg-white email-content-light" : "bg-background"
)}>
)}
style={isDark ? { backgroundColor: '#121212' } : undefined}>
{isBodyLoading ? (
<div
className="space-y-3 px-6 py-4 animate-pulse"
@@ -5137,7 +5176,7 @@ export function EmailViewer({
<PluginSlot name="email-footer" />
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
<div className="flex-shrink-0">
<Avatar
@@ -5232,6 +5271,7 @@ export function EmailViewer({
</div>)}
</div>
</div>
</div>
{/* Email Source Modal */}
{showSourceModal && email && (
@@ -5283,7 +5323,7 @@ export function EmailViewer({
{/* Mobile bottom action bar */}
{isMobile && (
<nav className="fixed bottom-0 left-0 right-0 z-[50] bg-background border-t border-border sm:hidden overflow-hidden">
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-background border-t border-border sm:hidden overflow-hidden pb-[calc(env(safe-area-inset-bottom)/2)]">
<div className="flex items-center overflow-x-auto mobile-scroll-hidden">
<button
onClick={onNavigatePrev}
@@ -5433,4 +5473,4 @@ export function EmailViewer({
</div>
);
}
}
+1 -1
View File
@@ -127,7 +127,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
ref={triggerRef}
onClick={handleOpen}
className={cn(
"text-foreground hover:text-primary hover:underline cursor-pointer transition-colors",
"text-foreground hover:text-primary hover:underline cursor-pointer transition-colors min-w-0 break-words",
className
)}
>
+69 -14
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
@@ -440,6 +440,49 @@ function EmailCard({
return { html: "", isHtml: false };
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls]);
// Render the sanitized HTML body inside a sandboxed iframe so a malicious
// (or accidentally-bypassed) email cannot inject styles/scripts/forms into
// the host page. CSP <meta> is defense-in-depth in case the sanitizer ever
// emits a <script> tag through a parser quirk.
const iframeRef = useRef<HTMLIFrameElement>(null);
const emailIframeSrcDoc = useMemo(() => {
if (!emailContent.isHtml || !emailContent.html) return '';
const csp = "default-src 'none'; img-src data: blob: http: https:; style-src 'unsafe-inline'; font-src data: http: https:; media-src data: blob: http: https:; base-uri 'none'; form-action 'none'; frame-src 'none'";
return `<!DOCTYPE html><html><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${csp}">
<style>
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
img { max-width: 100% !important; height: auto !important; }
a { color: #1a73e8; }
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
td, th { word-break: break-word; padding: 0.5rem; }
pre { white-space: pre-wrap; word-wrap: break-word; }
</style></head><body>${emailContent.html}</body></html>`;
}, [emailContent.isHtml, emailContent.html]);
const handleIframeLoad = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe) return;
try {
const doc = iframe.contentDocument;
if (!doc?.body) return;
const resize = () => {
iframe.style.height = doc.documentElement.scrollHeight + 'px';
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(doc.body);
doc.querySelectorAll('a').forEach((a) => {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
} catch {
// contentDocument may be inaccessible under stricter sandboxes; ignore.
}
}, []);
return (
<div className={cn(
"rounded-lg border border-border overflow-hidden transition-all duration-200",
@@ -483,7 +526,7 @@ function EmailCard({
</div>
{!isExpanded && density !== 'extra-compact' && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{email.preview || "No preview available"}
{email.preview || t('email_viewer.no_preview_available')}
</p>
)}
</div>
@@ -534,18 +577,30 @@ function EmailCard({
{/* Email Body */}
<div style={{ padding: 'var(--density-card-p)' }}>
<div
className={cn(
"prose prose-sm max-w-none",
!emailAlwaysLightMode && "dark:prose-invert",
"prose-p:my-2 prose-headings:my-3",
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
"[&_img]:max-w-full [&_img]:h-auto"
)}
style={!emailContent.isHtml ? { whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace', fontSize: '13px' } : undefined}
dangerouslySetInnerHTML={{ __html: emailContent.html }}
/>
{emailContent.isHtml ? (
<iframe
ref={iframeRef}
srcDoc={emailIframeSrcDoc}
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
title="Email content"
className="w-full border-0 block"
style={{ minHeight: '60px' }}
onLoad={handleIframeLoad}
/>
) : (
<div
className={cn(
"prose prose-sm max-w-none",
!emailAlwaysLightMode && "dark:prose-invert",
"prose-p:my-2 prose-headings:my-3",
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
"[&_img]:max-w-full [&_img]:h-auto"
)}
style={{ whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace', fontSize: '13px' }}
dangerouslySetInnerHTML={{ __html: emailContent.html }}
/>
)}
</div>
{/* Attachments */}
+3 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback } from "react";
import { useTranslations } from "next-intl";
import { formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
@@ -27,6 +28,7 @@ export function ThreadEmailItem({
onClick,
onContextMenu,
}: ThreadEmailItemProps) {
const t = useTranslations('email_viewer');
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered;
@@ -177,7 +179,7 @@ export function ThreadEmailItem({
? "text-muted-foreground"
: "text-muted-foreground/70"
)}>
{email.preview || "No preview"}
{email.preview || t('no_preview_available')}
</span>
{/* Date */}
+4 -2
View File
@@ -52,6 +52,7 @@ interface SingleEmailItemProps {
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
const t = useTranslations('email_viewer');
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered;
@@ -317,7 +318,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{trimmedPreview || "No preview available"}
{trimmedPreview || t('no_preview_available')}
</p>
)}
</>
@@ -360,6 +361,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onMarkAsSpam,
}, ref) {
const t = useTranslations('threads');
const tEmailViewer = useTranslations('email_viewer');
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -724,7 +726,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{trimmedPreview || "No preview available"}
{trimmedPreview || tEmailViewer('no_preview_available')}
</p>
)}
</>
+19 -5
View File
@@ -182,7 +182,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
</div>
</div>
<div className="flex-1 flex items-center justify-center overflow-auto p-4" onClick={(e) => e.stopPropagation()}>
<div className="flex-1 flex items-center justify-center overflow-auto p-4">
{loading && (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="w-8 h-8 animate-spin" />
@@ -194,13 +194,19 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
)}
{!loading && !error && (fileType === "text") && content !== null && (
<pre className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm font-mono whitespace-pre-wrap break-words">
<pre
className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm font-mono whitespace-pre-wrap break-words"
onClick={(e) => e.stopPropagation()}
>
{content}
</pre>
)}
{!loading && !error && fileType === "markdown" && content !== null && (
<div className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm">
<div
className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm"
onClick={(e) => e.stopPropagation()}
>
<SimpleMarkdown content={content} />
</div>
)}
@@ -211,6 +217,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
alt={name}
className="max-w-full max-h-full object-contain rounded-lg bg-background"
draggable={false}
onClick={(e) => e.stopPropagation()}
/>
)}
@@ -220,6 +227,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
sandbox=""
className="w-full max-w-5xl h-full rounded-lg bg-white"
title={name}
onClick={(e) => e.stopPropagation()}
/>
)}
@@ -229,6 +237,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
type="application/pdf"
className="w-full max-w-5xl h-full rounded-lg bg-white"
aria-label={name}
onClick={(e) => e.stopPropagation()}
>
<Button onClick={() => void onDownload()}>
<Download className="w-4 h-4 mr-2" />
@@ -238,14 +247,19 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
)}
{!loading && !error && fileType === "audio" && objectUrl && (
<div className="bg-background rounded-lg p-8 max-w-lg w-full">
<div className="bg-background rounded-lg p-8 max-w-lg w-full" onClick={(e) => e.stopPropagation()}>
<p className="text-sm font-medium mb-4 text-center">{name}</p>
<audio controls className="w-full" src={objectUrl} />
</div>
)}
{!loading && !error && fileType === "video" && objectUrl && (
<video controls className="max-w-4xl max-h-full rounded-lg" src={objectUrl} />
<video
controls
className="max-w-4xl max-h-full rounded-lg"
src={objectUrl}
onClick={(e) => e.stopPropagation()}
/>
)}
</div>
</div>
+1 -1
View File
@@ -273,7 +273,7 @@ export function NavigationRail({
if (orientation === "horizontal") {
return (
<nav
className={cn("flex items-center bg-background border-t border-border shrink-0 overflow-x-auto mobile-scroll-hidden", className)}
className={cn("flex items-center bg-background border-t border-border shrink-0 overflow-x-auto mobile-scroll-hidden pb-[calc(env(safe-area-inset-bottom)/2)]", className)}
role="navigation"
aria-label={t("nav_label")}
>
@@ -0,0 +1,16 @@
'use client';
import { PluginSlot } from '@/components/plugins/plugin-slot';
import { useAuthStore } from '@/stores/auth-store';
/**
* Mounts the `app-top-banner` plugin slot with the current session
* username + serverUrl as extraProps. Drop this at the top of every
* authenticated page so plugins like impersonation-notice render
* everywhere, not just on the mail page.
*/
export function AppTopBannerSlot() {
const username = useAuthStore((s) => s.username);
const serverUrl = useAuthStore((s) => s.serverUrl);
return <PluginSlot name="app-top-banner" extraProps={{ username, serverUrl }} />;
}
@@ -52,11 +52,14 @@ export function NotificationSettings() {
useEffect(() => {
if (!supported) return;
if (!client) return;
const accountId = client.getAccountId();
if (!accountId) return;
void (async () => {
const enabled = await isWebPushEnabled();
if (enabled) setPushStatus({ kind: 'enabled' });
const enabled = await isWebPushEnabled(accountId);
setPushStatus(enabled ? { kind: 'enabled' } : { kind: 'idle' });
})();
}, [supported]);
}, [supported, client]);
const trimmedRelay = relayUrl.trim().replace(/\/+$/, '');
const isValidRelay = /^https?:\/\/.+/i.test(trimmedRelay);
+4
View File
@@ -12,6 +12,7 @@ interface ConfigData {
oauthOnly: boolean;
oauthClientId: string;
oauthIssuerUrl: string;
oauthScopes: string;
rememberMeEnabled: boolean;
settingsSyncEnabled: boolean;
stalwartFeaturesEnabled: boolean;
@@ -90,6 +91,7 @@ export function useConfig(): AppConfig {
oauthOnly: configCache?.oauthOnly || false,
oauthClientId: configCache?.oauthClientId || '',
oauthIssuerUrl: configCache?.oauthIssuerUrl || '',
oauthScopes: configCache?.oauthScopes || '',
rememberMeEnabled: configCache?.rememberMeEnabled || false,
settingsSyncEnabled: configCache?.settingsSyncEnabled || false,
stalwartFeaturesEnabled: configCache?.stalwartFeaturesEnabled ?? true,
@@ -124,6 +126,7 @@ export function useConfig(): AppConfig {
oauthOnly: configCache.oauthOnly,
oauthClientId: configCache.oauthClientId,
oauthIssuerUrl: configCache.oauthIssuerUrl,
oauthScopes: configCache.oauthScopes,
rememberMeEnabled: configCache.rememberMeEnabled,
settingsSyncEnabled: configCache.settingsSyncEnabled,
stalwartFeaturesEnabled: configCache.stalwartFeaturesEnabled,
@@ -159,6 +162,7 @@ export function useConfig(): AppConfig {
oauthOnly: data.oauthOnly,
oauthClientId: data.oauthClientId,
oauthIssuerUrl: data.oauthIssuerUrl,
oauthScopes: data.oauthScopes,
rememberMeEnabled: data.rememberMeEnabled,
settingsSyncEnabled: data.settingsSyncEnabled,
stalwartFeaturesEnabled: data.stalwartFeaturesEnabled,
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { createHmac } from 'node:crypto';
import {
ImpersonationJwtError,
verifyImpersonationJwt,
impersonationReplayCache,
} from '@/lib/impersonation/jwt';
const SECRET = 'a'.repeat(64);
const ISSUER = 'platform-api/webmail';
function base64Url(input: Buffer | string): string {
return Buffer.from(input)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
function sign(payload: Record<string, unknown>, secret: string = SECRET, header: Record<string, unknown> = { alg: 'HS256', typ: 'JWT' }): string {
const h = base64Url(JSON.stringify(header));
const p = base64Url(JSON.stringify(payload));
const sig = createHmac('sha256', secret).update(`${h}.${p}`).digest();
return `${h}.${p}.${base64Url(sig)}`;
}
function basePayload(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
const now = Math.floor(Date.now() / 1000);
return {
iss: ISSUER,
iat: now,
exp: now + 120,
jti: 'jti-' + Math.random().toString(36).slice(2),
mailbox: 'alice@example.test',
...overrides,
};
}
describe('verifyImpersonationJwt', () => {
beforeEach(() => {
impersonationReplayCache.clear();
});
it('accepts a valid HS256 token', () => {
const token = sign(basePayload());
const claims = verifyImpersonationJwt(token, SECRET, { expectedIssuer: ISSUER });
expect(claims.mailbox).toBe('alice@example.test');
});
it('rejects non-HS256 algorithms', () => {
const header = { alg: 'none', typ: 'JWT' };
const h = base64Url(JSON.stringify(header));
const p = base64Url(JSON.stringify(basePayload()));
const token = `${h}.${p}.`;
expect(() => verifyImpersonationJwt(token, SECRET)).toThrow(ImpersonationJwtError);
});
it('rejects tokens with a forged signature', () => {
const token = sign(basePayload(), 'a-different-secret-that-is-also-long-enough-32');
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/signature/i);
});
it('rejects when secret is too short', () => {
const token = sign(basePayload());
expect(() => verifyImpersonationJwt(token, 'short')).toThrowError(/32 characters/);
});
it('rejects expired tokens', () => {
const now = Math.floor(Date.now() / 1000);
const token = sign(basePayload({ iat: now - 600, exp: now - 300 }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/expired/i);
});
it('rejects tokens with lifetime over the 300s ceiling', () => {
const now = Math.floor(Date.now() / 1000);
const token = sign(basePayload({ iat: now, exp: now + 3600 }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/lifetime/i);
});
it('rejects tokens with iss mismatch when expectedIssuer is set', () => {
const token = sign(basePayload({ iss: 'someone-else' }));
expect(() =>
verifyImpersonationJwt(token, SECRET, { expectedIssuer: ISSUER }),
).toThrowError(/issuer/i);
});
it("rejects mailbox containing '%'", () => {
const token = sign(basePayload({ mailbox: 'a%b@example.test' }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/'%'/);
});
it("rejects mailbox containing ':'", () => {
const token = sign(basePayload({ mailbox: 'a:b@example.test' }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/':'/);
});
it('rejects malformed tokens', () => {
expect(() => verifyImpersonationJwt('not.a.jwt.extra', SECRET)).toThrow();
expect(() => verifyImpersonationJwt('', SECRET)).toThrow();
});
it('honours nbf with skew', () => {
const now = Math.floor(Date.now() / 1000);
const token = sign(basePayload({ nbf: now + 600 }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/not yet valid/i);
});
});
describe('impersonationReplayCache', () => {
beforeEach(() => {
impersonationReplayCache.clear();
});
it('accepts a jti once and rejects it on second use', () => {
const now = Math.floor(Date.now() / 1000);
expect(impersonationReplayCache.consume('jti-1', now + 60, now)).toBe(true);
expect(impersonationReplayCache.consume('jti-1', now + 60, now)).toBe(false);
});
it('prunes expired jtis on next consume', () => {
const now = Math.floor(Date.now() / 1000);
impersonationReplayCache.consume('jti-old', now - 600, now - 600);
// Far in the future — pruning should clear the old entry.
expect(impersonationReplayCache.consume('jti-new', now + 60, now + 1000)).toBe(true);
// Re-using the old jti is allowed after pruning (security irrelevant since
// the token would fail signature/exp validation upstream).
expect(impersonationReplayCache.consume('jti-old', now + 60, now + 1000)).toBe(true);
});
});
+1
View File
@@ -44,6 +44,7 @@ function resetStore() {
plugins: [],
slots: {
'toolbar-actions': [],
'app-top-banner': [],
'email-banner': [],
'email-footer': [],
'composer-toolbar': [],
+252
View File
@@ -481,6 +481,258 @@ describe("round-trip: parse → generate → parse", () => {
});
});
describe("vCard 4.0 parsing (issue #289)", () => {
it("strips group prefix from property names (item1.EMAIL)", () => {
// Evolution / Apple Contacts emit grouped properties so an X-ABLABEL line
// can attach a label. We must still parse the EMAIL itself.
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Ada Lovelace",
"item1.EMAIL:ada@example.com",
"item1.X-ABLABEL:Personal",
"item2.TEL:tel:+1-555-0100",
"item2.X-ABLABEL:Mobile",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
const card = result[0];
expect(card.emails?.e0?.address).toBe("ada@example.com");
expect(card.phones?.p0?.number).toBe("+1-555-0100");
});
it("strips tel:/mailto: URI scheme from TEL/EMAIL values", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Alan Turing",
"EMAIL:mailto:alan@example.com",
"TEL;VALUE=uri:tel:+44-20-1234-5678",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].emails?.e0?.address).toBe("alan@example.com");
expect(result[0].phones?.p0?.number).toBe("+44-20-1234-5678");
});
it("maps PREF=n parameter to pref field", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Grace Hopper",
"EMAIL;PREF=1:grace@home.example",
"EMAIL;PREF=2:grace@work.example",
"TEL;PREF=1:+1-555-9999",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].emails?.e0?.pref).toBe(1);
expect(result[0].emails?.e1?.pref).toBe(2);
expect(result[0].phones?.p0?.pref).toBe(1);
});
it("decodes RFC 6868 caret-encoded parameter values", () => {
// ^n → LF, ^^ → ^, ^' → DQUOTE
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Test",
'ADR;LABEL="Line 1^nLine 2";TYPE=HOME:;;Sub St;Town;;;US',
"EMAIL:t@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].addresses?.a0?.fullAddress).toBe("Line 1\nLine 2");
expect(result[0].addresses?.a0?.contexts).toEqual({ private: true });
});
it("survives quoted parameter values containing semicolons", () => {
// Without quote-aware param splitting, the ; inside LABEL would shred
// the param list and the ADR would lose its TYPE.
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Lev",
'ADR;LABEL="Building A; Suite 12";TYPE=WORK:;;1 Plaza;NYC;NY;10001;US',
"EMAIL:lev@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].addresses?.a0?.fullAddress).toBe("Building A; Suite 12");
expect(result[0].addresses?.a0?.contexts).toEqual({ work: true });
expect(result[0].addresses?.a0?.locality).toBe("NYC");
});
it("parses BIRTHPLACE and DEATHPLACE (RFC 6474)", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Marie Curie",
"BDAY:18671107",
"BIRTHPLACE:Warsaw\\, Poland",
"DEATHDATE:19340704",
"DEATHPLACE:Passy\\, France",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
const annivs = Object.values(result[0].anniversaries || {});
const birth = annivs.find((a) => a.kind === "birth");
const death = annivs.find((a) => a.kind === "death");
expect(birth?.place?.fullAddress).toBe("Warsaw, Poland");
expect(death?.place?.fullAddress).toBe("Passy, France");
});
it("parses EXPERTISE / HOBBY / INTEREST with LEVEL (RFC 6715)", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Polymath",
"EXPERTISE;LEVEL=expert:cryptography",
"EXPERTISE;LEVEL=beginner:welding",
"HOBBY;LEVEL=high:gardening",
"INTEREST;LEVEL=medium:opera",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
const info = Object.values(result[0].personalInfo || {});
expect(info).toEqual(expect.arrayContaining([
{ kind: "expertise", value: "cryptography", level: "high" },
{ kind: "expertise", value: "welding", level: "low" },
{ kind: "hobby", value: "gardening", level: "high" },
{ kind: "interest", value: "opera", level: "medium" },
]));
});
it("parses ORG-DIRECTORY (RFC 6715) and CONTACT-URI (RFC 8605)", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Corp Person",
"ORG-DIRECTORY:https://example.com/staff/",
"CONTACT-URI;PREF=1:https://example.com/contact",
"EMAIL:c@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(Object.values(result[0].directories || {})[0]).toMatchObject({
uri: "https://example.com/staff/",
kind: "directory",
});
const links = Object.values(result[0].links || {});
expect(links[0]).toMatchObject({
uri: "https://example.com/contact",
kind: "contact",
pref: 1,
});
});
it("parses RFC 9554 CREATED, GRAMGENDER, PRONOUNS", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Modern Person",
"CREATED:20250101T120000Z",
"GRAMGENDER:neuter",
"PRONOUNS:they/them",
"PRONOUNS;PREF=2:ze/zir",
"EMAIL:m@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].created).toBe("20250101T120000Z");
expect(result[0].speakToAs?.grammaticalGender).toBe("neuter");
const pronouns = Object.values(result[0].speakToAs?.pronouns || {});
expect(pronouns).toEqual(expect.arrayContaining([
expect.objectContaining({ pronouns: "they/them" }),
expect.objectContaining({ pronouns: "ze/zir", pref: 2 }),
]));
});
it("accepts vCard 4.0 KIND values (location, device, application)", () => {
for (const k of ["location", "device", "application"] as const) {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
`KIND:${k}`,
"FN:Thing",
"END:VCARD",
].join("\r\n");
expect(parseVCard(vcf)[0].kind).toBe(k);
}
});
it("handles ADR with LABEL/GEO/TZ/CC parameters (RFC 9554)", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:GeoPerson",
'ADR;CC=DE;GEO="geo:52.5,13.4";TZ=Europe/Berlin;LABEL="Unter den Linden 1\\nBerlin":;;Unter den Linden 1;Berlin;;10117;Germany',
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
const addr = result[0].addresses?.a0;
expect(addr?.countryCode).toBe("DE");
expect(addr?.coordinates).toBe("52.5,13.4");
expect(addr?.timeZone).toBe("Europe/Berlin");
expect(addr?.fullAddress).toContain("Unter den Linden 1");
expect(addr?.locality).toBe("Berlin");
});
it("unfolds LF-only continuation lines (no CR)", () => {
// Unix exporters often use LF only; we must still unfold.
const vcf = "BEGIN:VCARD\nVERSION:4.0\nFN:John\n Doe\nEMAIL:j@d.com\nEND:VCARD";
const result = parseVCard(vcf);
expect(result[0].name?.components).toEqual(
expect.arrayContaining([{ kind: "given", value: "JohnDoe" }])
);
});
it("round-trips vCard 4.0-only properties through generateVCard", () => {
const original = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Round Trip",
"EMAIL;PREF=1:rt@example.com",
"BDAY:19700101",
"BIRTHPLACE:Somewhere",
"EXPERTISE;LEVEL=expert:vCard",
"HOBBY;LEVEL=medium:reading",
"ORG-DIRECTORY:https://example.com/dir",
"CONTACT-URI:https://example.com/contact",
"CREATED:20240101T000000Z",
"END:VCARD",
].join("\r\n");
const exported = generateVCard(parseVCard(original));
const reparsed = parseVCard(exported)[0];
expect(reparsed.emails?.e0?.pref).toBe(1);
expect(Object.values(reparsed.anniversaries || {}).find(a => a.kind === "birth")?.place?.fullAddress).toBe("Somewhere");
const info = Object.values(reparsed.personalInfo || {});
expect(info).toEqual(expect.arrayContaining([
{ kind: "expertise", value: "vCard", level: "high" },
{ kind: "hobby", value: "reading", level: "medium" },
]));
expect(Object.values(reparsed.directories || {})[0]?.uri).toBe("https://example.com/dir");
expect(Object.values(reparsed.links || {})[0]).toMatchObject({
uri: "https://example.com/contact",
kind: "contact",
});
expect(reparsed.created).toBe("20240101T000000Z");
});
});
describe("detectDuplicates", () => {
it("detects duplicates by matching email (case-insensitive)", () => {
const existing: ContactCard[] = [
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
describe('compareVersions', () => {
it('orders by major, minor, patch', () => {
expect(compareVersions('1.0.0', '1.0.0')).toBe(0);
expect(compareVersions('1.0.1', '1.0.0')).toBeGreaterThan(0);
expect(compareVersions('1.0.0', '1.0.1')).toBeLessThan(0);
expect(compareVersions('2.0.0', '1.9.9')).toBeGreaterThan(0);
expect(compareVersions('1.10.0', '1.9.0')).toBeGreaterThan(0);
});
it('treats missing segments as 0', () => {
expect(compareVersions('1', '1.0.0')).toBe(0);
expect(compareVersions('1.2', '1.2.0')).toBe(0);
});
it('tolerates a leading v', () => {
expect(compareVersions('v1.6.7', '1.6.7')).toBe(0);
});
it('ignores pre-release / build metadata', () => {
expect(compareVersions('1.6.7-rc.1', '1.6.7')).toBe(0);
expect(compareVersions('1.6.7+build.5', '1.6.7')).toBe(0);
});
});
describe('isVersionSatisfied', () => {
it('returns true when current >= required', () => {
expect(isVersionSatisfied('1.6.7', '1.6.7')).toBe(true);
expect(isVersionSatisfied('1.6.8', '1.6.7')).toBe(true);
expect(isVersionSatisfied('2.0.0', '1.9.9')).toBe(true);
});
it('returns false when current < required', () => {
expect(isVersionSatisfied('1.6.6', '1.6.7')).toBe(false);
expect(isVersionSatisfied('1.5.0', '1.6.0')).toBe(false);
expect(isVersionSatisfied('0.0.0', '1.0.0')).toBe(false);
});
it('treats empty / null / undefined required as no requirement', () => {
expect(isVersionSatisfied('1.0.0', '')).toBe(true);
expect(isVersionSatisfied('1.0.0', null)).toBe(true);
expect(isVersionSatisfied('1.0.0', undefined)).toBe(true);
});
});
+13 -3
View File
@@ -189,11 +189,21 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
/**
* Set the admin password without verifying a current one. Used by the setup
* wizard during initial bootstrap. Refuses to overwrite an existing password.
* wizard during initial bootstrap.
*
* Refuses to overwrite an existing password unless `allowOverwrite` is true.
* The wizard's finish route passes `allowOverwrite: true` so a half-completed
* setup (admin.json left behind by an ADMIN_PASSWORD env var or an aborted
* earlier wizard run, while setupComplete is still false) can be recovered
* by simply running the wizard again. Safe because the finish route is
* already gated by the one-time setup token.
*/
export async function setInitialAdminPassword(newPassword: string): Promise<boolean> {
export async function setInitialAdminPassword(
newPassword: string,
options: { allowOverwrite?: boolean } = {},
): Promise<boolean> {
const existing = await readConfigData();
if (existing) return false;
if (existing && !options.allowOverwrite) return false;
const hash = await hashPassword(newPassword);
cachedConfig = { passwordHash: hash };
cachedState = freshState();
+2
View File
@@ -146,6 +146,8 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', fileEnvVar: 'OAUTH_CLIENT_SECRET_FILE', type: 'string', defaultValue: '' },
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
oauthScopes: { envVar: 'OAUTH_SCOPES', type: 'string', defaultValue: '' },
oauthExtraScopes: { envVar: 'OAUTH_EXTRA_SCOPES', type: 'string', defaultValue: '' },
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
+189
View File
@@ -0,0 +1,189 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
export class ImpersonationJwtError extends Error {
status: number;
code: string;
constructor(code: string, message: string, status: number = 401) {
super(message);
this.name = 'ImpersonationJwtError';
this.code = code;
this.status = status;
}
}
export interface ImpersonationClaims {
iss: string;
iat: number;
exp: number;
nbf?: number;
jti: string;
mailbox: string;
tenant_id?: string;
actor_user_id?: string;
}
const MAX_TOKEN_LIFETIME_SEC = 300;
const CLOCK_SKEW_SEC = 60;
const MIN_SECRET_LENGTH = 32;
function base64UrlDecode(input: string): Buffer {
const pad = input.length % 4 === 0 ? 0 : 4 - (input.length % 4);
const b64 = input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(pad);
return Buffer.from(b64, 'base64');
}
function parseSegment(segment: string): unknown {
try {
return JSON.parse(base64UrlDecode(segment).toString('utf8'));
} catch {
throw new ImpersonationJwtError('malformed', 'Malformed JWT segment', 400);
}
}
function assertString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.length === 0) {
throw new ImpersonationJwtError('claims', `Missing or invalid '${field}' claim`);
}
return value;
}
function assertNumber(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new ImpersonationJwtError('claims', `Missing or invalid '${field}' claim`);
}
return value;
}
/**
* Verify an HS256 JWT for master-user impersonation. Returns the validated
* claims on success; throws ImpersonationJwtError otherwise.
*
* Caller must perform replay-protection (jti tracking) on the returned claims.
*/
export function verifyImpersonationJwt(
token: string,
secret: string,
options: { expectedIssuer?: string; now?: number } = {},
): ImpersonationClaims {
if (typeof token !== 'string' || token.length === 0) {
throw new ImpersonationJwtError('malformed', 'Missing token', 400);
}
if (typeof secret !== 'string' || secret.length < MIN_SECRET_LENGTH) {
throw new ImpersonationJwtError(
'config',
`BULWARK_JWT_AUTH_SECRET must be at least ${MIN_SECRET_LENGTH} characters`,
500,
);
}
const parts = token.split('.');
if (parts.length !== 3) {
throw new ImpersonationJwtError('malformed', 'Token must have 3 segments', 400);
}
const [headerB64, payloadB64, sigB64] = parts;
// Header — reject anything but HS256 BEFORE attempting signature verification.
const header = parseSegment(headerB64) as Record<string, unknown>;
if (header.alg !== 'HS256') {
throw new ImpersonationJwtError('alg', `Unsupported alg '${String(header.alg)}'`);
}
if (header.typ !== undefined && header.typ !== 'JWT') {
throw new ImpersonationJwtError('alg', `Unsupported typ '${String(header.typ)}'`);
}
// Signature — constant-time compare.
const expected = createHmac('sha256', secret).update(`${headerB64}.${payloadB64}`).digest();
const provided = base64UrlDecode(sigB64);
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
throw new ImpersonationJwtError('signature', 'Invalid signature');
}
// Claims.
const payload = parseSegment(payloadB64) as Record<string, unknown>;
const iss = assertString(payload.iss, 'iss');
if (options.expectedIssuer && iss !== options.expectedIssuer) {
throw new ImpersonationJwtError('iss', `Unexpected issuer '${iss}'`);
}
const iat = assertNumber(payload.iat, 'iat');
const exp = assertNumber(payload.exp, 'exp');
const jti = assertString(payload.jti, 'jti');
const mailbox = assertString(payload.mailbox, 'mailbox');
// Mailbox MUST NOT contain '%' or ':' — those would inject into the
// master-user auth header.
if (mailbox.includes('%') || mailbox.includes(':')) {
throw new ImpersonationJwtError('mailbox', "mailbox must not contain '%' or ':'");
}
const nowSec = options.now ?? Math.floor(Date.now() / 1000);
if (typeof payload.nbf === 'number' && nowSec + CLOCK_SKEW_SEC < payload.nbf) {
throw new ImpersonationJwtError('nbf', 'Token not yet valid');
}
if (nowSec - CLOCK_SKEW_SEC > exp) {
throw new ImpersonationJwtError('exp', 'Token expired');
}
if (iat - CLOCK_SKEW_SEC > nowSec) {
throw new ImpersonationJwtError('iat', 'Token issued in the future');
}
// Hard ceiling on lifetime — refuse long-lived handoff tokens even if the
// signer asked for one.
if (exp - iat > MAX_TOKEN_LIFETIME_SEC) {
throw new ImpersonationJwtError('lifetime', `Token lifetime exceeds ${MAX_TOKEN_LIFETIME_SEC}s ceiling`);
}
const claims: ImpersonationClaims = { iss, iat, exp, jti, mailbox };
if (typeof payload.nbf === 'number') claims.nbf = payload.nbf;
if (typeof payload.tenant_id === 'string') claims.tenant_id = payload.tenant_id;
if (typeof payload.actor_user_id === 'string') claims.actor_user_id = payload.actor_user_id;
return claims;
}
// ─── Replay protection ──────────────────────────────────────────
// In-memory LRU keyed by jti. Entries expire automatically once their
// underlying JWT could no longer be replayed (exp + skew). On a multi-pod
// deployment each pod has its own cache; that's acceptable because a token
// stolen mid-flight could only be replayed against the pod that already
// consumed it (and that pod will reject it). For stronger guarantees,
// platforms can issue per-pod-routed tokens or front Bulwark with a
// single-leader load balancer for the impersonate route.
const REPLAY_CACHE_MAX = 4096;
class ReplayCache {
private entries = new Map<string, number>(); // jti -> exp epoch seconds
/** Returns true if jti was not previously seen and has been recorded. */
consume(jti: string, exp: number, now: number = Math.floor(Date.now() / 1000)): boolean {
this.prune(now);
if (this.entries.has(jti)) return false;
if (this.entries.size >= REPLAY_CACHE_MAX) {
// Evict the oldest entry — Map preserves insertion order.
const first = this.entries.keys().next().value;
if (first !== undefined) this.entries.delete(first);
}
this.entries.set(jti, exp);
return true;
}
private prune(now: number): void {
for (const [jti, exp] of this.entries) {
if (exp + CLOCK_SKEW_SEC < now) {
this.entries.delete(jti);
} else {
// Insertion order means later entries are no older than this one — but
// exp isn't strictly monotonic with insertion, so we can't break here.
}
}
}
get size(): number {
return this.entries.size;
}
clear(): void {
this.entries.clear();
}
}
export const impersonationReplayCache = new ReplayCache();
+51
View File
@@ -0,0 +1,51 @@
import { configManager } from '@/lib/admin/config-manager';
export interface ImpersonationConfig {
jwtSecret: string;
masterUser: string;
masterPassword: string;
expectedIssuer: string;
}
/**
* Returns null when impersonation is not configured the route MUST surface
* that as a 404 so an unconfigured deployment doesn't expose the endpoint.
*
* Required env:
* BULWARK_JWT_AUTH_SECRET (>= 32 chars)
* BULWARK_STALWART_MASTER_USER master account address (e.g. master@example.com)
* BULWARK_STALWART_MASTER_PASSWORD
*
* Optional env:
* BULWARK_JWT_AUTH_ISSUER (default: "platform-api/webmail")
*/
export function readImpersonationConfig(): ImpersonationConfig | null {
const jwtSecret = process.env.BULWARK_JWT_AUTH_SECRET ?? '';
const masterUser = process.env.BULWARK_STALWART_MASTER_USER ?? '';
const masterPassword = process.env.BULWARK_STALWART_MASTER_PASSWORD ?? '';
if (!jwtSecret || !masterUser || !masterPassword) return null;
return {
jwtSecret,
masterUser,
masterPassword,
expectedIssuer: process.env.BULWARK_JWT_AUTH_ISSUER ?? 'platform-api/webmail',
};
}
/**
* Resolves the upstream JMAP server URL the same way /api/auth/session does
* for trusted entries: the global `jmapServerUrl` admin setting, then the
* legacy env fallbacks. Returns null if none is configured.
*
* The impersonation flow is server-to-server (no user input), so we never
* accept a custom endpoint only admin-configured URLs.
*/
export async function resolveImpersonationServerUrl(): Promise<string | null> {
await configManager.ensureLoaded();
const url =
configManager.get<string>('jmapServerUrl', '') ||
process.env.JMAP_SERVER_URL ||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
'';
return url || null;
}
+16 -2
View File
@@ -1,6 +1,20 @@
import { configManager } from '@/lib/admin/config-manager';
const DEFAULT_SCOPES = 'openid email profile';
const EXTRA_SCOPES = process.env.OAUTH_EXTRA_SCOPES || '';
export const OAUTH_SCOPES = process.env.OAUTH_SCOPES || (EXTRA_SCOPES ? `${DEFAULT_SCOPES} ${EXTRA_SCOPES}`.trim() : DEFAULT_SCOPES);
/**
* Resolve the OAuth scopes to request at authorize time.
*
* Reads admin override / OAUTH_SCOPES / OAUTH_EXTRA_SCOPES at call time so
* runtime env vars (and admin dashboard changes) take effect without a rebuild.
* Server-only: callers in the browser must read `oauthScopes` from /api/config.
*/
export function getOauthScopes(): string {
const explicit = configManager.get<string>('oauthScopes', '');
if (explicit) return explicit;
const extra = configManager.get<string>('oauthExtraScopes', '');
return extra ? `${DEFAULT_SCOPES} ${extra}`.trim() : DEFAULT_SCOPES;
}
export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
export const REFRESH_TOKEN_SERVER_COOKIE = 'jmap_rts';
+14
View File
@@ -185,6 +185,15 @@ export interface PluginAPI {
i18n: PluginI18n;
ui: {
registerToolbarAction: (action: ToolbarAction) => Disposable;
/**
* Register a banner that renders at the very top of the authenticated app
* shell above the navigation rail, sidebar and content panes. Used for
* persistent global notices (impersonation, maintenance, etc.). The
* component receives `{ username, serverUrl }` as props.
*
* Requires the `ui:app-top-banner` permission.
*/
registerAppTopBanner: (component: React.ComponentType<Record<string, unknown>>) => Disposable;
registerEmailBanner: (factory: BannerFactory) => Disposable;
registerEmailFooter: (component: React.ComponentType) => Disposable;
registerSettingsSection: (section: SettingsSection) => Disposable;
@@ -710,6 +719,11 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
return registerSlot(plugin.id, 'email-banner', factory.render as unknown as React.ComponentType<Record<string, unknown>>, 100);
},
registerAppTopBanner: (component: React.ComponentType<Record<string, unknown>>) => {
requirePermission(plugin, 'ui:app-top-banner');
return registerSlot(plugin.id, 'app-top-banner', component, 100);
},
registerEmailFooter: (component: React.ComponentType) => {
requirePermission(plugin, 'ui:email-footer');
return registerSlot(plugin.id, 'email-footer', component as React.ComponentType<Record<string, unknown>>, 100);
+2 -1
View File
@@ -230,6 +230,7 @@ export interface InstalledPlugin {
export type SlotName =
| 'toolbar-actions'
| 'app-top-banner'
| 'email-banner'
| 'email-footer'
| 'composer-toolbar'
@@ -778,7 +779,7 @@ export const ALL_PERMISSIONS = [
'security:read',
'auth:observe',
'http:post', 'http:fetch',
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer',
'ui:composer-toolbar', 'ui:composer-sidebar',
'ui:sidebar-widget', 'ui:settings-section',
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
+364 -37
View File
@@ -48,7 +48,96 @@ function grammaticalGenderToVcardSex(gender: string): string {
}
function unfoldLines(vcf: string): string {
return vcf.replace(/\r\n[ \t]/g, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// Normalize line endings first, then unfold continuation lines (RFC 6350 §3.2).
// Continuation lines start with a single SPACE or TAB; we must handle both
// CRLF (RFC-canonical) and LF-only files (common from Unix exporters).
return vcf
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.replace(/\n[ \t]/g, "");
}
// RFC 6868 parameter value encoding — used inside parameter values only.
// Caret-encoded sequences: ^n → LF, ^^ → ^, ^' → DQUOTE.
function decodeParamValue(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "^" && i + 1 < s.length) {
const next = s[i + 1];
if (next === "n") { out += "\n"; i++; continue; }
if (next === "^") { out += "^"; i++; continue; }
if (next === "'") { out += '"'; i++; continue; }
}
out += s[i];
}
return out;
}
// Split on delim, respecting DQUOTE-quoted spans (RFC 6350 §3.3 / §5).
function splitRespectingQuotes(s: string, delim: string): string[] {
const out: string[] = [];
let buf = "";
let inQuote = false;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === '"') {
inQuote = !inQuote;
buf += ch;
continue;
}
if (ch === delim && !inQuote) {
out.push(buf);
buf = "";
continue;
}
buf += ch;
}
out.push(buf);
return out;
}
// Find the first ":" outside of a DQUOTE-quoted parameter value.
// Returns -1 when none. Needed because property params may carry quoted
// values that contain colons (e.g. ADR;LABEL="Suite 100:..." or X- params).
function findValueColon(line: string): number {
let inQuote = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') { inQuote = !inQuote; continue; }
if (ch === ":" && !inQuote) return i;
}
return -1;
}
// vCard properties may carry a group prefix: "item1.EMAIL:foo@bar".
// Strip the prefix and return the bare property name + params component.
function stripGroupPrefix(keyPart: string): string {
const dot = keyPart.indexOf(".");
if (dot < 0) return keyPart;
const before = keyPart.substring(0, dot);
// Only treat as group if the segment before the dot has no ";" (which would
// indicate it's actually a param boundary) and matches the RFC 6350 group
// grammar (ALPHA / DIGIT / "-").
if (before.includes(";")) return keyPart;
if (!/^[A-Za-z0-9-]+$/.test(before)) return keyPart;
return keyPart.substring(dot + 1);
}
// Strip URI scheme prefix from a value (e.g. "tel:+1-555" → "+1-555").
function stripUriScheme(val: string, scheme: string): string {
const prefix = `${scheme}:`;
if (val.toLowerCase().startsWith(prefix)) return val.substring(prefix.length);
return val;
}
function parsePrefParam(params: Record<string, string>): number | undefined {
if (params.PREF) {
const n = parseInt(params.PREF, 10);
if (!Number.isNaN(n)) return n;
}
// vCard 3.0 style: TYPE=PREF (no numeric value)
if (params.TYPE && /\bPREF\b/i.test(params.TYPE)) return 1;
return undefined;
}
// vCard 2.1 quoted-printable soft line breaks: a line ending in `=` continues
@@ -117,11 +206,18 @@ function encodeValue(val: string): string {
function parseParams(paramStr: string): Record<string, string> {
const params: Record<string, string> = {};
if (!paramStr) return params;
const parts = paramStr.split(";");
const parts = splitRespectingQuotes(paramStr, ";");
for (const part of parts) {
if (!part) continue;
const eq = part.indexOf("=");
if (eq > 0) {
params[part.substring(0, eq).toUpperCase()] = part.substring(eq + 1).replace(/"/g, "");
const name = part.substring(0, eq).toUpperCase();
// Strip surrounding quotes then RFC 6868 caret-decode.
// Strip surrounding DQUOTE if present (RFC 6350 §3.3). Pre-decode there
// are no literal LFs in a parameter value (those arrive as "^n" via
// RFC 6868), so we don't need the dotAll flag.
const rawVal = part.substring(eq + 1).replace(/^"(.*)"$/, "$1");
params[name] = decodeParamValue(rawVal);
} else {
const upper = part.toUpperCase();
if (upper === "QUOTED-PRINTABLE" || upper === "BASE64") {
@@ -190,9 +286,9 @@ export function parseVCard(vcfString: string): ContactCard[] {
}
if (current) {
const colonIdx = trimmed.indexOf(":");
const colonIdx = findValueColon(trimmed);
if (colonIdx < 1) continue;
const keyPart = trimmed.substring(0, colonIdx);
const keyPart = stripGroupPrefix(trimmed.substring(0, colonIdx));
const value = trimmed.substring(colonIdx + 1);
if (!current[keyPart]) current[keyPart] = [];
current[keyPart].push(value);
@@ -205,12 +301,18 @@ export function parseVCard(vcfString: string): ContactCard[] {
function buildContact(raw: Record<string, string[]>): ContactCard | null {
const id = `import-${generateUUID()}`;
const card: ContactCard = { id, addressBookIds: {} };
// Deferred BIRTHPLACE/DEATHPLACE values — attach to anniversary at end,
// because the BDAY/DEATHDATE entry may appear in any order.
let birthPlace: string | undefined;
let deathPlace: string | undefined;
for (const [fullKey, values] of Object.entries(raw)) {
const semiIdx = fullKey.indexOf(";");
const propName = (semiIdx > 0 ? fullKey.substring(0, semiIdx) : fullKey).toUpperCase();
const paramStr = semiIdx > 0 ? fullKey.substring(semiIdx + 1) : "";
// splitRespectingQuotes so a quoted param value containing ";" survives.
const segments = splitRespectingQuotes(fullKey, ";");
const propName = (segments.shift() || "").toUpperCase();
const paramStr = segments.join(";");
const params = parseParams(paramStr);
const pref = parsePrefParam(params);
const isQuotedPrintable = params.ENCODING?.toUpperCase() === "QUOTED-PRINTABLE";
@@ -257,8 +359,10 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
if (!card.emails) card.emails = {};
const idx = Object.keys(card.emails).length;
card.emails[`e${idx}`] = {
address: val,
address: stripUriScheme(val, "mailto"),
contexts: typeToContext(params.TYPE),
label: params["X-ABLABEL"] || undefined,
pref,
};
break;
}
@@ -267,9 +371,13 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
if (!card.phones) card.phones = {};
const idx = Object.keys(card.phones).length;
card.phones[`p${idx}`] = {
number: val,
// vCard 4.0 TEL is a URI value (RFC 6350 §6.4.1); strip the
// "tel:" scheme for storage as a bare number.
number: stripUriScheme(val, "tel"),
contexts: typeToContext(params.TYPE),
features: typeToPhoneFeatures(params.TYPE),
label: params["X-ABLABEL"] || undefined,
pref,
};
break;
}
@@ -295,7 +403,15 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
region: adrParts[4] || undefined,
postcode: adrParts[5] || undefined,
country: adrParts[6] || undefined,
// vCard 4.0 (RFC 9554 §3.2): CC param carries ISO country code,
// and LABEL/GEO/TZ params attach directly to the ADR.
countryCode: params.CC || undefined,
fullAddress: params.LABEL || undefined,
coordinates: params.GEO ? stripUriScheme(params.GEO, "geo") : undefined,
timeZone: params.TZ || undefined,
contexts: typeToContext(params.TYPE),
label: params["X-ABLABEL"] || undefined,
pref,
};
break;
}
@@ -318,8 +434,10 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
break;
case "KIND": {
// RFC 6350 §6.1.4 plus RFC 6473 (application).
const k = val.toLowerCase();
if (k === "group" || k === "individual" || k === "org") {
if (k === "group" || k === "individual" || k === "org" ||
k === "location" || k === "device" || k === "application") {
card.kind = k;
}
break;
@@ -336,7 +454,8 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
if (!card.media) card.media = {};
const idx = Object.keys(card.media).length;
const encoding = params.ENCODING?.toUpperCase();
const mediaType = params.TYPE || params.MEDIATYPE || "";
// vCard 4.0 uses MEDIATYPE; 3.0 reuses TYPE for the image kind.
const mediaType = params.MEDIATYPE || (params.TYPE && params.TYPE.includes("/") ? params.TYPE : (params.TYPE && /^(JPEG|JPG|PNG|GIF|WEBP|HEIC|BMP|SVG)$/i.test(params.TYPE) ? params.TYPE : "")) || "";
if (encoding === "B" || encoding === "BASE64") {
// Inline base64 photo - construct a data URI
const mime = mediaType.includes("/") ? mediaType : mediaType ? `image/${mediaType.toLowerCase()}` : "image/jpeg";
@@ -346,7 +465,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
mediaType: mime,
};
} else if (val.startsWith("data:") || val.startsWith("http://") || val.startsWith("https://")) {
// URI value (data URI or URL)
// vCard 4.0 URI value (data URI or URL) — no ENCODING param.
card.media[`m${idx}`] = {
kind: "photo",
uri: val,
@@ -376,28 +495,36 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
card.onlineServices[`u${idx}`] = {
uri: val,
contexts: typeToContext(params.TYPE),
label: params.TYPE?.toLowerCase() === "home" || params.TYPE?.toLowerCase() === "work" ? undefined : params.TYPE,
label: params["X-ABLABEL"] ||
(params.TYPE?.toLowerCase() === "home" || params.TYPE?.toLowerCase() === "work" ? undefined : params.TYPE),
pref,
};
break;
}
case "IMPP":
case "X-SOCIALPROFILE": {
case "X-SOCIALPROFILE":
case "SOCIALPROFILE": {
// RFC 9554 §3.7 introduces SOCIALPROFILE; treat the same as IMPP/X-SOCIALPROFILE.
if (!card.onlineServices) card.onlineServices = {};
const idx = Object.keys(card.onlineServices).length;
const svc: ContactOnlineService = {
uri: val,
contexts: typeToContext(params.TYPE),
pref,
};
if (params["X-SERVICE-TYPE"]) {
svc.service = params["X-SERVICE-TYPE"];
} else if (propName === "X-SOCIALPROFILE" && params.TYPE) {
} else if (params.SERVICE) {
svc.service = params.SERVICE;
} else if ((propName === "X-SOCIALPROFILE" || propName === "SOCIALPROFILE") && params.TYPE) {
const typeVal = params.TYPE.toLowerCase();
if (typeVal !== "work" && typeVal !== "home") {
svc.service = params.TYPE;
}
}
if (params["X-USER"]) svc.user = params["X-USER"];
if (params["X-ABLABEL"]) svc.label = params["X-ABLABEL"];
card.onlineServices[`u${idx}`] = svc;
break;
}
@@ -408,6 +535,14 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
break;
}
case "BIRTHPLACE": {
// RFC 6474 §2.1. Stash the location and attach to the birth
// anniversary at the end of buildContact, since BDAY may appear
// either before or after BIRTHPLACE in the vCard.
birthPlace = val;
break;
}
case "ANNIVERSARY":
case "X-ANNIVERSARY": {
if (!card.anniversaries) card.anniversaries = {};
@@ -424,6 +559,12 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
break;
}
case "DEATHPLACE": {
// RFC 6474 §2.2.
deathPlace = val;
break;
}
case "CATEGORIES": {
if (!card.keywords) card.keywords = {};
const cats = val.split(",").map(c => c.trim()).filter(Boolean);
@@ -438,6 +579,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
const idx = Object.keys(card.cryptoKeys).length;
card.cryptoKeys[`k${idx}`] = {
uri: val,
mediaType: params.MEDIATYPE || (params.TYPE && params.TYPE.includes("/") ? params.TYPE : undefined),
contexts: typeToContext(params.TYPE),
};
break;
@@ -445,9 +587,15 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
case "RELATED": {
if (!card.relatedTo) card.relatedTo = {};
const relType = params.TYPE?.toLowerCase();
// RFC 6350 §6.6.6: TYPE may be a comma-separated list (or
// multi-valued via repeated params); convert to relation map.
const relation: Record<string, boolean> = {};
if (relType) relation[relType] = true;
if (params.TYPE) {
for (const t of params.TYPE.split(",")) {
const norm = t.trim().toLowerCase();
if (norm) relation[norm] = true;
}
}
card.relatedTo[val] = { relation: Object.keys(relation).length > 0 ? relation : undefined };
break;
}
@@ -458,6 +606,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
card.preferredLanguages[`l${idx}`] = {
language: val,
contexts: typeToContext(params.TYPE),
pref,
};
break;
}
@@ -494,16 +643,22 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
}
case "GENDER": {
// vCard 4.0 §6.2.7: sex-component[;identity-component]. We map the
// sex letter to JSContact's grammaticalGender and stuff the free-
// form identity into pronouns (a coarse approximation; RFC 9554's
// PRONOUNS / GRAMGENDER, handled below, are preferred when present).
const gParts = val.split(";");
const sexCode = gParts[0]?.toUpperCase();
const identityText = gParts[1];
if (sexCode || identityText) {
card.speakToAs = {};
if (!card.speakToAs) card.speakToAs = {};
if (sexCode) {
card.speakToAs.grammaticalGender = vcardSexToGrammaticalGender(sexCode);
}
if (identityText) {
card.speakToAs.pronouns = { p0: { pronouns: identityText } };
if (!card.speakToAs.pronouns) card.speakToAs.pronouns = {};
const pkey = `p${Object.keys(card.speakToAs.pronouns).length}`;
card.speakToAs.pronouns[pkey] = { pronouns: identityText };
}
}
break;
@@ -513,7 +668,9 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
if (!card.media) card.media = {};
const idx = Object.keys(card.media).length;
const encoding = params.ENCODING?.toUpperCase();
const mediaType = params.TYPE || params.MEDIATYPE || "";
// Prefer MEDIATYPE (vCard 4.0); fall back to TYPE only when it's a
// MIME type or a known image format token (vCard 3.0 idiom).
const mediaType = params.MEDIATYPE || (params.TYPE && (params.TYPE.includes("/") || /^(JPEG|JPG|PNG|GIF|WEBP|SVG)$/i.test(params.TYPE)) ? params.TYPE : "");
if (encoding === "B" || encoding === "BASE64") {
const mime = mediaType.includes("/") ? mediaType : mediaType ? `image/${mediaType.toLowerCase()}` : "image/png";
card.media[`m${idx}`] = {
@@ -535,7 +692,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
if (!card.media) card.media = {};
const idx = Object.keys(card.media).length;
const encoding = params.ENCODING?.toUpperCase();
const mediaType = params.TYPE || params.MEDIATYPE || "";
const mediaType = params.MEDIATYPE || (params.TYPE && (params.TYPE.includes("/") || /^(OGG|MP3|WAV|AAC|FLAC)$/i.test(params.TYPE)) ? params.TYPE : "");
if (encoding === "B" || encoding === "BASE64") {
const mime = mediaType.includes("/") ? mediaType : mediaType ? `audio/${mediaType.toLowerCase()}` : "audio/ogg";
card.media[`m${idx}`] = {
@@ -581,10 +738,110 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
case "SOURCE":
card.source = val;
break;
// ---- RFC 6715 (EXPERTISE / HOBBY / INTEREST / ORG-DIRECTORY) ----
case "EXPERTISE":
case "HOBBY":
case "INTEREST": {
if (!card.personalInfo) card.personalInfo = {};
const idx = Object.keys(card.personalInfo).length;
const kind = propName.toLowerCase() as "expertise" | "hobby" | "interest";
const rawLevel = params.LEVEL?.toLowerCase();
// RFC 6715 levels: expertise uses beginner/average/expert; hobby/
// interest use high/medium/low. Normalize all into JSContact's
// high/medium/low triplet.
const levelMap: Record<string, "high" | "medium" | "low"> = {
beginner: "low", average: "medium", expert: "high",
low: "low", medium: "medium", high: "high",
};
const level = rawLevel ? levelMap[rawLevel] : undefined;
card.personalInfo[`i${idx}`] = { kind, value: val, level };
break;
}
case "ORG-DIRECTORY": {
// RFC 6715 §2.4 — directory URI for the contact's organization.
if (!card.directories) card.directories = {};
const idx = Object.keys(card.directories).length;
card.directories[`d${idx}`] = {
uri: val,
kind: "directory",
mediaType: params.MEDIATYPE || undefined,
};
break;
}
// ---- RFC 8605 (CONTACT-URI) ----
case "CONTACT-URI": {
if (!card.links) card.links = {};
const idx = Object.keys(card.links).length;
card.links[`l${idx}`] = {
uri: val,
kind: "contact",
pref,
};
break;
}
// ---- RFC 9554 vCard 4.0 extensions ----
case "CREATED":
card.created = val;
break;
case "GRAMGENDER": {
// RFC 9554 §3.4 — grammatical gender (animate/common/feminine/masculine/neuter).
if (!card.speakToAs) card.speakToAs = {};
card.speakToAs.grammaticalGender = val.toLowerCase();
break;
}
case "PRONOUNS": {
// RFC 9554 §3.5 — free-form pronouns. May appear multiple times.
if (!card.speakToAs) card.speakToAs = {};
if (!card.speakToAs.pronouns) card.speakToAs.pronouns = {};
const pkey = `p${Object.keys(card.speakToAs.pronouns).length}`;
card.speakToAs.pronouns[pkey] = {
pronouns: val,
pref,
contexts: typeToContext(params.TYPE),
};
break;
}
// Silently swallow purely structural / sync metadata properties so
// they don't appear in any catch-all default.
case "VERSION":
case "XML":
case "CLIENTPIDMAP":
case "X-ABLABEL":
break;
}
}
}
// Attach BIRTHPLACE/DEATHPLACE to the matching anniversary, creating an
// anniversary entry if no BDAY/DEATHDATE was present.
if (birthPlace || deathPlace) {
if (!card.anniversaries) card.anniversaries = {};
if (birthPlace) {
let birth = Object.values(card.anniversaries).find(a => a.kind === "birth");
if (!birth) {
card.anniversaries.a0 = { kind: "birth", date: "" };
birth = card.anniversaries.a0;
}
birth.place = { fullAddress: birthPlace };
}
if (deathPlace) {
let death = Object.values(card.anniversaries).find(a => a.kind === "death");
if (!death) {
const key = `a${Object.keys(card.anniversaries).length}`;
card.anniversaries[key] = { kind: "death", date: "" };
death = card.anniversaries[key];
}
death.place = { fullAddress: deathPlace };
}
}
const hasName = card.name && (card.name.components?.length ?? 0) > 0 || !!card.name?.full;
const hasEmail = card.emails && Object.keys(card.emails).length > 0;
if (!hasName && !hasEmail && card.kind !== "group") return null;
@@ -640,8 +897,11 @@ function generateSingleVCard(contact: ContactCard): string {
if (contact.emails) {
for (const email of Object.values(contact.emails)) {
const type = contextToType(email.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
lines.push(`EMAIL${typeParam}:${email.address}`);
const params: string[] = [];
if (type) params.push(`TYPE=${type}`);
if (email.pref) params.push(`PREF=${email.pref}`);
const paramStr = params.length > 0 ? `;${params.join(";")}` : "";
lines.push(`EMAIL${paramStr}:${email.address}`);
}
}
@@ -655,8 +915,11 @@ function generateSingleVCard(contact: ContactCard): string {
if (phone.features[feat]) typeParts.push(feat.toUpperCase());
}
}
const typeParam = typeParts.length > 0 ? `;TYPE=${typeParts.join(",")}` : "";
lines.push(`TEL${typeParam}:${phone.number}`);
const params: string[] = [];
if (typeParts.length > 0) params.push(`TYPE=${typeParts.join(",")}`);
if (phone.pref) params.push(`PREF=${phone.pref}`);
const paramStr = params.length > 0 ? `;${params.join(";")}` : "";
lines.push(`TEL${paramStr}:${phone.number}`);
}
}
@@ -681,7 +944,11 @@ function generateSingleVCard(contact: ContactCard): string {
if (contact.addresses) {
for (const addr of Object.values(contact.addresses)) {
const type = contextToType(addr.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
const adrParams: string[] = [];
if (type) adrParams.push(`TYPE=${type}`);
if (addr.countryCode) adrParams.push(`CC=${addr.countryCode}`);
if (addr.pref) adrParams.push(`PREF=${addr.pref}`);
const paramStr = adrParams.length > 0 ? `;${adrParams.join(";")}` : "";
let street = addr.street || "";
let locality = addr.locality || "";
let region = addr.region || "";
@@ -707,7 +974,7 @@ function generateSingleVCard(contact: ContactCard): string {
postcode,
country,
];
lines.push(`ADR${typeParam}:${parts.map(encodeValue).join(";")}`);
lines.push(`ADR${paramStr}:${parts.map(encodeValue).join(";")}`);
}
}
@@ -715,11 +982,17 @@ function generateSingleVCard(contact: ContactCard): string {
for (const ann of Object.values(contact.anniversaries)) {
const dateStr = anniversaryDateToVcardString(ann.date);
if (ann.kind === "birth") {
lines.push(`BDAY:${dateStr}`);
if (dateStr) lines.push(`BDAY:${dateStr}`);
if (ann.place?.fullAddress) {
lines.push(`BIRTHPLACE:${encodeValue(ann.place.fullAddress)}`);
}
} else if (ann.kind === "wedding") {
lines.push(`ANNIVERSARY:${dateStr}`);
if (dateStr) lines.push(`ANNIVERSARY:${dateStr}`);
} else if (ann.kind === "death") {
lines.push(`DEATHDATE:${dateStr}`);
if (dateStr) lines.push(`DEATHDATE:${dateStr}`);
if (ann.place?.fullAddress) {
lines.push(`DEATHPLACE:${encodeValue(ann.place.fullAddress)}`);
}
}
}
}
@@ -732,13 +1005,17 @@ function generateSingleVCard(contact: ContactCard): string {
if (svc.service) params.push(`X-SERVICE-TYPE=${svc.service}`);
const ctxType = contextToType(svc.contexts);
if (ctxType) params.push(`TYPE=${ctxType}`);
if (svc.pref) params.push(`PREF=${svc.pref}`);
const paramStr = params.length > 0 ? `;${params.join(";")}` : "";
lines.push(`IMPP${paramStr}:${svc.uri}`);
} else {
// Output as URL for plain web links
const type = contextToType(svc.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
lines.push(`URL${typeParam}:${svc.uri}`);
const params: string[] = [];
if (type) params.push(`TYPE=${type}`);
if (svc.pref) params.push(`PREF=${svc.pref}`);
const paramStr = params.length > 0 ? `;${params.join(";")}` : "";
lines.push(`URL${paramStr}:${svc.uri}`);
}
}
}
@@ -753,8 +1030,11 @@ function generateSingleVCard(contact: ContactCard): string {
if (contact.preferredLanguages) {
for (const lang of Object.values(contact.preferredLanguages)) {
const type = contextToType(lang.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
lines.push(`LANG${typeParam}:${lang.language}`);
const params: string[] = [];
if (type) params.push(`TYPE=${type}`);
if (lang.pref) params.push(`PREF=${lang.pref}`);
const paramStr = params.length > 0 ? `;${params.join(";")}` : "";
lines.push(`LANG${paramStr}:${lang.language}`);
}
}
@@ -769,8 +1049,50 @@ function generateSingleVCard(contact: ContactCard): string {
if (contact.cryptoKeys) {
for (const key of Object.values(contact.cryptoKeys)) {
const type = contextToType(key.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
lines.push(`KEY${typeParam}:${key.uri}`);
const params: string[] = [];
if (type) params.push(`TYPE=${type}`);
if (key.mediaType) params.push(`MEDIATYPE=${key.mediaType}`);
const paramStr = params.length > 0 ? `;${params.join(";")}` : "";
lines.push(`KEY${paramStr}:${key.uri}`);
}
}
if (contact.personalInfo) {
// RFC 6715 — emit EXPERTISE / HOBBY / INTEREST with LEVEL.
const levelOut: Record<string, Record<string, string>> = {
expertise: { high: "expert", medium: "average", low: "beginner" },
hobby: { high: "high", medium: "medium", low: "low" },
interest: { high: "high", medium: "medium", low: "low" },
};
for (const info of Object.values(contact.personalInfo)) {
const propMap: Record<string, string> = {
expertise: "EXPERTISE", hobby: "HOBBY", interest: "INTEREST",
};
const prop = propMap[info.kind];
if (!prop) continue;
const levelParam = info.level && levelOut[info.kind]?.[info.level]
? `;LEVEL=${levelOut[info.kind][info.level]}` : "";
lines.push(`${prop}${levelParam}:${encodeValue(info.value)}`);
}
}
if (contact.directories) {
for (const dir of Object.values(contact.directories)) {
const mt = dir.mediaType ? `;MEDIATYPE=${dir.mediaType}` : "";
lines.push(`ORG-DIRECTORY${mt}:${dir.uri}`);
}
}
if (contact.links) {
// RFC 8605 CONTACT-URI for kind=contact; everything else falls back to URL.
for (const link of Object.values(contact.links)) {
const params: string[] = [];
const type = contextToType(link.contexts);
if (type) params.push(`TYPE=${type}`);
if (link.pref) params.push(`PREF=${link.pref}`);
const paramStr = params.length > 0 ? `;${params.join(";")}` : "";
const prop = link.kind === "contact" ? "CONTACT-URI" : "URL";
lines.push(`${prop}${paramStr}:${link.uri}`);
}
}
@@ -844,6 +1166,11 @@ function generateSingleVCard(contact: ContactCard): string {
lines.push(`SOURCE:${contact.source}`);
}
if (contact.created) {
// RFC 9554 §3.1 — CREATED is a timestamp; emit as-is for round-trip.
lines.push(`CREATED:${contact.created}`);
}
lines.push("END:VCARD");
return lines.join("\r\n");
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Lenient semver comparison for the marketplace's `minAppVersion` gate.
*
* Parses "major.minor.patch" (any segment may be missing treated as 0)
* and ignores pre-release / build metadata. Returns negative, zero or
* positive in the same shape as Array.prototype.sort comparators.
*
* We intentionally do NOT pull in a full semver dependency: plugins
* declare minimum app versions as simple "X.Y.Z" strings and we only
* need a >= check.
*/
export function compareVersions(a: string, b: string): number {
const pa = parseVersion(a);
const pb = parseVersion(b);
for (let i = 0; i < 3; i++) {
if (pa[i] !== pb[i]) return pa[i] - pb[i];
}
return 0;
}
function parseVersion(v: string): [number, number, number] {
const cleaned = String(v || '').trim().replace(/^v/i, '');
// Drop pre-release / build metadata.
const core = cleaned.split(/[-+]/)[0];
const parts = core.split('.').map((p) => {
const n = parseInt(p, 10);
return Number.isFinite(n) ? n : 0;
});
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
}
/**
* True when `current` satisfies `required` (i.e. current >= required).
* Empty / null / undefined `required` is treated as no requirement.
*/
export function isVersionSatisfied(current: string, required: string | null | undefined): boolean {
if (!required) return true;
return compareVersions(current, required) >= 0;
}
+55 -17
View File
@@ -6,8 +6,21 @@
import type { IJMAPClient } from '@/lib/jmap/client-interface';
const DEVICE_CLIENT_ID_KEY = 'bulwark.push.deviceClientId.v1';
const SUBSCRIPTION_ID_KEY = 'bulwark.push.subscriptionId.v1';
// Per-account keys: a single browser may be signed in to multiple accounts,
// each with its own JMAP PushSubscription and its own relay record. Scoping
// the deviceClientId per account is what makes per-account notifications work
// at all - the relay keys subscriptions on subscriptionId (= deviceClientId),
// so a globally-shared key meant re-registering account B overwrote A.
const DEVICE_CLIENT_ID_PREFIX = 'bulwark.push.deviceClientId.v1.';
const SUBSCRIPTION_ID_PREFIX = 'bulwark.push.subscriptionId.v1.';
function deviceClientIdKey(accountId: string): string {
return DEVICE_CLIENT_ID_PREFIX + accountId;
}
function subscriptionIdKey(accountId: string): string {
return SUBSCRIPTION_ID_PREFIX + accountId;
}
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '');
const SW_SCOPE = `${BASE_PATH}/`;
@@ -79,14 +92,24 @@ function randomDeviceClientId(): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
function getOrCreateDeviceClientId(): string {
const existing = localStorage.getItem(DEVICE_CLIENT_ID_KEY);
function getOrCreateDeviceClientId(accountId: string): string {
const key = deviceClientIdKey(accountId);
const existing = localStorage.getItem(key);
if (existing) return existing;
const next = randomDeviceClientId();
localStorage.setItem(DEVICE_CLIENT_ID_KEY, next);
localStorage.setItem(key, next);
return next;
}
function anyOtherAccountHasSubscription(accountId: string): boolean {
const skip = subscriptionIdKey(accountId);
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k !== skip && k.startsWith(SUBSCRIPTION_ID_PREFIX)) return true;
}
return false;
}
// PushManager.subscribe wants the VAPID public key as a BufferSource.
// Returning a Uint8Array<ArrayBuffer> (not the wider ArrayBufferLike that
// includes SharedArrayBuffer) keeps strict TS happy on lib.dom 2024+.
@@ -260,7 +283,8 @@ export async function enableWebPush(
});
}
const deviceClientId = getOrCreateDeviceClientId();
const accountId = params.client.getAccountId();
const deviceClientId = getOrCreateDeviceClientId(accountId);
await registerWithRelay({
relayBaseUrl,
@@ -278,7 +302,8 @@ export async function enableWebPush(
// Reuse the JMAP-side PushSubscription if the server still has it, just
// refreshing the expiry so it doesn't time out between sessions.
const existingSubs = await params.client.listPushSubscriptions().catch(() => []);
const storedServerId = localStorage.getItem(SUBSCRIPTION_ID_KEY);
const subIdKey = subscriptionIdKey(accountId);
const storedServerId = localStorage.getItem(subIdKey);
if (storedServerId) {
const match = existingSubs.find((s) => s.id === storedServerId);
if (match) {
@@ -286,7 +311,7 @@ export async function enableWebPush(
if (refreshed) return { subscriptionId: storedServerId };
await params.client.destroyPushSubscription(storedServerId).catch(() => undefined);
}
localStorage.removeItem(SUBSCRIPTION_ID_KEY);
localStorage.removeItem(subIdKey);
}
// Reap any leftover subscriptions still bound to this device. These pile
@@ -309,7 +334,7 @@ export async function enableWebPush(
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
await params.client.verifyPushSubscription(serverAssignedId, verificationCode);
localStorage.setItem(SUBSCRIPTION_ID_KEY, serverAssignedId);
localStorage.setItem(subIdKey, serverAssignedId);
return { subscriptionId: serverAssignedId };
}
@@ -320,37 +345,50 @@ export interface DisableWebPushParams {
}
// Best-effort teardown: clear the JMAP subscription, the relay mapping, and
// the browser PushSubscription. Any single failure is swallowed so the user
// always ends up in a "disabled" state locally.
// (only when no other accounts still need it) the browser-wide
// PushSubscription. Any single failure is swallowed so the user always ends
// up in a "disabled" state locally.
export async function disableWebPush(params: DisableWebPushParams): Promise<void> {
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
const accountId = params.client.getAccountId();
const storedServerId = localStorage.getItem(SUBSCRIPTION_ID_KEY);
const subIdKey = subscriptionIdKey(accountId);
const devIdKey = deviceClientIdKey(accountId);
const storedServerId = localStorage.getItem(subIdKey);
if (storedServerId) {
await params.client.destroyPushSubscription(storedServerId).catch(() => undefined);
localStorage.removeItem(SUBSCRIPTION_ID_KEY);
localStorage.removeItem(subIdKey);
}
const deviceClientId = localStorage.getItem(DEVICE_CLIENT_ID_KEY);
const deviceClientId = localStorage.getItem(devIdKey);
if (deviceClientId && relayBaseUrl) {
await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
{ method: 'DELETE' },
).catch(() => undefined);
}
// Keep the deviceClientId around so a later re-enable for this account
// reuses the same relay subscriptionId rather than scattering orphans.
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
// The browser-wide PushSubscription is shared by every account on this
// origin, so only tear it down if no other account is still using it.
if (
!anyOtherAccountHasSubscription(accountId)
&& typeof navigator !== 'undefined'
&& 'serviceWorker' in navigator
) {
const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
const sub = await registration?.pushManager.getSubscription();
if (sub) await sub.unsubscribe().catch(() => undefined);
}
}
export async function isWebPushEnabled(): Promise<boolean> {
export async function isWebPushEnabled(accountId: string): Promise<boolean> {
if (!isWebPushSupported()) return false;
if (Notification.permission !== 'granted') return false;
const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
if (!registration) return false;
const sub = await registration.pushManager.getSubscription();
return sub !== null && localStorage.getItem(SUBSCRIPTION_ID_KEY) !== null;
return sub !== null && localStorage.getItem(subscriptionIdKey(accountId)) !== null;
}
+2
View File
@@ -249,6 +249,8 @@
"compose": "Napsat",
"compose_hint": "Napsat novou zprávu",
"no_subject": "(Bez předmětu)",
"no_body_content": "(Žádný obsah těla)",
"no_preview_available": "Náhled není k dispozici",
"loading_email": "Načítání zprávy...",
"loading": "Načítání...",
"reply": "Odpovědět",
+2
View File
@@ -249,6 +249,8 @@
"compose": "Ny besked",
"compose_hint": "Skriv ny besked",
"no_subject": "(Intet emne)",
"no_body_content": "(Intet indhold tilgængeligt)",
"no_preview_available": "Forhåndsvisning ikke tilgængelig",
"loading_email": "Indlæser e-mail...",
"loading": "Indlæser...",
"reply": "Svar",
+2
View File
@@ -249,6 +249,8 @@
"compose": "Verfassen",
"compose_hint": "Neue Nachricht verfassen",
"no_subject": "(Kein Betreff)",
"no_body_content": "(Kein Inhalt verfügbar)",
"no_preview_available": "Keine Vorschau verfügbar",
"loading_email": "E-Mail wird geladen...",
"loading": "Lädt...",
"reply": "Antworten",
+2
View File
@@ -249,6 +249,8 @@
"compose": "Compose",
"compose_hint": "Compose new message",
"no_subject": "(No Subject)",
"no_body_content": "(No body content available)",
"no_preview_available": "No preview available",
"loading_email": "Loading email...",
"loading": "Loading...",
"reply": "Reply",
+2
View File
@@ -249,6 +249,8 @@
"compose": "Redactar",
"compose_hint": "Redactar nuevo mensaje",
"no_subject": "(Sin Asunto)",
"no_body_content": "(Sin contenido disponible)",
"no_preview_available": "Vista previa no disponible",
"loading_email": "Cargando correo...",
"loading": "Cargando...",
"reply": "Responder",
+2
View File
@@ -249,6 +249,8 @@
"compose": "Rédiger",
"compose_hint": "Rédiger un nouveau message",
"no_subject": "(Sans objet)",
"no_body_content": "(Aucun contenu disponible)",
"no_preview_available": "Aucun aperçu disponible",
"loading_email": "Chargement de l'email...",
"loading": "Chargement...",
"reply": "Répondre",
+2
View File
@@ -249,6 +249,8 @@
"compose": "Componi",
"compose_hint": "Componi nuovo messaggio",
"no_subject": "(Nessun oggetto)",
"no_body_content": "(Nessun contenuto disponibile)",
"no_preview_available": "Anteprima non disponibile",
"loading_email": "Caricamento messaggio...",
"loading": "Caricamento...",
"reply": "Rispondi",
+2
View File
@@ -249,6 +249,8 @@
"compose": "作成",
"compose_hint": "新しいメッセージを作成",
"no_subject": "(件名なし)",
"no_body_content": "(本文がありません)",
"no_preview_available": "プレビューは利用できません",
"loading_email": "メールを読み込み中...",
"loading": "読み込み中...",
"reply": "返信",
+2
View File
@@ -249,6 +249,8 @@
"compose": "메일 쓰기",
"compose_hint": "새 메시지 작성",
"no_subject": "(제목 없음)",
"no_body_content": "(본문 내용 없음)",
"no_preview_available": "미리 보기를 사용할 수 없습니다",
"loading_email": "메일을 불러오는 중...",
"loading": "불러오는 중...",
"reply": "답장",
+2
View File
@@ -249,6 +249,8 @@
"compose": "Rakstīt",
"compose_hint": "Rakstīt jaunu ziņojumu",
"no_subject": "(nav temata)",
"no_body_content": "(nav satura)",
"no_preview_available": "Priekšskatījums nav pieejams",
"loading_email": "Ielādē vēstuli...",
"loading": "Ielādē...",
"reply": "Atbildēt",
+2
View File
@@ -249,6 +249,8 @@
"compose": "Opstellen",
"compose_hint": "Nieuw bericht opstellen",
"no_subject": "(Geen onderwerp)",
"no_body_content": "(Geen inhoud beschikbaar)",
"no_preview_available": "Geen voorbeeld beschikbaar",
"loading_email": "E-mail laden...",
"loading": "Laden...",
"reply": "Beantwoorden",
+2
View File
@@ -249,6 +249,8 @@
"compose": "Napisz",
"compose_hint": "Napisz nową wiadomość",
"no_subject": "(Bez tematu)",
"no_body_content": "(Brak treści)",
"no_preview_available": "Podgląd niedostępny",
"loading_email": "Ładowanie wiadomości...",
"loading": "Ładowanie...",
"reply": "Odpowiedz",
+2
View File
@@ -249,6 +249,8 @@
"compose": "Redigir",
"compose_hint": "Redigir nova mensagem",
"no_subject": "(Sem Assunto)",
"no_body_content": "(Sem conteúdo disponível)",
"no_preview_available": "Pré-visualização não disponível",
"loading_email": "Carregando e-mail...",
"loading": "Carregando...",
"reply": "Responder",
+2
View File
@@ -249,6 +249,8 @@
"compose": "Написать",
"compose_hint": "Написать новое сообщение",
"no_subject": "(Без темы)",
"no_body_content": "(Содержимое отсутствует)",
"no_preview_available": "Предварительный просмотр недоступен",
"loading_email": "Загрузка письма...",
"loading": "Загрузка...",
"reply": "Ответить",
+2
View File
@@ -249,6 +249,8 @@
"compose": "Yeni E-posta",
"compose_hint": "Yeni e-posta oluştur",
"no_subject": "(Konu Yok)",
"no_body_content": "(İçerik yok)",
"no_preview_available": "Önizleme kullanılamıyor",
"loading_email": "E-posta yükleniyor...",
"loading": "Yükleniyor...",
"reply": "Yanıtla",
+2
View File
@@ -249,6 +249,8 @@
"compose": "Скласти",
"compose_hint": "Написати нове повідомлення",
"no_subject": "(без теми)",
"no_body_content": "(вміст відсутній)",
"no_preview_available": "Попередній перегляд недоступний",
"loading_email": "Завантаження електронної пошти...",
"loading": "Завантаження...",
"reply": "Відповісти",
+2
View File
@@ -249,6 +249,8 @@
"compose": "撰写邮件",
"compose_hint": "撰写新邮件",
"no_subject": "(无主题)",
"no_body_content": "(无正文内容)",
"no_preview_available": "无可用预览",
"loading_email": "正在加载邮件...",
"loading": "正在加载…",
"reply": "回复",
+6
View File
@@ -46,6 +46,12 @@ const nextConfig: NextConfig = {
// it from node_modules at runtime instead of trying to bundle it. Used by
// PLUGIN_DEV_DIR's on-the-fly bundler.
serverExternalPackages: ["esbuild"],
// Sibling repos checked out under ./repos/ are unrelated source trees that
// Turbopack's NFT can otherwise rope into the trace when dynamic fs calls
// confuse it. Keeps the build from ballooning memory tracing dead code.
outputFileTracingExcludes: {
"*": ["./repos/**/*"],
},
turbopack: {
root: import.meta.dirname,
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-webmail",
"version": "1.6.6",
"version": "1.6.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-webmail",
"version": "1.6.6",
"version": "1.6.7",
"license": "AGPL-3.0-only",
"dependencies": {
"@tanstack/react-virtual": "^3.13.24",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
"version": "1.6.6",
"version": "1.6.7",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",
+14 -1
View File
@@ -98,6 +98,16 @@ async function handlePush(event) {
? payload.accountLabel
: "";
// JMAP StateChange wraps changes in { changed: { [accountId]: {...} } }.
// The relay forwards a single account's StateChange per push, so the first
// key is the one this notification is for. Without this the preview API
// would just fall back to the first signed-in slot and surface mail from
// the wrong account.
const changed = payload && payload.changed && typeof payload.changed === "object"
? payload.changed
: null;
const accountId = changed ? Object.keys(changed)[0] || "" : "";
// Best effort: ask the webmail to look up the latest unread email so we can
// build a useful notification. If the request fails (offline, session
// expired, server down) we fall back to a generic "New mail" so the user
@@ -105,7 +115,10 @@ async function handlePush(event) {
let preview = null;
let previewOk = false;
try {
const res = await fetch(`${BASE_PATH}/api/push/preview`, {
const previewUrl = accountId
? `${BASE_PATH}/api/push/preview?accountId=${encodeURIComponent(accountId)}`
: `${BASE_PATH}/api/push/preview`;
const res = await fetch(previewUrl, {
credentials: "include",
cache: "no-store",
});
+41 -1
View File
@@ -1246,7 +1246,7 @@ export const useAuthStore = create<AuthState>()(
checkAuth: async () => {
const accountStore = useAccountStore.getState();
const accounts = accountStore.accounts;
let accounts = accountStore.accounts;
// If the only account is the demo account, re-initialize demo mode
// instead of trying to restore a server session (which doesn't exist).
@@ -1255,6 +1255,46 @@ export const useAuthStore = create<AuthState>()(
return;
}
// Orphan-cookie adoption — when no accounts are registered but a
// basic-auth session cookie is present (set by /api/auth/impersonate
// or by another server-side hand-off), promote it into the account
// registry so the normal restoration path picks it up. Without this
// the cookies sit unused and the SPA bounces to the login screen.
if (accounts.length === 0) {
try {
const restore = await apiFetch('/api/auth/session', { method: 'PUT' });
if (restore.ok) {
const data = await restore.json();
if (data?.serverUrl && data?.username && data?.password) {
// Stalwart master-user impersonation uses "target%master" as
// the auth username. The full string must be preserved for
// JMAP auth, but the user-facing display (avatar, switcher,
// sign-out copy) should only show the target mailbox.
const fullUsername: string = data.username;
const displayMailbox = fullUsername.includes('%')
? fullUsername.split('%', 1)[0]
: fullUsername;
accountStore.addAccount({
label: displayMailbox,
serverUrl: data.serverUrl,
username: fullUsername,
authMode: 'basic',
rememberMe: true,
displayName: displayMailbox,
email: displayMailbox,
lastLoginAt: Date.now(),
isConnected: false,
hasError: false,
isDefault: true,
});
accounts = useAccountStore.getState().accounts;
}
}
} catch (err) {
debug.error('Orphan session cookie adoption failed:', err);
}
}
// Multi-account restoration: restore all registered accounts
if (accounts.length > 0) {
// Null out client so the page doesn't fire data-loading effects
+1 -1
View File
@@ -20,7 +20,7 @@ import { apiFetch } from '@/lib/browser-navigation';
// ─── Slot State ──────────────────────────────────────────────
const SLOT_NAMES: SlotName[] = [
'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar', 'composer-sidebar', 'composer-sidebar-right',
'toolbar-actions', 'app-top-banner', 'email-banner', 'email-footer', 'composer-toolbar', 'composer-sidebar', 'composer-sidebar-right',
'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom',
'calendar-event-actions', 'admin-plugin-page',
];