Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c5aec9ca4 | ||
|
|
375220298d | ||
|
|
452976ed95 | ||
|
|
243a2adfbf | ||
|
|
1ba4a13353 | ||
|
|
5de12dfb79 | ||
|
|
689d646c57 | ||
|
|
49cd7f8130 | ||
|
|
4545e212f4 | ||
|
|
b1f4f6eae0 | ||
|
|
9a431a873b | ||
|
|
bb7e1c4538 | ||
|
|
356abcfc2d | ||
|
|
3099b4801e | ||
|
|
fc641e94ac | ||
|
|
0e758409ee | ||
|
|
8c93941d8d | ||
|
|
4221c9a50f | ||
|
|
3a559479bd | ||
|
|
482493a10d | ||
|
|
0e1036eb49 | ||
|
|
349406723c | ||
|
|
997bedc91b | ||
|
|
307e6d5d34 | ||
|
|
ca1108f455 | ||
|
|
0ff88f36ed | ||
|
|
285b4e349c | ||
|
|
2b4ebb1fbb | ||
|
|
a829c2818f | ||
|
|
c54cf73c3a | ||
|
|
c45ef86924 | ||
|
|
f39366b470 | ||
|
|
b725000f4d |
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
[](https://grafana.external.bulwarkmail.org/)
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(); }}
|
||||
|
||||
@@ -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,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" />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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: '/' },
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ function resetStore() {
|
||||
plugins: [],
|
||||
slots: {
|
||||
'toolbar-actions': [],
|
||||
'app-top-banner': [],
|
||||
'email-banner': [],
|
||||
'email-footer': [],
|
||||
'composer-toolbar': [],
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
@@ -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
@@ -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';
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -249,6 +249,8 @@
|
||||
"compose": "作成",
|
||||
"compose_hint": "新しいメッセージを作成",
|
||||
"no_subject": "(件名なし)",
|
||||
"no_body_content": "(本文がありません)",
|
||||
"no_preview_available": "プレビューは利用できません",
|
||||
"loading_email": "メールを読み込み中...",
|
||||
"loading": "読み込み中...",
|
||||
"reply": "返信",
|
||||
|
||||
@@ -249,6 +249,8 @@
|
||||
"compose": "메일 쓰기",
|
||||
"compose_hint": "새 메시지 작성",
|
||||
"no_subject": "(제목 없음)",
|
||||
"no_body_content": "(본문 내용 없음)",
|
||||
"no_preview_available": "미리 보기를 사용할 수 없습니다",
|
||||
"loading_email": "메일을 불러오는 중...",
|
||||
"loading": "불러오는 중...",
|
||||
"reply": "답장",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -249,6 +249,8 @@
|
||||
"compose": "Написать",
|
||||
"compose_hint": "Написать новое сообщение",
|
||||
"no_subject": "(Без темы)",
|
||||
"no_body_content": "(Содержимое отсутствует)",
|
||||
"no_preview_available": "Предварительный просмотр недоступен",
|
||||
"loading_email": "Загрузка письма...",
|
||||
"loading": "Загрузка...",
|
||||
"reply": "Ответить",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -249,6 +249,8 @@
|
||||
"compose": "Скласти",
|
||||
"compose_hint": "Написати нове повідомлення",
|
||||
"no_subject": "(без теми)",
|
||||
"no_body_content": "(вміст відсутній)",
|
||||
"no_preview_available": "Попередній перегляд недоступний",
|
||||
"loading_email": "Завантаження електронної пошти...",
|
||||
"loading": "Завантаження...",
|
||||
"reply": "Відповісти",
|
||||
|
||||
@@ -249,6 +249,8 @@
|
||||
"compose": "撰写邮件",
|
||||
"compose_hint": "撰写新邮件",
|
||||
"no_subject": "(无主题)",
|
||||
"no_body_content": "(无正文内容)",
|
||||
"no_preview_available": "无可用预览",
|
||||
"loading_email": "正在加载邮件...",
|
||||
"loading": "正在加载…",
|
||||
"reply": "回复",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user