From fc5f6f43d617a736002760191275ffd13200f592 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 21 May 2026 23:35:58 +0200 Subject: [PATCH] feat: expose PWA, app identity, and extension directory keys in JSON config #312 --- app/(main)/[locale]/auth/callback/page.tsx | 2 +- app/(main)/[locale]/files/page.tsx | 4 +-- app/(main)/[locale]/login/page.tsx | 4 +-- app/(main)/[locale]/pro/page.tsx | 10 ++++---- app/(main)/admin/_tabs/auth.tsx | 4 +-- app/api/admin/config/route.ts | 2 +- app/api/admin/marketplace/[slug]/route.ts | 15 +++++++---- app/api/admin/marketplace/route.ts | 14 ++++++++--- app/api/auth/impersonate/route.ts | 6 ++--- app/api/auth/sso/complete/route.ts | 2 +- app/api/auth/sso/start/route.ts | 2 +- app/api/plugin-signing-pubkey/route.ts | 2 +- app/api/pwa-icon/[size]/route.ts | 7 +++++- app/api/setup/finish/route.ts | 2 +- app/manifest.ts | 25 ++++++++++++------- components/contacts/contacts-sidebar.tsx | 2 +- .../email/calendar-invitation-banner.tsx | 4 +-- components/email/email-composer.tsx | 6 ++--- components/email/email-viewer.tsx | 4 +-- components/files/file-browser.tsx | 4 +-- components/layout/navigation-rail.tsx | 2 +- components/layout/sidebar.tsx | 8 +++--- components/plugins/plugin-iframe-slot.tsx | 4 +-- components/pro/pro-compose-tab-body.tsx | 2 +- components/pro/pro-email-tab-body.tsx | 4 +-- components/pro/pro-interface-redirect.tsx | 2 +- .../providers/embedded-bridge-provider.tsx | 2 +- hooks/use-is-embedded.ts | 2 +- hooks/use-media-query.ts | 4 +-- hooks/use-pane-size.ts | 2 +- hooks/use-pro-multi-account-calendars.ts | 2 +- hooks/use-pro-multi-account-contacts.ts | 2 +- hooks/use-pro-multi-account-identities.ts | 4 +-- lib/__tests__/impersonation-jwt.test.ts | 2 +- lib/admin/plugin-approvals.ts | 2 +- lib/admin/plugin-dev.ts | 2 +- lib/admin/plugin-signing.ts | 2 +- lib/admin/types.ts | 6 +++++ lib/impersonation/jwt.ts | 12 ++++----- lib/impersonation/master-config.ts | 4 +-- lib/jmap/types.ts | 8 +++--- lib/plugin-loader.ts | 2 +- lib/plugin-sandbox/bundle-signing.ts | 2 +- lib/plugin-sandbox/host-api.ts | 4 +-- lib/plugin-sandbox/host-bridge.ts | 6 ++--- lib/plugin-sandbox/runtime.tsx | 4 +-- lib/setup/session.ts | 2 +- lib/vcard.ts | 16 ++++++------ lib/version-compare.ts | 2 +- stores/auth-store.ts | 4 +-- stores/calendar-store.ts | 14 +++++------ stores/client-registry.ts | 2 +- stores/email-store.ts | 6 ++--- stores/file-store.ts | 2 +- stores/plugin-store.ts | 8 +++--- stores/pro-tab-store.ts | 8 +++--- stores/settings-store.ts | 6 ++--- stores/smime-store.ts | 2 +- 58 files changed, 159 insertions(+), 130 deletions(-) diff --git a/app/(main)/[locale]/auth/callback/page.tsx b/app/(main)/[locale]/auth/callback/page.tsx index efe701cb..a469c194 100644 --- a/app/(main)/[locale]/auth/callback/page.tsx +++ b/app/(main)/[locale]/auth/callback/page.tsx @@ -90,7 +90,7 @@ function OAuthCallbackInner() { if (mobileRedirectUri && mobileRedirectUri.startsWith("bulwarkmobile://")) { // Drive /api/auth/sso/complete directly so we can read the tokens - // out of the response — loginWithServerSso would consume them and + // out of the response - loginWithServerSso would consume them and // wire up the webmail auth store, which isn't useful here. The // server's mobile-flow branch (keyed on the pending cookie) skips // the refresh-token cookie write for the same reason. diff --git a/app/(main)/[locale]/files/page.tsx b/app/(main)/[locale]/files/page.tsx index 7b8d22fa..10aa3a52 100644 --- a/app/(main)/[locale]/files/page.tsx +++ b/app/(main)/[locale]/files/page.tsx @@ -136,7 +136,7 @@ export default function FilesPage() { // Initialize JMAP files client. In the Pro shell, all connected accounts // are surfaced as top-level folders at the root, so we *don't* auto-attach - // to the active account — the user picks one explicitly. + // to the active account - the user picks one explicitly. useEffect(() => { if (!isAuthenticated || !client || hasFetched.current) return; hasFetched.current = true; @@ -397,7 +397,7 @@ export default function FilesPage() { const currentFilesAccountId = useFileStore((s) => s.currentAccountId); // Pro shell only: all connected accounts are equal top-level entries at - // the root. The root path "/" itself is a cross-account picker — no + // the root. The root path "/" itself is a cross-account picker - no // account's files are shown until the user enters one. const accountFolders = isEmbedded ? accounts diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index a998af52..0ef6ccd7 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -351,7 +351,7 @@ export default function LoginPage() { const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; // In mobile-handoff mode the callback page needs to know it should // redirect into the app rather than into /mail. Stash the params in - // sessionStorage so the same-tab callback can read them — the SSO + // sessionStorage so the same-tab callback can read them - the SSO // pending cookie carries the authoritative copy server-side too. if (isMobileHandoff) { try { @@ -623,7 +623,7 @@ export default function LoginPage() { saveUsername(formData.username); if (isMobileHandoff) { // The isAuthenticated effect handles the redirect; nothing else to - // do here. Don't push to / — that would race the deep link. + // do here. Don't push to / - that would race the deep link. return; } router.push('/'); diff --git a/app/(main)/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx index c4c9b51e..c8b00f14 100644 --- a/app/(main)/[locale]/pro/page.tsx +++ b/app/(main)/[locale]/pro/page.tsx @@ -58,8 +58,8 @@ interface PaneProps { function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused }: PaneProps) { const paneRef = useRef(null); // Measured pane width, published to children via PaneSizeContext so that - // useDeviceDetection / useIsMobile / etc. branch on pane width — not full - // viewport — and inner pages collapse to their mobile/tablet layouts when + // useDeviceDetection / useIsMobile / etc. branch on pane width - not full + // viewport - and inner pages collapse to their mobile/tablet layouts when // the pane is narrow. const [paneWidth, setPaneWidth] = useState(null); @@ -268,7 +268,7 @@ export default function ProHome() { // Stable keys are essential: when the split collapses, the row's child // list goes from [splitPane, divider, mainPane] (or the leading variant) // to [mainPane]. Without keys, React would reuse the Pane instance at - // index 0 — repurposing the *split* pane's instance into the main pane, + // index 0 - repurposing the *split* pane's instance into the main pane, // which strands the main pane's ResizeObserver/paneWidth on a now- // unmounted DOM node and reparents the mail tab body (causing remount // + stale "still-narrow" measurements after the split is closed). @@ -316,7 +316,7 @@ export default function ProHome() {
- {/* Leftmost Navigation Rail — identical to the standard layout */} + {/* Leftmost Navigation Rail - identical to the standard layout */}
- {/* Panes container — accepts body drops for split/move. */} + {/* Panes container - accepts body drops for split/move. */}
- + diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts index 7bc89aaa..cf89f033 100644 --- a/app/api/admin/config/route.ts +++ b/app/api/admin/config/route.ts @@ -6,7 +6,7 @@ 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 +// 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']); diff --git a/app/api/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts index ddd003da..80f16422 100644 --- a/app/api/admin/marketplace/[slug]/route.ts +++ b/app/api/admin/marketplace/[slug]/route.ts @@ -7,8 +7,12 @@ import { } from '@/lib/admin/plugin-registry'; import JSZip from 'jszip'; import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types'; +import { configManager } from '@/lib/admin/config-manager'; -const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org'; +async function getDirectoryUrl(): Promise { + await configManager.ensureLoaded(); + return configManager.get('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org'; +} const MAX_PREVIEW_SOURCE_LEN = 100_000; @@ -27,9 +31,10 @@ export async function GET( if ('error' in result) return result.error; const { slug } = await params; + const directoryUrl = await getDirectoryUrl(); // 1. Extension metadata + screenshots + theme previews from the directory - const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, DIRECTORY_URL); + const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, directoryUrl); const detailRes = await fetch(detailUrl.toString(), { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10000), @@ -63,7 +68,7 @@ export async function GET( try { const bundleUrl = new URL( `/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`, - DIRECTORY_URL, + directoryUrl, ); const bundleRes = await fetch(bundleUrl.toString(), { signal: AbortSignal.timeout(30000), @@ -151,7 +156,7 @@ export async function GET( // 4. Build screenshot URLs (proxy through the directory's public files endpoint). const screenshots = Array.isArray(extension.screenshots) ? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({ - url: new URL(`/api/v1/files/${s.path}`, DIRECTORY_URL).toString(), + url: new URL(`/api/v1/files/${s.path}`, directoryUrl).toString(), altText: s.altText ?? null, })) : []; @@ -170,7 +175,7 @@ export async function GET( const fileUrl = (path: unknown): string | null => typeof path === 'string' && path - ? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString() + ? new URL(`/api/v1/files/${path}`, directoryUrl).toString() : null; return NextResponse.json( diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index b3720bad..907c37b7 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -19,8 +19,12 @@ import { import JSZip from 'jszip'; import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types'; import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader'; +import { configManager } from '@/lib/admin/config-manager'; -const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org'; +async function getDirectoryUrl(): Promise { + await configManager.ensureLoaded(); + return configManager.get('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org'; +} /** * GET /api/admin/marketplace - Search/browse the extension directory @@ -31,8 +35,9 @@ export async function GET(request: NextRequest) { const result = await requireAdminAuth(request); if ('error' in result) return result.error; + const directoryUrl = await getDirectoryUrl(); const { searchParams } = request.nextUrl; - const url = new URL('/api/v1/extensions', DIRECTORY_URL); + const url = new URL('/api/v1/extensions', directoryUrl); // Forward all search params for (const [key, value] of searchParams.entries()) { @@ -64,7 +69,7 @@ export async function GET(request: NextRequest) { const fileUrl = (path: unknown): string | null => typeof path === 'string' && path - ? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString() + ? new URL(`/api/v1/files/${path}`, directoryUrl).toString() : null; if (data.data) { @@ -108,7 +113,8 @@ export async function POST(request: NextRequest) { } // Download the bundle from the directory - const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, DIRECTORY_URL); + const directoryUrl = await getDirectoryUrl(); + const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, directoryUrl); const bundleRes = await fetch(bundleUrl.toString(), { signal: AbortSignal.timeout(30000), }); diff --git a/app/api/auth/impersonate/route.ts b/app/api/auth/impersonate/route.ts index 908704af..7a5dca04 100644 --- a/app/api/auth/impersonate/route.ts +++ b/app/api/auth/impersonate/route.ts @@ -23,7 +23,7 @@ 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 + * 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. @@ -48,7 +48,7 @@ function impersonationCookieOptions() { export async function GET(request: NextRequest) { const config = readImpersonationConfig(); if (!config) { - // Not configured — behave exactly like an unknown route. + // Not configured - behave exactly like an unknown route. return new NextResponse('Not found', { status: 404 }); } @@ -112,7 +112,7 @@ export async function GET(request: NextRequest) { authHeader, }); - // Structured audit log — operators rely on this for security review. + // Structured audit log - operators rely on this for security review. logger.info('Impersonation session granted', { event: 'impersonation_granted', jti: claims.jti, diff --git a/app/api/auth/sso/complete/route.ts b/app/api/auth/sso/complete/route.ts index 4ca79151..06763cb6 100644 --- a/app/api/auth/sso/complete/route.ts +++ b/app/api/auth/sso/complete/route.ts @@ -74,7 +74,7 @@ export async function POST(request: NextRequest) { const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId); // For the mobile handoff flow the tokens are handed back to the app - // verbatim — we deliberately don't write any cookies on the webmail + // verbatim - we deliberately don't write any cookies on the webmail // origin (the mobile browser tab disposes of the session after the // redirect anyway, but the cookie would still get committed to the // user's main webmail session if they happened to be logged in there). diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts index 59326c96..fd0e861f 100644 --- a/app/api/auth/sso/start/route.ts +++ b/app/api/auth/sso/start/route.ts @@ -77,7 +77,7 @@ export async function POST(request: NextRequest) { // /complete handler reaches the same OAuth endpoint we used to authorize. // Mobile params are captured here so /complete knows to return tokens to // the caller (in the JSON response) instead of writing the usual server - // cookies — and so the callback page can redirect back to the app. + // cookies - and so the callback page can redirect back to the app. const pendingData = { state, code_verifier: codeVerifier, diff --git a/app/api/plugin-signing-pubkey/route.ts b/app/api/plugin-signing-pubkey/route.ts index 8c49739e..7aba09d1 100644 --- a/app/api/plugin-signing-pubkey/route.ts +++ b/app/api/plugin-signing-pubkey/route.ts @@ -7,7 +7,7 @@ import { logger } from '@/lib/logger'; * * Returns the host's Ed25519 public key (base64-encoded raw 32 bytes) so the * sandboxed plugin loader can verify bundle signatures before evaluation. - * Public — every logged-in user needs to fetch it on app boot. + * Public - every logged-in user needs to fetch it on app boot. * * The response is long-cache-eligible (the key rotates only when an operator * deletes the on-disk PEM), but we keep it `no-store` for simplicity. The diff --git a/app/api/pwa-icon/[size]/route.ts b/app/api/pwa-icon/[size]/route.ts index 8b42dbb7..14977870 100644 --- a/app/api/pwa-icon/[size]/route.ts +++ b/app/api/pwa-icon/[size]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import sharp from 'sharp'; import path from 'node:path'; import { readFile } from 'node:fs/promises'; +import { configManager } from '@/lib/admin/config-manager'; const VALID_SIZES = new Set([192, 512]); @@ -32,7 +33,11 @@ export async function GET( return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 }); } - const iconUrl = process.env.PWA_ICON_URL || process.env.FAVICON_URL; + await configManager.ensureLoaded(); + const sources = configManager.getAllWithSources(); + const iconUrl = + (sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') || + (sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : ''); if (!iconUrl) { return new NextResponse('No PWA icon configured', { status: 404 }); } diff --git a/app/api/setup/finish/route.ts b/app/api/setup/finish/route.ts index 381cebbe..b4d8ea50 100644 --- a/app/api/setup/finish/route.ts +++ b/app/api/setup/finish/route.ts @@ -60,7 +60,7 @@ export async function POST(request: NextRequest) { try { // 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 + // 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 }); diff --git a/app/manifest.ts b/app/manifest.ts index 6d2d065c..16c86881 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -1,4 +1,5 @@ import type { MetadataRoute } from "next"; +import { configManager } from "@/lib/admin/config-manager"; export const dynamic = "force-dynamic"; @@ -21,22 +22,28 @@ type ExtendedManifest = MetadataRoute.Manifest & { const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, ""); const withBase = (p: string) => `${BASE_PATH}${p}`; -export default function manifest(): ExtendedManifest { +export default async function manifest(): Promise { + await configManager.ensureLoaded(); + const appName = - process.env.APP_NAME || + configManager.get("appName") || process.env.NEXT_PUBLIC_APP_NAME || "Bulwark Webmail"; - const shortName = process.env.APP_SHORT_NAME || appName; + const shortName = configManager.get("appShortName") || appName; const description = - process.env.APP_DESCRIPTION || + configManager.get("appDescription") || "A modern webmail client built for Stalwart Mail Server"; - const themeColor = process.env.PWA_THEME_COLOR || "#ffffff"; - const backgroundColor = process.env.PWA_BACKGROUND_COLOR || "#ffffff"; + const themeColor = configManager.get("pwaThemeColor") || "#ffffff"; + const backgroundColor = configManager.get("pwaBackgroundColor") || "#ffffff"; - // If PWA_ICON_URL or FAVICON_URL is configured, serve dynamically resized PNGs - // via /api/pwa-icon/[size]. Otherwise fall back to the default Bulwark PNGs. - const hasCustomIcon = !!(process.env.PWA_ICON_URL || process.env.FAVICON_URL); + // If pwaIconUrl or faviconUrl was explicitly configured (admin override or + // env var), serve dynamically resized PNGs via /api/pwa-icon/[size]. + // Otherwise fall back to the static Bulwark PNGs - sources marked "default" + // are the built-in placeholder paths and not real custom icons. + const sources = configManager.getAllWithSources(); + const hasCustomIcon = + sources.pwaIconUrl?.source !== "default" || sources.faviconUrl?.source !== "default"; const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon ? [ diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index 2403b33d..99d4ce26 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -541,7 +541,7 @@ export function ContactsSidebar({ )}
- {/* Shared accounts with address books — only when not already split + {/* Shared accounts with address books - only when not already split into per-account groups above (multi-account Pro mode). */} {!multiAccountMode && sharedBookGroups.map((group) => (
diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index b87a42ac..17cc4fde 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -389,7 +389,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp setActionError(null); try { // JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST - // is lost. Fetch raw ICS to extract METHOD as a reliable fallback — in + // 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), @@ -420,7 +420,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp setState('parsed'); - // Hydrate the calendar store with the matching event in the background — + // 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. diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index d13f3ddf..98b89989 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -982,7 +982,7 @@ export function EmailComposer({ try { const previousDraftId = draftIdRef.current; // Use the JMAP client and raw identity id for the *owning* account - // — falls back to active client for single-account / same-account + // - falls back to active client for single-account / same-account // identities. See `composerClient` derivation above. const savedDraftId = await composerClient.createDraft( toAddresses, @@ -1287,7 +1287,7 @@ export function EmailComposer({ // S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) { - // S/MIME keys are scoped to one JMAP account's identity — sending + // S/MIME keys are scoped to one JMAP account's identity - sending // from a cross-account identity via S/MIME would mix accounts' // certs/clients. Refuse upfront and tell the user to switch. const crossAccount = stripCrossAccountIdentityPrefix(currentIdentity.id); @@ -1440,7 +1440,7 @@ export function EmailComposer({ const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput); // Strip the cross-account namespace from the identity id before - // handing it to the parent — the JMAP server only knows the raw + // handing it to the parent - the JMAP server only knows the raw // id. The owning local account travels alongside so the parent // can route the send through the right client. const rawIdentityId = outgoing.identityId || currentIdentity?.id; diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 411468a6..5cb2d7bd 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1142,7 +1142,7 @@ export function EmailViewer({ const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => { if (isMobileDevice) { - // No room for a sidebar on mobile — send the user to the contacts page + // No room for a sidebar on mobile - send the user to the contacts page // with params describing what to show. The `from=email` flag turns the // page's mobile back button into a router.back() that returns here. const allRecipients = [ @@ -2896,7 +2896,7 @@ export function EmailViewer({ // window between selectedEmail changing and isLoading flipping true, so the // quick reply / body don't flicker through a partial render. // An empty bodyValues with no referenced parts means the email has no body - // (e.g. calendar-only invites) — not "still loading". + // (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)); diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index 926f95f5..8399a612 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -91,7 +91,7 @@ interface FileBrowserProps { /** Pro shell only: all connected accounts surfaced as top-level folders at the root. */ accountFolders?: AccountFolderEntry[]; onSelectAccount?: (accountId: string) => void; - /** Pro shell only: when true, the root is a pure account picker — hide the file toolbar and don't render a regular listing. */ + /** Pro shell only: when true, the root is a pure account picker - hide the file toolbar and don't render a regular listing. */ accountPickerMode?: boolean; /** Pro shell only: label of the currently-attached account, shown as a breadcrumb segment after Home. */ accountLabel?: string | null; @@ -471,7 +471,7 @@ export function FileBrowser({ }, [resources, searchQuery, sortKey, sortDir, folderLayout]); // Build breadcrumb segments. In Pro mode an account is mounted "between" - // Home and the account's filesystem — surfaced as a non-clickable label + // Home and the account's filesystem - surfaced as a non-clickable label // (clicking the actual account again would be a no-op; Home detaches it). const breadcrumbs: { name: string; path: string; isAccount?: boolean }[] = currentPath === '/' ? [{ name: t("breadcrumb_root"), path: '/' }] diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 1d1b05e2..8a8b58f9 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -47,7 +47,7 @@ interface NavigationRailProps { activeAppId?: string | null; /** * If provided, intercepts the rail's built-in route navigation. Return - * `true` to prevent the underlying `` from navigating — used by the + * `true` to prevent the underlying `` from navigating - used by the * Pro interface to open the route as a tab instead. The visual rail is * unchanged. */ diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index f397a9ae..a67ccab2 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -78,7 +78,7 @@ interface SidebarProps { /** * Multi-account (Pro) mode props. When `multiAccountMode` is true, the * sidebar renders a per-connected-account group instead of a single - * folders section — Thunderbird-style. `accountMailboxes` provides the + * folders section - Thunderbird-style. `accountMailboxes` provides the * mailbox list for non-active accounts (the active account still flows * through the `mailboxes` prop). `viewingAccountId` highlights which * account's folder is currently selected (null = active account). @@ -715,7 +715,7 @@ export function Sidebar({ } catch { return new Set(); } }); // Per-connected-account collapse state for Pro / Thunderbird-style mode. - // Stored as the set of accountIds the user has explicitly collapsed — + // Stored as the set of accountIds the user has explicitly collapsed - // anything not in the set is treated as expanded. Inverting the storage // model lets new accounts default to expanded automatically. const [collapsedAccountGroups, setCollapsedAccountGroups] = useState>(() => { @@ -738,7 +738,7 @@ export function Sidebar({ const connectedAccounts = accounts.filter(a => a.isConnected); // Pro shell treats the unified mailbox as a core part of the multi-account // UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. The - // 2+ account requirement still applies — with a single account the + // 2+ account requirement still applies - with a single account the // unified counts would just duplicate that account's inbox. const showUnified = (multiAccountMode || enableUnifiedMailbox) && connectedAccounts.length > 1; const { unifiedCounts } = useEmailStore(); @@ -930,7 +930,7 @@ export function Sidebar({ className )} > - {/* Header — hidden in the Pro shell, which owns its own chrome and + {/* Header - hidden in the Pro shell, which owns its own chrome and would otherwise render an empty strip (no collapse, no switcher). */} {!isEmbedded && (
diff --git a/components/plugins/plugin-iframe-slot.tsx b/components/plugins/plugin-iframe-slot.tsx index b95f3166..5bbdf2e8 100644 --- a/components/plugins/plugin-iframe-slot.tsx +++ b/components/plugins/plugin-iframe-slot.tsx @@ -1,6 +1,6 @@ 'use client'; -// Sandboxed slot mount. One iframe per (plugin, slot) — created lazily after +// Sandboxed slot mount. One iframe per (plugin, slot) - created lazily after // the background instance confirms `shouldShow(context)` (if defined). The // iframe renders the plugin's slot component using the plugin's bundle in a // null-origin context; its height is pushed back via postMessage and applied @@ -59,7 +59,7 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) { try { inst.destroy(); } catch { /* ignore */ } instanceRef.current = null; }; - // We intentionally don't depend on extraProps here — propagating prop + // We intentionally don't depend on extraProps here - propagating prop // changes happens via postMessage below to avoid iframe churn. // eslint-disable-next-line react-hooks/exhaustive-deps }, [show, pluginId, slot]); diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx index ceea8b4a..5f24f526 100644 --- a/components/pro/pro-compose-tab-body.tsx +++ b/components/pro/pro-compose-tab-body.tsx @@ -18,7 +18,7 @@ interface ProComposeTabBodyProps { /** * Renders a standalone `` inside its own Pro tab. Sending, * draft autosave, and discard all flow through the shared `email-store`, so - * the result is identical to composing inline in the mail page — the + * the result is identical to composing inline in the mail page - the * composer is just hosted in its own tab instead of in the right pane. */ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 55e43e10..0f5fc0d7 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -39,7 +39,7 @@ function buildReplyContext(email: Email): ProReplyContext { /** * Renders a single email in its own Pro tab. Fetches the email content on - * mount via `email-store.fetchEmailContent` so the tab is self-sufficient — + * mount via `email-store.fetchEmailContent` so the tab is self-sufficient - * it doesn't depend on what the Mail tab has selected. */ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { @@ -160,7 +160,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { if (!client || !email) return; try { await toggleStar(client, email.id); - // Reflect locally — the viewer re-reads from email-store's selectedEmail + // Reflect locally - the viewer re-reads from email-store's selectedEmail // shape only for the mail tab; here we update our local copy too. setEmail((prev) => prev ? { ...prev, diff --git a/components/pro/pro-interface-redirect.tsx b/components/pro/pro-interface-redirect.tsx index 098fe219..132856fe 100644 --- a/components/pro/pro-interface-redirect.tsx +++ b/components/pro/pro-interface-redirect.tsx @@ -16,7 +16,7 @@ const STANDARD_PATH_TO_TAB: Record { if (!embeddedMode || !isEmbedded()) return; - // Refuse to attach the listener without a pinned parent origin — + // Refuse to attach the listener without a pinned parent origin - // otherwise any cross-origin frame could forge sso:trigger-logout. if (!parentOrigin) { console.error( diff --git a/hooks/use-is-embedded.ts b/hooks/use-is-embedded.ts index 3ef8acbf..3c16d98b 100644 --- a/hooks/use-is-embedded.ts +++ b/hooks/use-is-embedded.ts @@ -8,7 +8,7 @@ import { createContext, useContext } from "react"; * read this to hide their own NavigationRail and let the shell own the * chrome. * - * Provided via context by the Pro shell — no URL coupling, no iframe. + * Provided via context by the Pro shell - no URL coupling, no iframe. */ export const EmbeddedContext = createContext(false); diff --git a/hooks/use-media-query.ts b/hooks/use-media-query.ts index bcec359a..16465b74 100644 --- a/hooks/use-media-query.ts +++ b/hooks/use-media-query.ts @@ -42,7 +42,7 @@ export function useMediaQuery(query: string): boolean { /** * When the Pro shell renders a page inside a (possibly split) pane, that pane * publishes its measured width via `PaneSizeContext`. Inner pages should - * branch their layout against the pane width — not the full viewport — so a + * branch their layout against the pane width - not the full viewport - so a * narrow pane gets the mobile/tablet layout instead of overflowing. * * Returns `null` when no pane size is published, signalling the caller to @@ -63,7 +63,7 @@ function classifyPane(paneWidth: number | null) { * * When invoked inside a Pro pane, the returned values reflect the pane's * width instead of the window's. The global UI store is NOT updated in that - * case — two split panes would otherwise fight to write conflicting values, + * case - two split panes would otherwise fight to write conflicting values, * and the store is meant to mirror the actual viewport for callers that read * it directly (mobile navigation helpers etc.). */ diff --git a/hooks/use-pane-size.ts b/hooks/use-pane-size.ts index 1b952073..e0fdd0f5 100644 --- a/hooks/use-pane-size.ts +++ b/hooks/use-pane-size.ts @@ -4,7 +4,7 @@ import { createContext, useContext } from "react"; /** * Width of the pane that's hosting the current subtree, in CSS pixels. - * `null` means "no pane is providing a size" — fall back to viewport-based + * `null` means "no pane is providing a size" - fall back to viewport-based * media queries. Set by the Pro shell on each split pane via ResizeObserver. */ export const PaneSizeContext = createContext(null); diff --git a/hooks/use-pro-multi-account-calendars.ts b/hooks/use-pro-multi-account-calendars.ts index 5db46ede..7bc225ad 100644 --- a/hooks/use-pro-multi-account-calendars.ts +++ b/hooks/use-pro-multi-account-calendars.ts @@ -9,7 +9,7 @@ import { useIsEmbedded } from "@/hooks/use-is-embedded"; /** * When the Pro shell is the active interface, aggregate calendars from - * every connected account so the calendar sidebar lists them all — the + * every connected account so the calendar sidebar lists them all - the * same way [[use-pro-multi-account-mailboxes]] does for mail folders. * * Returns the resolved list of `{ localAccountId, client }` pairs so the diff --git a/hooks/use-pro-multi-account-contacts.ts b/hooks/use-pro-multi-account-contacts.ts index 8328f4e9..a69b7c0d 100644 --- a/hooks/use-pro-multi-account-contacts.ts +++ b/hooks/use-pro-multi-account-contacts.ts @@ -8,7 +8,7 @@ import { useSettingsStore } from "@/stores/settings-store"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; /** - * Pro-shell counterpart to [[useProMultiAccountCalendars]] — aggregates + * Pro-shell counterpart to [[useProMultiAccountCalendars]] - aggregates * contacts and address books from every connected JMAP account so the * contacts sidebar lists them all, grouped by local account. */ diff --git a/hooks/use-pro-multi-account-identities.ts b/hooks/use-pro-multi-account-identities.ts index ddff1c2c..786b7796 100644 --- a/hooks/use-pro-multi-account-identities.ts +++ b/hooks/use-pro-multi-account-identities.ts @@ -37,7 +37,7 @@ export function stripCrossAccountIdentityPrefix(id: string): { localAccountId: s /** * Pro shell only: load identities from every connected account and group * them by local account so the composer's From dropdown can render an - * per account — mirrors [[useProMultiAccountCalendars]] and + * per account - mirrors [[useProMultiAccountCalendars]] and * [[useProMultiAccountContacts]]. * * Outside Pro / embedded mode the hook returns `enabled: false` and the @@ -82,7 +82,7 @@ export function useProMultiAccountIdentities(): { const list = await client.getIdentities(); if (!cancelled) next[account.id] = list; } catch { - // Skip accounts that fail to load identities — one bad + // Skip accounts that fail to load identities - one bad // account shouldn't blank the whole dropdown. } }), diff --git a/lib/__tests__/impersonation-jwt.test.ts b/lib/__tests__/impersonation-jwt.test.ts index 9bba40fd..7b2ef8be 100644 --- a/lib/__tests__/impersonation-jwt.test.ts +++ b/lib/__tests__/impersonation-jwt.test.ts @@ -120,7 +120,7 @@ describe('impersonationReplayCache', () => { 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. + // 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). diff --git a/lib/admin/plugin-approvals.ts b/lib/admin/plugin-approvals.ts index 98e736b3..bed83315 100644 --- a/lib/admin/plugin-approvals.ts +++ b/lib/admin/plugin-approvals.ts @@ -7,7 +7,7 @@ // run. // // Each entry has one of three states: 'pending' (user installed, waiting for -// admin), 'approved' (admin signed off), 'denied' (admin refused — kept so we +// admin), 'approved' (admin signed off), 'denied' (admin refused - kept so we // don't keep asking). import { readFile, writeFile, rename } from 'node:fs/promises'; diff --git a/lib/admin/plugin-dev.ts b/lib/admin/plugin-dev.ts index 079182ef..84863a5c 100644 --- a/lib/admin/plugin-dev.ts +++ b/lib/admin/plugin-dev.ts @@ -155,7 +155,7 @@ async function loadDevPlugin(pluginDir: string): Promise // Hash from the exact bytes the bundle endpoint will serve so the client's // verifyBundle check passes. For src/ sources that means running esbuild - // here too — slightly more work per manifest list, but unavoidable since + // here too - slightly more work per manifest list, but unavoidable since // the source hash wouldn't match the served bundle. let bundleHash: string; try { diff --git a/lib/admin/plugin-signing.ts b/lib/admin/plugin-signing.ts index 9f5f2ff7..cf0ff247 100644 --- a/lib/admin/plugin-signing.ts +++ b/lib/admin/plugin-signing.ts @@ -8,7 +8,7 @@ // The keypair lives at `data/admin/plugin-signing.key` (PEM-encoded // PKCS#8 private, mode 0600) and is generated lazily on first use. Operators // who want to pin the key out-of-band can drop a pre-generated PEM at that -// path before first boot — the loader just reads what's there. +// path before first boot - the loader just reads what's there. import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as nodeSign, KeyObject } from 'node:crypto'; import { readFile, writeFile, chmod } from 'node:fs/promises'; diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 98f994a8..320c783b 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -128,11 +128,16 @@ export interface AuditEntry { /** Config keys that map to environment variables */ export const CONFIG_ENV_MAP: Record = { appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' }, + appShortName: { envVar: 'APP_SHORT_NAME', type: 'string', defaultValue: '' }, + appDescription: { envVar: 'APP_DESCRIPTION', type: 'string', defaultValue: '' }, jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' }, stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true }, demoMode: { envVar: 'DEMO_MODE', type: 'boolean', defaultValue: false }, devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false }, faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' }, + pwaIconUrl: { envVar: 'PWA_ICON_URL', type: 'url', defaultValue: '' }, + pwaThemeColor: { envVar: 'PWA_THEME_COLOR', type: 'string', defaultValue: '#ffffff' }, + pwaBackgroundColor: { envVar: 'PWA_BACKGROUND_COLOR', type: 'string', defaultValue: '#ffffff' }, appLogoLightUrl: { envVar: 'APP_LOGO_LIGHT_URL', type: 'url', defaultValue: '' }, appLogoDarkUrl: { envVar: 'APP_LOGO_DARK_URL', type: 'url', defaultValue: '' }, loginLogoLightUrl: { envVar: 'LOGIN_LOGO_LIGHT_URL', type: 'url', defaultValue: '/branding/Bulwark_Logo_Color.svg' }, @@ -159,6 +164,7 @@ export const CONFIG_ENV_MAP: Record; if (header.alg !== 'HS256') { throw new ImpersonationJwtError('alg', `Unsupported alg '${String(header.alg)}'`); @@ -91,7 +91,7 @@ export function verifyImpersonationJwt( throw new ImpersonationJwtError('alg', `Unsupported typ '${String(header.typ)}'`); } - // Signature — constant-time compare. + // 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)) { @@ -109,7 +109,7 @@ export function verifyImpersonationJwt( const jti = assertString(payload.jti, 'jti'); const mailbox = assertString(payload.mailbox, 'mailbox'); - // Mailbox MUST NOT contain '%' or ':' — those would inject into the + // 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 ':'"); @@ -126,7 +126,7 @@ export function verifyImpersonationJwt( 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 + // 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`); @@ -158,7 +158,7 @@ class ReplayCache { 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. + // Evict the oldest entry - Map preserves insertion order. const first = this.entries.keys().next().value; if (first !== undefined) this.entries.delete(first); } @@ -171,7 +171,7 @@ class ReplayCache { if (exp + CLOCK_SKEW_SEC < now) { this.entries.delete(jti); } else { - // Insertion order means later entries are no older than this one — but + // 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. } } diff --git a/lib/impersonation/master-config.ts b/lib/impersonation/master-config.ts index 19928a35..392d3d15 100644 --- a/lib/impersonation/master-config.ts +++ b/lib/impersonation/master-config.ts @@ -8,7 +8,7 @@ export interface ImpersonationConfig { } /** - * Returns null when impersonation is not configured — the route MUST surface + * 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: @@ -38,7 +38,7 @@ export function readImpersonationConfig(): ImpersonationConfig | null { * 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. + * accept a custom endpoint - only admin-configured URLs. */ export async function resolveImpersonationServerUrl(): Promise { await configManager.ensureLoaded(); diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 17141ef8..517f678d 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -159,7 +159,7 @@ export interface Identity { textSignature?: string; htmlSignature?: string; mayDelete: boolean; - // See `Calendar.localAccountId` — set when the Pro shell aggregates + // See `Calendar.localAccountId` - set when the Pro shell aggregates // identities from multiple connected accounts so we can route sends // back through the owning JMAP client. `accountName` is the // user-facing label for the dropdown's optgroup. @@ -178,7 +178,7 @@ export interface ContactCard { accountId?: string; accountName?: string; isShared?: boolean; - // Local account-store ID — set when the Pro shell aggregates contacts + // Local account-store ID - set when the Pro shell aggregates contacts // from multiple connected accounts. See `Calendar.localAccountId`. localAccountId?: string; language?: string; @@ -381,7 +381,7 @@ export interface AddressBook { accountId?: string; accountName?: string; isShared?: boolean; - // See `Calendar.localAccountId` — same purpose for address books. + // See `Calendar.localAccountId` - same purpose for address books. localAccountId?: string; } @@ -483,7 +483,7 @@ export interface CalendarEvent { accountId?: string; accountName?: string; isShared?: boolean; - // See `Calendar.localAccountId` — same purpose for events. + // See `Calendar.localAccountId` - same purpose for events. localAccountId?: string; isDraft: boolean; isOrigin: boolean; diff --git a/lib/plugin-loader.ts b/lib/plugin-loader.ts index 468af2fa..b388a41f 100644 --- a/lib/plugin-loader.ts +++ b/lib/plugin-loader.ts @@ -19,7 +19,7 @@ import { all as allActive, get as getActive } from './plugin-sandbox/registry'; * Previously: re-published React/ReactDOM on `globalThis.__PLUGIN_EXTERNALS__` * so blob-imported plugin code could resolve `react`. With the sandbox model * plugins receive React injected as a function argument inside their iframe - * runtime — there is nothing to expose on the host window. + * runtime - there is nothing to expose on the host window. * * Kept as a no-op for callers that still invoke it during app bootstrap. */ diff --git a/lib/plugin-sandbox/bundle-signing.ts b/lib/plugin-sandbox/bundle-signing.ts index 1298789c..451edc42 100644 --- a/lib/plugin-sandbox/bundle-signing.ts +++ b/lib/plugin-sandbox/bundle-signing.ts @@ -6,7 +6,7 @@ // a bundle the loader verifies the signature; mismatch refuses the load. // // User-installed plugins (uploaded via the file picker, no server hop) have -// no signature — verification is skipped for those, since the user is +// no signature - verification is skipped for those, since the user is // installing their own code. Verification kicks in for server-managed // bundles only (the `managed: true` flag on `InstalledPlugin`). diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 29626bb1..856650d0 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -28,7 +28,7 @@ const PERM_PER_METHOD: Record = { 'admin.getAllConfig': 'admin:config', 'admin.setConfig': 'admin:config', 'admin.deleteConfig': 'admin:config', - // ui — any plugin can ask the host to render a modal or open a URL. + // ui - any plugin can ask the host to render a modal or open a URL. 'ui.confirm': null, 'ui.alert': null, 'ui.openExternalUrl': null, @@ -289,7 +289,7 @@ export async function dispatchApiCall( } case 'ui.openExternalUrl': { const url = String(args[0] ?? ''); - // Only http(s) — the sandbox should not be able to navigate the host + // Only http(s) - the sandbox should not be able to navigate the host // anywhere internal, nor open javascript:/data:/file: schemes. let parsed: URL; try { parsed = new URL(url); } catch { throw new Error('ui.openExternalUrl: invalid URL'); } diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index dd41be52..90b0c6df 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -39,7 +39,7 @@ function encodeCallbacks( if (Array.isArray(value)) { return value.map((v) => encodeCallbacks(v, table, depth + 1)); } - // Plain object — copy own enumerable keys. + // Plain object - copy own enumerable keys. const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { out[k] = encodeCallbacks(v, table, depth + 1); @@ -161,7 +161,7 @@ export class SandboxInstance { private send(msg: HostToSandbox): void { // targetOrigin '*' is required because the iframe is opaque-origin. The - // payload contains no host secrets — bundle code and manifest fields the + // payload contains no host secrets - bundle code and manifest fields the // plugin already owns. this.iframe.contentWindow?.postMessage(msg, '*'); } @@ -236,7 +236,7 @@ export class SandboxInstance { } case 'slot-resize': - // The iframe has no intrinsic height — sync it to the content height + // The iframe has no intrinsic height - sync it to the content height // the sandbox reported, otherwise the wrapper reserves space but the // iframe stays at 0px and the slot appears blank. this.iframe.style.height = `${msg.height}px`; diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 8adf78bc..e171875e 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -181,7 +181,7 @@ function buildPluginApi(manifest: PluginManifest) { /** * Resolve a bundler-emitted `require(name)` call inside the sandbox. Plugin * bundlers should be configured to externalise React; the runtime provides - * those modules here. Anything else is refused — the sandbox has no Node- + * those modules here. Anything else is refused - the sandbox has no Node- * compatible module resolution and we don't want plugins probing globals. * * The host injects the per-plugin API as `@plugin-host`, so plugin code can @@ -337,7 +337,7 @@ function bootSlot(payload: SlotInit): void { sendToHost({ type: 'init-done', hooks: [], slots: [], shortcuts: [] }); } -// Populated by bootSlot — receives `props-update` messages. +// Populated by bootSlot - receives `props-update` messages. let slotPropsUpdater: ((next: Record) => void) | null = null; async function handleInit(payload: InitPayload): Promise { diff --git a/lib/setup/session.ts b/lib/setup/session.ts index a3ee7368..46938d58 100644 --- a/lib/setup/session.ts +++ b/lib/setup/session.ts @@ -25,7 +25,7 @@ export async function authenticateWizardRequest(): Promise { export function buildSessionCookieAttributes(request?: NextRequest) { // Match Secure to the actual request protocol. Browsers drop Secure cookies // on plain HTTP, so unconditionally setting Secure in production breaks - // setup over HTTP — the operator gets "Wizard session required" on every + // setup over HTTP - the operator gets "Wizard session required" on every // step. The wizard surfaces a cleartext-credentials warning in the UI when // HTTPS isn't in use. return { diff --git a/lib/vcard.ts b/lib/vcard.ts index efaea176..efd592d4 100644 --- a/lib/vcard.ts +++ b/lib/vcard.ts @@ -57,7 +57,7 @@ function unfoldLines(vcf: string): string { .replace(/\n[ \t]/g, ""); } -// RFC 6868 parameter value encoding — used inside parameter values only. +// RFC 6868 parameter value encoding - used inside parameter values only. // Caret-encoded sequences: ^n → LF, ^^ → ^, ^' → DQUOTE. function decodeParamValue(s: string): string { let out = ""; @@ -301,7 +301,7 @@ export function parseVCard(vcfString: string): ContactCard[] { function buildContact(raw: Record): ContactCard | null { const id = `import-${generateUUID()}`; const card: ContactCard = { id, addressBookIds: {} }; - // Deferred BIRTHPLACE/DEATHPLACE values — attach to anniversary at end, + // 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; @@ -465,7 +465,7 @@ function buildContact(raw: Record): ContactCard | null { mediaType: mime, }; } else if (val.startsWith("data:") || val.startsWith("http://") || val.startsWith("https://")) { - // vCard 4.0 URI value (data URI or URL) — no ENCODING param. + // vCard 4.0 URI value (data URI or URL) - no ENCODING param. card.media[`m${idx}`] = { kind: "photo", uri: val, @@ -760,7 +760,7 @@ function buildContact(raw: Record): ContactCard | null { } case "ORG-DIRECTORY": { - // RFC 6715 §2.4 — directory URI for the contact's organization. + // 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}`] = { @@ -789,14 +789,14 @@ function buildContact(raw: Record): ContactCard | null { break; case "GRAMGENDER": { - // RFC 9554 §3.4 — grammatical gender (animate/common/feminine/masculine/neuter). + // 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. + // 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}`; @@ -1058,7 +1058,7 @@ function generateSingleVCard(contact: ContactCard): string { } if (contact.personalInfo) { - // RFC 6715 — emit EXPERTISE / HOBBY / INTEREST with LEVEL. + // RFC 6715 - emit EXPERTISE / HOBBY / INTEREST with LEVEL. const levelOut: Record> = { expertise: { high: "expert", medium: "average", low: "beginner" }, hobby: { high: "high", medium: "medium", low: "low" }, @@ -1167,7 +1167,7 @@ function generateSingleVCard(contact: ContactCard): string { } if (contact.created) { - // RFC 9554 §3.1 — CREATED is a timestamp; emit as-is for round-trip. + // RFC 9554 §3.1 - CREATED is a timestamp; emit as-is for round-trip. lines.push(`CREATED:${contact.created}`); } diff --git a/lib/version-compare.ts b/lib/version-compare.ts index 560fd99b..c8e3c133 100644 --- a/lib/version-compare.ts +++ b/lib/version-compare.ts @@ -1,7 +1,7 @@ /** * Lenient semver comparison for the marketplace's `minAppVersion` gate. * - * Parses "major.minor.patch" (any segment may be missing — treated as 0) + * 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. * diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 9e5647d5..5b7fcd9c 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1256,7 +1256,7 @@ export const useAuthStore = create()( return; } - // Orphan-cookie adoption — when no accounts are registered but a + // 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 @@ -1664,5 +1664,5 @@ export const useAuthStore = create()( ); // Expose getClientForAccount to the calendar/contact stores via a small -// shared registry — see [[stores/client-registry]] for rationale. +// shared registry - see [[stores/client-registry]] for rationale. setClientLookup((accountId) => useAuthStore.getState().getClientForAccount(accountId)); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 20db15e3..1fb94a40 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -15,7 +15,7 @@ import { getClientByLocalAccountId } from './client-registry'; /** * When the Pro shell aggregates calendars/events from every connected * account, the entity carries a `localAccountId` pointing back to the - * owning JMAP client. Mutations need to use *that* client — the active + * owning JMAP client. Mutations need to use *that* client - the active * client (passed in by the page) could be on a different server entirely. * Falls back to the active client when `localAccountId` is unset or no * matching client is registered. @@ -75,7 +75,7 @@ function prefixCalendarsWithLocalAccount( return calendars.map((cal) => ({ ...cal, localAccountId })); } const prefix = buildCrossAccountIdPrefix(localAccountId); - // Preserve each calendar's original `isShared` flag — it distinguishes + // Preserve each calendar's original `isShared` flag - it distinguishes // the user's own calendars on the other account from calendars shared // *into* that account by yet another user. The sidebar uses this split // to render "My Calendars" vs "Shared" sub-sections per account. @@ -186,7 +186,7 @@ export interface ICalSubscription { url: string; calendarId: string; // The JMAP account this subscription belongs to. Optional for back- - // compat with subs persisted before multi-account scoping landed — + // compat with subs persisted before multi-account scoping landed - // legacy entries with no accountId are shown only in whichever account // the user has active (treated as floating). New subs always set it. accountId?: string; @@ -910,7 +910,7 @@ export const useCalendarStore = create()( if (calendarEvents.length === 0) break; // Separate events that live ONLY in this calendar (delete) from - // events also linked to other calendars (unlink only — don't + // events also linked to other calendars (unlink only - don't // cascade-delete the user's copy elsewhere). const idsToDelete: string[] = []; const eventsToUnlink: Array<{ id: string; calendarIds: Record }> = []; @@ -1008,7 +1008,7 @@ export const useCalendarStore = create()( icalSubscriptions: [...state.icalSubscriptions, subscription], })); - // Initial fetch — roll back the calendar create if it fails so we + // Initial fetch - roll back the calendar create if it fails so we // don't leave a phantom calendar around after a bad URL / 404 / etc. await get().refreshICalSubscription(client, subscription.id); @@ -1097,7 +1097,7 @@ export const useCalendarStore = create()( if (!sub) return; // Skip if the subscription is scoped to a different JMAP account - // than the one this client is talking to — otherwise we'd create + // than the one this client is talking to - otherwise we'd create // events in the wrong account / against a missing calendar. if (sub.accountId && sub.accountId !== client.getAccountId()) { debug.warn('calendar', 'Skipping subscription refresh: account mismatch', { sub: sub.name }); @@ -1228,7 +1228,7 @@ export const useCalendarStore = create()( clearState: () => { // Preserve iCal subscriptions across the account-switch teardown. - // They're now scoped per-account via sub.accountId — wiping them + // They're now scoped per-account via sub.accountId - wiping them // here would lose them from localStorage on every switch. const preservedSubs = get().icalSubscriptions; set({ diff --git a/stores/client-registry.ts b/stores/client-registry.ts index 18a245b8..3a3baa36 100644 --- a/stores/client-registry.ts +++ b/stores/client-registry.ts @@ -3,7 +3,7 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface'; /** * Tiny indirection used by the calendar and contact stores to look up a * JMAP client by local account ID without importing `auth-store` directly - * — that would form a top-level cycle (auth-store already imports the + * - that would form a top-level cycle (auth-store already imports the * feature stores to bootstrap them after login). * * `auth-store` registers its `getClientForAccount` on module init via diff --git a/stores/email-store.ts b/stores/email-store.ts index add15ed2..76532385 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -23,7 +23,7 @@ interface EmailStore { accountMailboxes: Record; /** * When set, the mail view is reading from this account instead of the - * global active one. `null` means "use the global active account" — i.e. + * global active one. `null` means "use the global active account" - i.e. * the standard single-account behavior. Selecting a folder under a * non-active account in the Pro sidebar updates this without changing * `useAuthStore.activeAccountId`. @@ -249,7 +249,7 @@ function resolveActionMailboxes(): Mailbox[] { * Builds the `UnifiedAccountClient[]` list used by every unified fan-out * action (browse, load-more, search). Each entry has a JMAP client plus a * fresh mailbox list so the helpers can resolve the role mailbox per account. - * Accounts whose mailbox fetch fails are skipped — the unified result will + * Accounts whose mailbox fetch fails are skipped - the unified result will * surface that in its per-account error map. */ async function buildUnifiedAccountClients(): Promise { @@ -1162,7 +1162,7 @@ export const useEmailStore = create((set, get) => ({ })); // Refresh mailbox folder lists/counters for every account we touched. - // Background-only so the move feels instant — counters will catch up. + // Background-only so the move feels instant - counters will catch up. const activeAccountId = useAuthStore.getState().activeAccountId; const touched = new Set([destAccountId, ...emailIdsBySource.keys()]); for (const acctId of touched) { diff --git a/stores/file-store.ts b/stores/file-store.ts index d3ef3cba..115252e2 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -48,7 +48,7 @@ interface FileState { selectedResources: Set; uploadProgress: UploadProgress | null; client: IJMAPClient | null; - /** Which connected account's files are being browsed. Pro shell only — null in single-account contexts. */ + /** Which connected account's files are being browsed. Pro shell only - null in single-account contexts. */ currentAccountId: string | null; clipboard: ClipboardState | null; uploadAbortController: AbortController | null; diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 2d2303db..017b7007 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -155,7 +155,7 @@ export const usePluginStore = create()( })); return; } else { - // 'pending' or 'not-requested' — submit a request and refuse to enable. + // 'pending' or 'not-requested' - submit a request and refuse to enable. await submitApprovalRequest(plugin).catch(() => { /* best effort */ }); set(state => ({ plugins: state.plugins.map(p => @@ -165,12 +165,12 @@ export const usePluginStore = create()( return; } } else if (requireApproval && !policyApproved) { - // No bundleHash means we can't pin the approval — refuse. + // No bundleHash means we can't pin the approval - refuse. return; } // Per-user consent gate: prompt for any permission the user has not - // explicitly approved yet. Managed plugins (admin-pushed) skip this — + // explicitly approved yet. Managed plugins (admin-pushed) skip this - // the admin has already approved them at install time. const implicit = new Set(IMPLICIT_PERMISSIONS); const granted = new Set(plugin.grantedPermissions ?? []); @@ -549,7 +549,7 @@ async function downloadPluginBundle(pluginId: string, bundleHash?: string): Prom // Ed25519 signature verification. Present on every server-managed bundle // since the signing module is server-side; refuse to persist a bundle // that fails verification. If the header is missing (older server / dev - // build with signing disabled) we log and allow — the SHA-256 hash check + // build with signing disabled) we log and allow - the SHA-256 hash check // at load time still catches transport corruption. const sig = res.headers.get('X-Bundle-Signature'); if (sig) { diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts index 3e077d05..8db0ef96 100644 --- a/stores/pro-tab-store.ts +++ b/stores/pro-tab-store.ts @@ -8,7 +8,7 @@ export type ProTabKind = export type ProPaneId = 'main' | 'split'; /** - * Pro split layout. Only side-by-side is supported — the pane that "splits + * Pro split layout. Only side-by-side is supported - the pane that "splits * off" always lives next to the main pane on the horizontal axis. Kept as * a type alias to leave room for future layouts without churning callers. */ @@ -17,7 +17,7 @@ export type ProSplitOrientation = 'vertical'; export type ProComposerMode = 'compose' | 'reply' | 'replyAll' | 'forward'; /** - * Mirror of `EmailComposer.replyTo` — kept as a structural type here so the + * Mirror of `EmailComposer.replyTo` - kept as a structural type here so the * tab store doesn't take a runtime dependency on the composer module. */ export interface ProReplyContext { @@ -92,7 +92,7 @@ interface ProTabState { /** * Move a tab next to another tab. `edge` controls whether it lands before - * or after the target — used by the tab bar's drop indicator. Reordering + * or after the target - used by the tab bar's drop indicator. Reordering * works both within a pane and across panes (cross-pane drops move the * tab to the target pane). */ @@ -476,7 +476,7 @@ export const useProTabStore = create()( { name: 'pro-tabs', version: 3, - // Don't persist transient compose drafts in tab metadata — the composer's + // Don't persist transient compose drafts in tab metadata - the composer's // own draft-store already handles that. Persisted email tabs are fine to // restore (the tab body refetches the email by id). partialize: (state) => ({ diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 3c2ebd7a..caaac19d 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -45,7 +45,7 @@ export type ProtocolOpenMode = 'active-session' | 'new-tab'; /** * Settings that must never round-trip through the cross-device sync API. - * Decided per device and kept only in the local zustand-persist storage — + * Decided per device and kept only in the local zustand-persist storage - * a value already stored on the server (from a prior build) is ignored on * import. */ @@ -520,7 +520,7 @@ export const useSettingsStore = create()( toolbarPosition: state.toolbarPosition, hideAccountSwitcher: state.hideAccountSwitcher, showRailAccountList: state.showRailAccountList, - // proInterface is intentionally omitted — it's a per-device choice + // proInterface is intentionally omitted - it's a per-device choice // (see DEVICE_LOCAL_SETTING_KEYS) and must not be synced. enableUnifiedMailbox: state.enableUnifiedMailbox, senderFavicons: state.senderFavicons, @@ -853,7 +853,7 @@ if (typeof window !== 'undefined') { syncWarn('Settings sync endpoint returned 404, disabling sync'); syncEnabled = false; } else if (res.status === 403) { - // Identity mismatch — current session cookies don't match the + // Identity mismatch - current session cookies don't match the // username/serverUrl we're syncing for (common in dev mock mode where // no stalwart-context cookie is written, or when rememberMe is off). // Retrying won't help for this session; disable to stop the noise. diff --git a/stores/smime-store.ts b/stores/smime-store.ts index 4299bf23..a0b99b94 100644 --- a/stores/smime-store.ts +++ b/stores/smime-store.ts @@ -19,7 +19,7 @@ import { // Legacy storage key used by an earlier build that persisted unlock passphrases // in sessionStorage. Wipe on module load so any in-flight tab upgrading to this // version doesn't leave plaintext key material sitting around. New code never -// writes here — unlocked CryptoKey handles live only in the in-memory Map below. +// writes here - unlocked CryptoKey handles live only in the in-memory Map below. const LEGACY_REMEMBERED_UNLOCKS_KEY = 'smime-unlocked-session'; if (typeof window !== 'undefined') { try { window.sessionStorage.removeItem(LEGACY_REMEMBERED_UNLOCKS_KEY); } catch { /* ignore */ }