Compare commits

...
12 Commits
Author SHA1 Message Date
Linus Rath fc49fe0687 chore: update version to 1.5.3 2026-04-28 18:43:09 +02:00
Linus Rath 419382d25d feat: add contacts feature gate and update telemetry payload 2026-04-28 17:54:11 +02:00
Linus Rath 8935b81f12 chore: update version to 1.5.3 2026-04-28 17:34:06 +02:00
Linus Rath ec581ce53e feat: update Docker configuration 2026-04-28 17:27:47 +02:00
Linus Rath 81d8465a79 fix: block telemetry endpoint from pointing at internal hosts 2026-04-28 17:16:30 +02:00
Linus Rath 0f3b506604 feat: add extension preview page and API for detailed extension information 2026-04-28 15:55:23 +02:00
Linus Rath 1b84547211 feat: add Theme API v2 with token compiler, skin slot 2026-04-28 15:39:13 +02:00
Linus Rath dafc8ace3c feat: track unique logins 2026-04-28 08:19:46 +02:00
Linus Rath 2c419cc4fe feat: add right-click context menu on empty calendar space 2026-04-28 01:56:15 +02:00
Linus Rath 90acf181f3 fix: harden plugin config, TOTP token exchange, and branding file serving 2026-04-28 01:44:37 +02:00
Linus Rath 54af07f2af feat: add anonymous instance telemetry
Adds a once-per-day heartbeat that lets the project see how many
instances run Bulwark, on what platforms, with what features enabled,
and roughly how many accounts they have. No email addresses, hostnames,
IPs, or any end-user data are ever sent.

- lib/telemetry: state file, payload builder, jittered scheduler,
  instance_id persistence at <data-dir>/.telemetry-id (delete to reset)
- app/api/admin/telemetry: admin API for status / set-consent /
  set-endpoint / send-now (all audit-logged)
- app/admin/telemetry: settings page with status, JSON payload preview,
  endpoint editor, send-now button, link to the privacy page
- instrumentation.node.ts: starts the scheduler on boot

Default state is enabled. The first heartbeat fires 1 hour after boot
so an admin who installs and immediately disables produces zero pings.
Disable via the settings UI, BULWARK_TELEMETRY=off (or
BULWARK_TELEMETRY_DISABLED=1), or by clearing the endpoint.

Account counts are bucketed (1, 2-5, 6-10, 11-50, 51-200, 201+) so a
small instance can't be re-identified by exact size. The /.telemetry-id
file can be deleted to mint a fresh instance_id.

Receiving collector is open source at bulwarkmail/dashboard. Self-host
your own and point at it via BULWARK_TELEMETRY_URL. Full schema,
retention (90d raw → aggregates), and lawful basis are documented at
bulwarkmail.org/docs/legal/privacy/telemetry.
2026-04-28 01:28:41 +02:00
Linus Rath 68f1fabc4b fix: batch shortcuts act on multi-selection when present #228 2026-04-28 00:04:29 +02:00
55 changed files with 3302 additions and 123 deletions
+19 -1
View File
@@ -84,6 +84,23 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# volume there (see docker-compose.yml).
# ADMIN_DATA_DIR=./data/admin
# =============================================================================
# Anonymous Telemetry
# =============================================================================
# Anonymous instance telemetry is enabled by default. Heartbeats contain no PII:
# version, platform, bucketed account counts, and feature toggles only. See
# https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema.
#
# Disable telemetry entirely (overrides the admin UI):
# BULWARK_TELEMETRY=off
# Directory for telemetry state: instance id, consent, login HMACs
# (default: ./data/telemetry). For Docker, the default resolves to
# /app/data/telemetry - mount a persistent volume there (see docker-compose.yml)
# so the instance id and consent choice survive upgrades.
# TELEMETRY_DATA_DIR=./data/telemetry
# =============================================================================
# Server Listen Address
# =============================================================================
@@ -191,7 +208,8 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
# =============================================================================
# URL of the BulwarkMail extension directory for the admin marketplace.
# Set this to enable browsing and installing plugins/themes from the directory.
# Defaults to https://extensions.bulwarkmail.org. Override only if you run
# your own directory (e.g. http://localhost:3001 for local development).
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
# =============================================================================
+21 -2
View File
@@ -1,11 +1,30 @@
# Changelog
## 1.5.3 (2026-04-28)
> **New:** Help shape Bulwark Webmail. Each instance now sends a lightweight daily heartbeat (version, platform, bucketed account counts, feature toggles - never message data or PII) so we can see which platforms and features actually get used and prioritize fixes where they matter most. You're in control: opt out any time from **Admin → Telemetry** or by setting `BULWARK_TELEMETRY=off`. Full schema in the [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
### Features
- **Telemetry**: Anonymous instance telemetry, on by default. Reports schema version, platform, bucketed account counts, and feature toggles only - disable from the admin UI, with `BULWARK_TELEMETRY=off`, or by clearing the endpoint
- **Telemetry**: Track unique logins (HMAC'd per instance, 90-day retention) so the heartbeat can report bucketed account totals without storing usernames
- **Plugins**: Theme API v2 with token compiler and skin slot
- **Plugins**: Extension preview page and detailed extension info API
- **Calendar**: Right-click context menu on empty calendar space
- **Docker**: Persistent named volume for telemetry data so the instance id and admin's consent choice survive container upgrades
### Fixes
- **Security**: Block telemetry endpoint from pointing at internal/loopback hosts (validation + DNS-rebind re-check at fetch time)
- **Security**: Harden plugin config, TOTP token exchange, and branding file serving
- **Mail**: Batch shortcuts now act on the multi-selection when one is present (#228)
## 1.5.2 (2026-04-27)
### Features
- **Plugins**: New `composer-sidebar` slot and `ui:composer-sidebar` permission plugins can now render a panel on either side of the New Message dialog. See `repos/subway-surfers` for an example
- **Plugins**: Manifests can declare `frameOrigins` a strictly-validated list of `https://host` origins the plugin needs to embed. The proxy reads the union from enabled plugins and merges it into the host CSP `frame-src`, so the host CSP no longer needs to know about specific embed providers
- **Plugins**: New `composer-sidebar` slot and `ui:composer-sidebar` permission - plugins can now render a panel on either side of the New Message dialog. See `repos/subway-surfers` for an example
- **Plugins**: Manifests can declare `frameOrigins` - a strictly-validated list of `https://host` origins the plugin needs to embed. The proxy reads the union from enabled plugins and merges it into the host CSP `frame-src`, so the host CSP no longer needs to know about specific embed providers
- **Calendar/Contacts**: JMAP sharing for calendars and address books
- **i18n**: Czech language support
+1 -1
View File
@@ -26,7 +26,7 @@ RUN apk upgrade --no-cache && \
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN mkdir -p /app/data/settings /app/data/admin && chown -R nextjs:nodejs /app/data
RUN mkdir -p /app/data/settings /app/data/admin /app/data/telemetry && chown -R nextjs:nodejs /app/data
USER nextjs
EXPOSE 3000
ENV PORT=3000
+3 -1
View File
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.5.2-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.5.3-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div>
@@ -53,6 +53,8 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
</tr>
</table>
> **Anonymous telemetry is on by default** since 1.5.3. Each instance sends a daily heartbeat (version, platform, bucketed account counts, feature toggles - no message data, no PII). Disable from **Admin → Telemetry**, by setting `BULWARK_TELEMETRY=off`, or by clearing the endpoint. Full schema: [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
## Overview
Bulwark is a full webmail suite not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
+1 -1
View File
@@ -1 +1 @@
1.5.2
1.5.3
+58 -3
View File
@@ -31,6 +31,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 { EmptySpaceContextMenu } from "@/components/calendar/empty-space-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { downloadEventICS } from "@/lib/calendar-ics-export";
@@ -104,6 +105,7 @@ export default function CalendarPage() {
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
const [defaultModalAllDay, setDefaultModalAllDay] = useState(false);
const [miniMonth, setMiniMonth] = useState(new Date());
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
@@ -326,11 +328,12 @@ export default function CalendarPage() {
setSelectedDate(date);
}, [setSelectedDate]);
const openCreateModal = useCallback((date?: Date, endDate?: Date) => {
const openCreateModal = useCallback((date?: Date, endDate?: Date, allDay?: boolean) => {
setEditEvent(null);
const d = date || selectedDate;
setDefaultModalDate(d);
setDefaultModalEndDate(endDate);
setDefaultModalAllDay(allDay ?? false);
setSelectedDate(d);
setShowEventModal(true);
}, [selectedDate, setSelectedDate]);
@@ -395,6 +398,21 @@ export default function CalendarPage() {
openEventContextMenu(e, event);
}, [closeDetail, openEventContextMenu]);
const {
contextMenu: emptyContextMenu,
openContextMenu: openEmptyContextMenu,
closeContextMenu: closeEmptyContextMenu,
menuRef: emptyContextMenuRef,
} = useContextMenu<{ date: Date; hour?: number; allDayArea?: boolean }>();
const handleContextMenuEmpty = useCallback(
(e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => {
closeDetail();
openEmptyContextMenu(e, { date, hour, allDayArea });
},
[closeDetail, openEmptyContextMenu],
);
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
if (isMobile) return;
if (calendarHoverPreview === 'off') return;
@@ -961,6 +979,7 @@ export default function CalendarPage() {
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onContextMenuEmpty={handleContextMenuEmpty}
onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek}
isMobile={isMobile}
@@ -978,6 +997,7 @@ export default function CalendarPage() {
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onContextMenuEmpty={handleContextMenuEmpty}
onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek}
timeFormat={timeFormat}
@@ -997,6 +1017,7 @@ export default function CalendarPage() {
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onContextMenuEmpty={handleContextMenuEmpty}
onCreateAtTime={openCreateModal}
timeFormat={timeFormat}
isMobile={isMobile}
@@ -1211,12 +1232,13 @@ export default function CalendarPage() {
calendars={calendars}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
defaultAllDay={defaultModalAllDay}
defaultCalendarId={defaultCalendarIdForCreate}
onSave={handleSaveEvent}
onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp}
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); }}
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
onPreviewChange={setPendingPreview}
currentUserEmails={currentUserEmails}
isMobile={false}
@@ -1282,6 +1304,38 @@ export default function CalendarPage() {
/>
)}
{emptyContextMenu.data && (() => {
const { date, hour } = emptyContextMenu.data;
return (
<EmptySpaceContextMenu
position={emptyContextMenu.position}
isOpen={emptyContextMenu.isOpen}
onClose={closeEmptyContextMenu}
menuRef={emptyContextMenuRef}
onNewEvent={() => {
const d = new Date(date);
if (typeof hour === "number") {
d.setHours(hour, 0, 0, 0);
} else {
const now = new Date();
d.setHours(now.getHours() + 1, 0, 0, 0);
}
openCreateModal(d);
}}
onNewAllDayEvent={() => {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
openCreateModal(d, undefined, true);
}}
onNewTask={enableCalendarTasks ? () => {
setEditTask(null);
setShowTaskModal(true);
} : undefined}
onGoToToday={goToToday}
/>
);
})()}
{detailEvent && detailAnchorRect && (
<EventDetailPopover
event={detailEvent}
@@ -1308,12 +1362,13 @@ export default function CalendarPage() {
calendars={calendars}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
defaultAllDay={defaultModalAllDay}
defaultCalendarId={defaultCalendarIdForCreate}
onSave={handleSaveEvent}
onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp}
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); }}
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
currentUserEmails={currentUserEmails}
isMobile={true}
/>
+68 -12
View File
@@ -171,6 +171,11 @@ export default function Home() {
createMailbox,
renameMailbox,
deleteMailbox,
batchDelete,
batchArchive,
batchMarkAsRead,
batchMarkAsSpam,
batchUndoSpam,
} = useEmailStore();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
@@ -353,27 +358,77 @@ export default function Home() {
onToggleStar: () => {
if (selectedEmail) handleToggleStar();
},
onArchive: () => {
if (selectedEmail) handleArchive();
onArchive: async () => {
if (selectedEmailIds.size > 0 && client) {
try {
await batchArchive(client);
} catch (error) {
console.error("Failed to batch archive:", error);
}
} else if (selectedEmail) {
handleArchive();
}
},
onDelete: () => {
if (selectedEmail) handleDelete();
onDelete: async () => {
if (selectedEmailIds.size > 0 && client) {
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInTrash = currentMailbox?.role === 'trash';
const isInJunk = currentMailbox?.role === 'junk';
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
const permanent = isInTrash || (isInJunk && permanentlyDeleteJunk);
const confirmed = await confirmDialog({
title: permanent
? t('email_list.permanent_delete_confirm_title')
: t('email_list.batch_actions.delete_confirm_title'),
message: permanent
? t('email_list.permanent_delete_confirm_batch_message', { count: selectedEmailIds.size })
: t('email_list.batch_actions.delete_confirm_message', { count: selectedEmailIds.size }),
confirmText: permanent
? t('email_list.permanent_delete')
: t('email_list.batch_actions.delete'),
variant: "destructive",
});
if (!confirmed) return;
try {
await batchDelete(client, permanent);
} catch (error) {
console.error("Failed to batch delete:", error);
}
} else if (selectedEmail) {
handleDelete();
}
},
onMarkAsUnread: async () => {
if (selectedEmail && client) {
if (!client) return;
if (selectedEmailIds.size > 0) {
await batchMarkAsRead(client, false);
} else if (selectedEmail) {
await markAsRead(client, selectedEmail.id, false);
}
},
onMarkAsRead: async () => {
if (selectedEmail && client) {
if (!client) return;
if (selectedEmailIds.size > 0) {
await batchMarkAsRead(client, true);
} else if (selectedEmail) {
await markAsRead(client, selectedEmail.id, true);
}
},
onToggleSpam: () => {
if (selectedEmail) {
// Check if we're in junk folder
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInJunk = currentMailbox?.role === 'junk';
onToggleSpam: async () => {
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInJunk = currentMailbox?.role === 'junk';
if (selectedEmailIds.size > 0 && client) {
const ids = Array.from(selectedEmailIds);
try {
if (isInJunk) {
await batchUndoSpam(client, ids);
} else {
await batchMarkAsSpam(client, ids);
}
} catch (error) {
console.error("Failed to batch toggle spam:", error);
}
} else if (selectedEmail) {
if (isInJunk) {
handleUndoSpam();
} else {
@@ -408,13 +463,14 @@ export default function Home() {
clearSelection();
},
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet]);
}), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes]);
// Initialize keyboard shortcuts
useKeyboardShortcuts({
enabled: isAuthenticated && !showComposer,
emails,
selectedEmailId: selectedEmail?.id,
selectionCount: selectedEmailIds.size,
handlers: keyboardHandlers,
});
+2
View File
@@ -14,6 +14,7 @@ import {
KeyRound,
Puzzle,
SwatchBook,
Activity,
Mail,
Calendar,
BookUser,
@@ -56,6 +57,7 @@ const NAV_GROUPS = [
{
label: 'System',
items: [
{ href: '/admin/telemetry', label: 'Telemetry', icon: Activity },
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
],
},
+542
View File
@@ -0,0 +1,542 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import {
ArrowLeft,
Download,
Loader2,
Puzzle,
SwatchBook,
Star,
Trash2,
Check,
Settings as SettingsIcon,
ExternalLink,
Shield,
AlertTriangle,
FileCode,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface PreviewData {
extension: {
slug: string;
name: string;
type: 'plugin' | 'theme';
pluginType: string | null;
description: string;
longDescription: string | null;
tags: string[];
permissions: string[];
totalDownloads: number;
featured: boolean;
githubRepo: string | null;
license: string | null;
minAppVersion: string | null;
author: {
displayName: string;
githubLogin: string;
avatarUrl: string | null;
verified?: boolean;
} | null;
latestVersion: string | null;
versions: Array<{
version: string;
changelog: string | null;
bundleSize: number;
minAppVersion: string | null;
publishedAt: string | null;
permissions: string[];
}>;
screenshots: Array<{ url: string; altText: string | null }>;
themePreviews: Array<{
variant: 'light' | 'dark';
previewPath: string;
colors: Record<string, string> | null;
}>;
createdAt: string | null;
updatedAt: string | null;
};
bundle: {
manifest: Record<string, unknown> | null;
source: { name: string; content: string; truncated: boolean } | null;
size: number;
error: string | null;
};
installed: boolean;
}
const RISKY_PERMISSIONS = new Set([
'mail:write',
'mail:delete',
'storage:write',
'network',
'admin',
]);
export default function MarketplacePreviewPage() {
const params = useParams();
const slug = params.slug as string;
const [data, setData] = useState<PreviewData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [installing, setInstalling] = useState(false);
const [uninstalling, setUninstalling] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [showSource, setShowSource] = useState(false);
const [showManifest, setShowManifest] = useState(false);
const fetchPreview = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await apiFetch(`/api/admin/marketplace/${encodeURIComponent(slug)}`);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
setError(body.error || 'Failed to load preview');
return;
}
setData(await res.json());
} catch {
setError('Failed to connect to extension directory');
} finally {
setLoading(false);
}
}, [slug]);
useEffect(() => { fetchPreview(); }, [fetchPreview]);
async function handleInstall() {
if (!data) return;
setInstalling(true);
setMessage(null);
try {
const res = await apiFetch('/api/admin/marketplace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug: data.extension.slug,
version: data.extension.latestVersion || '1.0.0',
type: data.extension.type,
}),
});
const body = await res.json();
if (res.ok) {
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `"${data.extension.name}" installed${warnings}` });
setData(prev => prev ? { ...prev, installed: true } : prev);
} else {
setMessage({ type: 'error', text: body.error || 'Installation failed' });
}
} catch {
setMessage({ type: 'error', text: 'Installation failed - network error' });
} finally {
setInstalling(false);
}
}
async function handleUninstall() {
if (!data) return;
if (!confirm(`Remove "${data.extension.name}"? This cannot be undone.`)) return;
setUninstalling(true);
setMessage(null);
try {
const endpoint = data.extension.type === 'theme'
? '/api/admin/themes'
: '/api/admin/plugins';
const res = await apiFetch(endpoint, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: data.extension.slug }),
});
const body = await res.json().catch(() => ({}));
if (res.ok) {
setMessage({ type: 'success', text: `"${data.extension.name}" removed` });
setData(prev => prev ? { ...prev, installed: false } : prev);
} else {
setMessage({ type: 'error', text: body.error || 'Uninstall failed' });
}
} catch {
setMessage({ type: 'error', text: 'Uninstall failed - network error' });
} finally {
setUninstalling(false);
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Loading...
</div>
);
}
if (error || !data) {
return (
<div className="space-y-4">
<Link
href="/admin/marketplace"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
</Link>
<p className="text-sm text-destructive">{error || 'Extension not found'}</p>
</div>
);
}
const ext = data.extension;
const bundle = data.bundle;
const isPlugin = ext.type === 'plugin';
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;
return (
<div className="space-y-6 max-w-4xl">
{/* Back link */}
<Link
href="/admin/marketplace"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
</Link>
{/* Header */}
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0">
{isPlugin ? (
<Puzzle className="w-7 h-7 text-muted-foreground" />
) : (
<SwatchBook className="w-7 h-7 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground truncate">{ext.name}</h1>
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
{data.installed && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium">
<Check className="w-3 h-3" /> Installed
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
isPlugin
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
}`}>
{isPlugin ? (ext.pluginType || 'plugin') : 'theme'}
</span>
{ext.author && (
<span>by {ext.author.displayName}</span>
)}
{ext.latestVersion && <span>v{ext.latestVersion}</span>}
{ext.license && <span>{ext.license}</span>}
<span className="inline-flex items-center gap-1">
<Download className="w-3 h-3" />
{ext.totalDownloads.toLocaleString()}
</span>
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 shrink-0">
{data.installed ? (
<>
<Link
href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'}
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
<SettingsIcon className="w-4 h-4" />
Manage
</Link>
<button
onClick={handleUninstall}
disabled={uninstalling}
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50 transition-colors"
>
{uninstalling ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Uninstall
</button>
</>
) : (
<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"
>
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
Install
</button>
)}
</div>
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</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" />
<div>
<p className="font-medium">Could not preview bundle</p>
<p className="text-xs mt-0.5 opacity-90">{bundle.error}</p>
</div>
</div>
)}
{/* Description */}
<section className="border border-border rounded-lg p-4">
<h2 className="text-sm font-medium text-foreground">About</h2>
<p className="text-sm text-muted-foreground mt-2">{ext.description}</p>
{ext.longDescription && ext.longDescription !== ext.description && (
<p className="text-sm text-muted-foreground mt-3 whitespace-pre-wrap">{ext.longDescription}</p>
)}
{ext.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{ext.tags.map(tag => (
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{tag}
</span>
))}
</div>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-4 pt-3 border-t border-border flex-wrap">
{ext.minAppVersion && <span>Requires app v{ext.minAppVersion}+</span>}
{bundle.size > 0 && <span>Bundle: {(bundle.size / 1024).toFixed(1)} KB</span>}
{ext.githubRepo && (
<a
href={`https://github.com/${ext.githubRepo}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 hover:text-foreground"
>
<ExternalLink className="w-3 h-3" />
{ext.githubRepo}
</a>
)}
</div>
</section>
{/* Screenshots */}
{ext.screenshots.length > 0 && (
<section className="border border-border rounded-lg p-4">
<h2 className="text-sm font-medium text-foreground">Screenshots</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
{ext.screenshots.map((s, i) => (
<img
key={i}
src={s.url}
alt={s.altText || `Screenshot ${i + 1}`}
className="w-full rounded-md border border-border bg-muted"
loading="lazy"
/>
))}
</div>
</section>
)}
{/* Theme color preview */}
{!isPlugin && ext.themePreviews.length > 0 && (
<section className="border border-border rounded-lg p-4">
<h2 className="text-sm font-medium text-foreground">Theme preview</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
{ext.themePreviews.map(preview => (
<ThemeColorSwatch key={preview.variant} preview={preview} />
))}
</div>
</section>
)}
{/* Permissions */}
{isPlugin && (
<section className="border border-border rounded-lg p-4">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Permissions</h2>
</div>
{manifestPerms.length === 0 ? (
<p className="text-sm text-muted-foreground mt-2">This plugin requests no permissions.</p>
) : (
<ul className="mt-3 space-y-1.5">
{manifestPerms.map(perm => {
const risky = RISKY_PERMISSIONS.has(perm);
return (
<li
key={perm}
className={`flex items-center gap-2 text-sm rounded-md px-2 py-1 ${
risky
? 'bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300'
: 'bg-muted/50 text-foreground'
}`}
>
{risky && <AlertTriangle className="w-3.5 h-3.5 shrink-0" />}
<code className="font-mono text-xs">{perm}</code>
</li>
);
})}
</ul>
)}
{frameOrigins.length > 0 && (
<div className="mt-4 pt-3 border-t border-border">
<h3 className="text-xs font-medium text-foreground">Iframe origins</h3>
<p className="text-xs text-muted-foreground mt-0.5">
The plugin will be allowed to embed content from these origins.
</p>
<ul className="mt-2 space-y-1">
{frameOrigins.map(origin => (
<li key={origin} className="text-xs font-mono text-foreground bg-muted/50 px-2 py-1 rounded">
{origin}
</li>
))}
</ul>
</div>
)}
</section>
)}
{/* Settings schema preview */}
{isPlugin && settingsSchema && Object.keys(settingsSchema).length > 0 && (
<section className="border border-border rounded-lg p-4">
<h2 className="text-sm font-medium text-foreground">User settings</h2>
<p className="text-xs text-muted-foreground mt-0.5">Settings users will be able to configure after install.</p>
<ul className="mt-3 divide-y divide-border">
{Object.entries(settingsSchema).map(([key, field]) => (
<li key={key} className="py-2">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-foreground">{key}</code>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{field.type}</span>
</div>
<div className="text-sm text-foreground mt-0.5">{field.label}</div>
{field.description && (
<div className="text-xs text-muted-foreground mt-0.5">{field.description}</div>
)}
</li>
))}
</ul>
</section>
)}
{/* Source / manifest disclosure */}
{bundle.manifest && (
<section className="border border-border rounded-lg">
<button
onClick={() => setShowManifest(v => !v)}
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-2">
<FileCode className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">manifest.json</h2>
</div>
{showManifest ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</button>
{showManifest && (
<pre className="px-4 pb-4 text-xs font-mono overflow-x-auto text-foreground whitespace-pre">
{JSON.stringify(bundle.manifest, null, 2)}
</pre>
)}
</section>
)}
{bundle.source && (
<section className="border border-border rounded-lg">
<button
onClick={() => setShowSource(v => !v)}
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-2">
<FileCode className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">{bundle.source.name}</h2>
{bundle.source.truncated && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400">truncated</span>
)}
</div>
{showSource ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</button>
{showSource && (
<pre className="px-4 pb-4 text-xs font-mono overflow-x-auto text-foreground whitespace-pre max-h-[600px] overflow-y-auto">
{bundle.source.content}
</pre>
)}
</section>
)}
{/* Version history */}
{ext.versions.length > 0 && (
<section className="border border-border rounded-lg p-4">
<h2 className="text-sm font-medium text-foreground">Version history</h2>
<ul className="mt-3 divide-y divide-border">
{ext.versions.slice(0, 5).map(v => (
<li key={v.version} className="py-2 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-foreground">v{v.version}</code>
{v.publishedAt && (
<span className="text-xs text-muted-foreground">
{new Date(v.publishedAt).toLocaleDateString()}
</span>
)}
</div>
{v.changelog && (
<p className="text-xs text-muted-foreground mt-0.5 whitespace-pre-wrap">{v.changelog}</p>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0">
{(v.bundleSize / 1024).toFixed(1)} KB
</span>
</li>
))}
</ul>
</section>
)}
</div>
);
}
function ThemeColorSwatch({ preview }: { preview: { variant: 'light' | 'dark'; colors: Record<string, string> | null } }) {
const colors = preview.colors || {};
const bg = colors.background || (preview.variant === 'dark' ? '#0f0f10' : '#ffffff');
const fg = colors.foreground || (preview.variant === 'dark' ? '#fafafa' : '#0a0a0a');
const accent = colors.primary || colors.accent || '#7c5cff';
const muted = colors.muted || (preview.variant === 'dark' ? '#1a1a1c' : '#f5f5f5');
const border = colors.border || (preview.variant === 'dark' ? '#27272a' : '#e5e5e5');
return (
<div className="rounded-md border border-border overflow-hidden">
<div className="px-3 py-2 text-xs font-medium text-muted-foreground bg-muted/30 border-b border-border capitalize">
{preview.variant}
</div>
<div className="p-3 space-y-2" style={{ background: bg, color: fg }}>
<div className="flex items-center gap-2">
<span className="inline-block w-6 h-6 rounded" style={{ background: accent }} />
<span className="text-sm font-medium" style={{ color: fg }}>Sample text</span>
</div>
<div className="rounded p-2 text-xs" style={{ background: muted, border: `1px solid ${border}` }}>
<span style={{ color: fg }}>Card surface</span>
</div>
<div className="flex flex-wrap gap-1">
{Object.entries(colors).slice(0, 6).map(([key, value]) => (
<span
key={key}
title={`${key}: ${value}`}
className="inline-block w-4 h-4 rounded border"
style={{ background: value, borderColor: border }}
/>
))}
</div>
</div>
</div>
);
}
+36 -25
View File
@@ -1,7 +1,8 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react';
import Link from 'next/link';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface Extension {
@@ -262,10 +263,11 @@ function ExtensionCard({
onInstall: () => void;
}) {
const isPlugin = extension.type === 'plugin';
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
return (
<div className="border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
<div className="p-4">
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
<Link href={previewHref} className="block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-lg">
{/* Header */}
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0">
@@ -277,7 +279,9 @@ function ExtensionCard({
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground truncate">{extension.name}</span>
<span className="text-sm font-medium text-foreground truncate group-hover:underline">
{extension.name}
</span>
{extension.featured && (
<Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" />
)}
@@ -315,7 +319,7 @@ function ExtensionCard({
</div>
)}
{/* Footer */}
{/* Footer (download count + permissions) */}
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
@@ -328,27 +332,34 @@ function ExtensionCard({
</span>
)}
</div>
{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>
) : (
<button
onClick={onInstall}
disabled={installing}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{installing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Download className="w-3 h-3" />
)}
Install
</button>
)}
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground">
<Eye className="w-3 h-3" />
Preview
</span>
</div>
</Link>
{/* Quick install button (sits over the link, stops navigation) */}
<div className="px-4 pb-4 -mt-1">
{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>
) : (
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
disabled={installing}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{installing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Download className="w-3 h-3" />
)}
Quick install
</button>
)}
</div>
</div>
);
+19 -1
View File
@@ -31,6 +31,7 @@ export default function AdminDashboardPage() {
const [pluginCount, setPluginCount] = useState(0);
const [themeCount, setThemeCount] = useState(0);
const [policyRuleCount, setPolicyRuleCount] = useState(0);
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
useEffect(() => {
@@ -38,7 +39,7 @@ export default function AdminDashboardPage() {
}, []);
async function fetchDashboardData() {
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes, telemetryRes] = await Promise.all([
apiFetch('/api/admin/auth'),
apiFetch('/api/admin/audit?limit=10'),
apiFetch('/api/config'),
@@ -46,6 +47,7 @@ export default function AdminDashboardPage() {
apiFetch('/api/admin/plugins').catch(() => null),
apiFetch('/api/admin/themes').catch(() => null),
apiFetch('/api/admin/policy').catch(() => null),
apiFetch('/api/admin/telemetry').catch(() => null),
]);
if (statusRes.ok) setStatus(await statusRes.json());
@@ -73,6 +75,12 @@ export default function AdminDashboardPage() {
const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0;
setPolicyRuleCount(restrictionCount + disabledGates);
}
if (telemetryRes?.ok) {
const telemetry = await telemetryRes.json();
if (telemetry.accountCounts && typeof telemetry.accountCounts.total === 'number') {
setAccountCounts(telemetry.accountCounts);
}
}
if (configData?.jmapServerUrl) {
try {
@@ -165,6 +173,16 @@ export default function AdminDashboardPage() {
</SettingItem>
</SettingsSection>
{/* Accounts */}
<SettingsSection title="Accounts" description="Unique logins recorded over the last 90 days">
<SettingItem label="Total accounts" description="Distinct identities seen in the retention window">
<span className="text-sm text-foreground">{accountCounts?.total ?? '-'}</span>
</SettingItem>
<SettingItem label="Active in last 7 days" description="Identities with a login in the past week">
<span className="text-sm text-foreground">{accountCounts?.active7d ?? '-'}</span>
</SettingItem>
</SettingsSection>
{/* Extensions */}
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
<SettingItem label="Plugins">
+1
View File
@@ -15,6 +15,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
+250
View File
@@ -0,0 +1,250 @@
'use client';
import { useEffect, useState } from 'react';
import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface TelemetryStatus {
consent: 'pending' | 'on' | 'off';
consentSource: 'env' | 'file';
endpoint: string;
defaultEndpoint: string;
consentedAt: string | null;
lastSentAt: string | null;
nextScheduledAt: string | null;
payloadPreview: Record<string, unknown>;
accountCounts: { total: number; active7d: number };
}
function timeAgo(iso: string | null): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
if (d < 0) return new Date(iso).toLocaleString();
const m = Math.floor(d / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m} min ago`;
const h = Math.floor(m / 60);
if (h < 48) return `${h} hours ago`;
const days = Math.floor(h / 24);
return `${days} days ago`;
}
export default function AdminTelemetryPage() {
const [status, setStatus] = useState<TelemetryStatus | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [endpointDraft, setEndpointDraft] = useState('');
const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null);
async function refresh(): Promise<void> {
setLoading(true);
try {
const r = await apiFetch('/api/admin/telemetry');
if (!r.ok) throw new Error('failed to load');
const data = (await r.json()) as TelemetryStatus;
setStatus(data);
setEndpointDraft(data.endpoint);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
useEffect(() => { void refresh(); }, []);
async function setConsent(consent: 'on' | 'off'): Promise<void> {
setBusy('consent');
try {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'set-consent', consent }),
});
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
alert(j.error ?? 'failed');
}
await refresh();
} finally { setBusy(null); }
}
async function saveEndpoint(): Promise<void> {
setBusy('endpoint');
try {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }),
});
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
alert(j.error ?? 'failed');
}
await refresh();
} finally { setBusy(null); }
}
async function sendNow(): Promise<void> {
setBusy('send');
setSendResult(null);
try {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'send-now' }),
});
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; status?: number; error?: string };
setSendResult({
ok: !!j.ok,
msg: j.ok ? `sent (HTTP ${j.status ?? '?'})` : `failed: ${j.error ?? 'unknown'}`,
});
await refresh();
} finally { setBusy(null); }
}
if (loading || !status) {
return (
<div className="p-8 flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> loading
</div>
);
}
const envOverridden = status.consentSource === 'env';
const isOn = status.consent === 'on';
return (
<div className="max-w-3xl mx-auto p-6 space-y-6">
<header className="space-y-2">
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
<p className="text-sm text-muted-foreground">
Bulwark sends one anonymous heartbeat per day so we can see how many instances are
running, on what platforms, and which features they use. <strong>Enabled by default</strong>;
one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '}
<a
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
target="_blank"
rel="noreferrer"
className="underline inline-flex items-center gap-1"
>
Full schema and policy <ExternalLink className="h-3 w-3" />
</a>
</p>
</header>
<section className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="font-medium">Status</div>
<div className="text-sm text-muted-foreground">
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
{status.consent === 'off' && 'Heartbeats are off.'}
{envOverridden && (
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
)}
</div>
</div>
<div className="flex gap-2">
<button
type="button"
disabled={busy === 'consent' || envOverridden || isOn}
onClick={() => void setConsent('on')}
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Enable
</button>
<button
type="button"
disabled={busy === 'consent' || envOverridden || status.consent === 'off'}
onClick={() => void setConsent('off')}
className="px-3 py-1.5 rounded-md border hover:bg-accent disabled:opacity-50"
>
Disable
</button>
</div>
</div>
<dl className="grid grid-cols-2 gap-2 text-sm pt-2 border-t">
<dt className="text-muted-foreground">Last sent</dt>
<dd>{timeAgo(status.lastSentAt)}</dd>
<dt className="text-muted-foreground">Next scheduled</dt>
<dd>{timeAgo(status.nextScheduledAt)}</dd>
<dt className="text-muted-foreground">Consented at</dt>
<dd>{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}</dd>
</dl>
</section>
<section className="rounded-lg border p-4 space-y-2">
<div className="font-medium">Account activity</div>
<p className="text-sm text-muted-foreground">
Unique accounts that have logged in over the last 90 days. Identities are stored as a
per-instance HMAC, never as plaintext usernames. These are the numbers reported in the
heartbeat as bucketed ranges.
</p>
<dl className="grid grid-cols-2 gap-2 text-sm pt-1">
<dt className="text-muted-foreground">Total (90d)</dt>
<dd className="font-mono">{status.accountCounts?.total ?? 0}</dd>
<dt className="text-muted-foreground">Active (7d)</dt>
<dd className="font-mono">{status.accountCounts?.active7d ?? 0}</dd>
</dl>
</section>
<section className="rounded-lg border p-4 space-y-3">
<div className="font-medium">Endpoint</div>
<p className="text-sm text-muted-foreground">
Where heartbeats are sent. Defaults to the project's collector. Point at your own collector
(open source at <code>bulwarkmail/dashboard</code>) or clear this field to disable sending.
</p>
<div className="flex gap-2">
<input
type="url"
value={endpointDraft}
onChange={(e) => setEndpointDraft(e.target.value)}
placeholder={status.defaultEndpoint}
className="flex-1 px-3 py-1.5 rounded-md border bg-background"
/>
<button
type="button"
disabled={busy === 'endpoint' || endpointDraft === status.endpoint}
onClick={() => void saveEndpoint()}
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
>
<Save className="h-4 w-4" /> Save
</button>
</div>
</section>
<section className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="font-medium">Payload preview</div>
<div className="text-sm text-muted-foreground">
Exactly what the next heartbeat would send from this install, right now.
</div>
</div>
<button
type="button"
disabled={busy === 'send' || !isOn}
onClick={() => void sendNow()}
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
>
<Send className="h-4 w-4" /> Send now
</button>
</div>
{sendResult && (
<div
className={`text-sm flex items-center gap-2 ${
sendResult.ok ? 'text-emerald-600' : 'text-red-600'
}`}
>
{sendResult.ok ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
{sendResult.msg}
</div>
)}
<pre className="text-xs bg-muted/50 rounded-md p-3 overflow-x-auto max-h-96">
{JSON.stringify(status.payloadPreview, null, 2)}
</pre>
</section>
</div>
);
}
@@ -53,11 +53,19 @@ export async function GET(
const buffer = await readFile(resolved);
// SVG can carry inline <script> and event handlers that execute when the
// file is fetched as a top-level document. Defense in depth on top of
// admin-only upload: nosniff blocks MIME confusion, the CSP forces a
// sandboxed unique origin so any script in an SVG is inert and cannot
// touch app cookies or storage.
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600, must-revalidate',
'Content-Length': String(buffer.length),
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy':
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
},
});
} catch {
+214
View File
@@ -0,0 +1,214 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/admin/session';
import { logger } from '@/lib/logger';
import {
getPluginRegistry,
getThemeRegistry,
} from '@/lib/admin/plugin-registry';
import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
const MAX_PREVIEW_SOURCE_LEN = 100_000;
/**
* GET /api/admin/marketplace/[slug]
* Returns full preview info for an extension: directory metadata,
* the bundle's manifest, a (truncated) source preview, and install status.
* Lets admins audit what they're about to install before pressing the button.
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ slug: string }> },
) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const { slug } = await params;
// 1. Extension metadata + screenshots + theme previews from the directory
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, DIRECTORY_URL);
const detailRes = await fetch(detailUrl.toString(), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!detailRes.ok) {
const status = detailRes.status === 404 ? 404 : 502;
return NextResponse.json(
{ error: status === 404 ? 'Extension not found' : 'Directory request failed' },
{ status },
);
}
const detailJson = await detailRes.json();
const extension = detailJson.data as Record<string, unknown> | undefined;
if (!extension) {
return NextResponse.json({ error: 'Extension not found' }, { status: 404 });
}
const type = extension.type as 'plugin' | 'theme';
const latestVersion = (extension.latestVersion as { version?: string } | null)?.version
?? null;
// 2. Pull the bundle so we can show what's actually inside.
let manifest: Record<string, unknown> | null = null;
let sourcePreview: { name: string; content: string; truncated: boolean } | null = null;
let bundleError: string | null = null;
let bundleSize = 0;
if (latestVersion) {
try {
const bundleUrl = new URL(
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
DIRECTORY_URL,
);
const bundleRes = await fetch(bundleUrl.toString(), {
signal: AbortSignal.timeout(30000),
});
if (!bundleRes.ok) {
bundleError = `Bundle download failed (${bundleRes.status})`;
} else {
const buffer = await bundleRes.arrayBuffer();
bundleSize = buffer.byteLength;
const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE;
if (buffer.byteLength > maxSize) {
bundleError = `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit`;
} else {
const zip = await JSZip.loadAsync(buffer);
// Detect optional root directory inside the ZIP.
const entries = Object.keys(zip.files);
const topDirs = new Set(entries.map((e) => e.split('/')[0]));
let root = '';
if (topDirs.size === 1) {
const dir = [...topDirs][0];
if (zip.files[dir + '/'] || entries.some((e) => e.startsWith(dir + '/'))) {
root = dir + '/';
}
}
const manifestFile = zip.file(root + 'manifest.json');
if (!manifestFile) {
bundleError = 'Bundle missing manifest.json';
} else {
try {
manifest = JSON.parse(await manifestFile.async('string'));
} catch {
bundleError = 'Invalid manifest.json in bundle';
}
}
if (manifest) {
if (type === 'theme') {
const cssFile = zip.file(root + 'theme.css');
if (cssFile) {
const css = await cssFile.async('string');
sourcePreview = {
name: 'theme.css',
content: css.length > MAX_PREVIEW_SOURCE_LEN
? css.slice(0, MAX_PREVIEW_SOURCE_LEN)
: css,
truncated: css.length > MAX_PREVIEW_SOURCE_LEN,
};
}
} else {
const entrypoint = (manifest.entrypoint as string) || 'index.js';
const jsFile = zip.file(root + entrypoint);
if (jsFile) {
const code = await jsFile.async('string');
sourcePreview = {
name: entrypoint,
content: code.length > MAX_PREVIEW_SOURCE_LEN
? code.slice(0, MAX_PREVIEW_SOURCE_LEN)
: code,
truncated: code.length > MAX_PREVIEW_SOURCE_LEN,
};
}
}
}
}
}
} catch (err) {
bundleError = err instanceof Error ? err.message : 'Failed to read bundle';
}
} else {
bundleError = 'Extension has no published version';
}
// 3. Install status (slug is used as the registry id at install time)
const [pluginRegistry, themeRegistry] = await Promise.all([
getPluginRegistry(),
getThemeRegistry(),
]);
const installed = type === 'theme'
? themeRegistry.themes.some((t) => t.id === slug)
: pluginRegistry.plugins.some((p) => p.id === slug);
// 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(),
altText: s.altText ?? null,
}))
: [];
// Strip the heavy `manifest` blob from versions when echoing the directory data.
const versions = Array.isArray(extension.versions)
? (extension.versions as Array<Record<string, unknown>>).map((v) => ({
version: v.version,
changelog: v.changelog,
bundleSize: v.bundleSize,
minAppVersion: v.minAppVersion,
publishedAt: v.publishedAt,
permissions: v.permissions,
}))
: [];
return NextResponse.json(
{
extension: {
slug: extension.slug,
name: extension.name,
type: extension.type,
pluginType: extension.pluginType ?? null,
description: extension.description,
longDescription: extension.longDescription ?? null,
tags: extension.tags ?? [],
permissions: extension.permissions ?? [],
totalDownloads: extension.totalDownloads ?? 0,
featured: extension.featured ?? false,
githubRepo: extension.githubRepo ?? null,
license: extension.license ?? null,
minAppVersion: extension.minAppVersion ?? null,
author: extension.author ?? null,
latestVersion,
versions,
screenshots,
themePreviews: extension.themePreviews ?? [],
createdAt: extension.createdAt ?? null,
updatedAt: extension.updatedAt ?? null,
},
bundle: {
manifest,
source: sourcePreview,
size: bundleSize,
error: bundleError,
},
installed,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (error) {
logger.error('Marketplace preview error', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json(
{ error: 'Failed to load preview' },
{ status: 502 },
);
}
}
+2 -2
View File
@@ -18,7 +18,7 @@ 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';
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001';
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
/**
* GET /api/admin/marketplace - Search/browse the extension directory
@@ -232,7 +232,7 @@ export async function POST(request: NextRequest) {
// Plugins may declare iframe origins they need for embedded content.
// Anything that doesn't pass strict origin validation is silently
// dropped the plugin still installs, but those origins are not
// dropped - the plugin still installs, but those origins are not
// added to the host CSP.
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
+2 -2
View File
@@ -139,7 +139,7 @@ export async function POST(request: NextRequest) {
const queryEntry = queryRes.methodResponses?.[0];
if (!queryEntry || queryEntry[0] === 'error') {
return NextResponse.json({
error: 'Stalwart denied OAuthClient/query your Stalwart account likely lacks admin permissions.',
error: 'Stalwart denied OAuthClient/query - your Stalwart account likely lacks admin permissions.',
detail: queryEntry?.[1],
}, { status: 403 });
}
@@ -187,7 +187,7 @@ export async function POST(request: NextRequest) {
const setEntry = setRes.methodResponses?.[0];
if (!setEntry || setEntry[0] === 'error') {
return NextResponse.json({
error: 'Stalwart denied OAuthClient/set admin permissions required.',
error: 'Stalwart denied OAuthClient/set - admin permissions required.',
detail: setEntry?.[1],
}, { status: 403 });
}
+31 -5
View File
@@ -2,15 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
import { getPlugin } from '@/lib/admin/plugin-registry';
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
import { requireAdminAuth } from '@/lib/admin/session';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* GET /api/admin/plugins/[id]/config - Read all config for a plugin
* GET /api/admin/plugins/[id]/config - Read plugin config
*
* Returns the full config object for admin-configured plugin settings.
* This endpoint is accessible from the client-side plugin API.
* - Admin sessions receive every field, including those declared
* `type: 'secret'` in the plugin's configSchema.
* - Authenticated mailbox users (the plugin running in their browser)
* receive only non-secret fields.
* - Anonymous callers are rejected so unauthenticated visitors cannot
* enumerate plugin secrets.
*/
export async function GET(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
@@ -20,13 +25,34 @@ export async function GET(
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
}
const adminAuth = await requireAdminAuth();
const isAdmin = !('error' in adminAuth);
if (!isAdmin) {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
}
const plugin = await getPlugin(id);
if (!plugin) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
const config = await getPluginConfig(id);
return NextResponse.json(config, {
let response: Record<string, unknown> = config;
if (!isAdmin && plugin.configSchema) {
response = {};
for (const [key, value] of Object.entries(config)) {
const field = plugin.configSchema[key];
if (field?.type === 'secret') continue;
response[key] = value;
}
}
return NextResponse.json(response, {
headers: { 'Cache-Control': 'no-store' },
});
} catch {
+135
View File
@@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import {
effectiveConsent,
loadState,
saveState,
buildPayload,
sendOnce,
reschedule,
DEFAULT_ENDPOINT,
getLoginCounts,
resolveEndpointAllowed,
} from '@/lib/telemetry';
/**
* GET /api/admin/telemetry
* Returns current consent + endpoint + next/last send + a live preview
* of exactly what the next heartbeat would contain.
*/
export async function GET() {
try {
const auth = await requireAdminAuth();
if ('error' in auth) return auth.error;
const { consent, source, state } = await effectiveConsent();
const [payload, accountCounts] = await Promise.all([
buildPayload(),
getLoginCounts(),
]);
return NextResponse.json(
{
consent,
consentSource: source,
endpoint: state.endpoint || DEFAULT_ENDPOINT,
consentedAt: state.consentedAt,
lastSentAt: state.lastSentAt,
nextScheduledAt: state.nextScheduledAt,
defaultEndpoint: DEFAULT_ENDPOINT,
payloadPreview: payload,
accountCounts,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (err) {
logger.error('telemetry GET error', {
error: err instanceof Error ? err.message : 'unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/admin/telemetry
* Body: { action: 'set-consent' | 'set-endpoint' | 'send-now', ... }
* set-consent : { action, consent: 'on' | 'off' }
* set-endpoint : { action, endpoint: string }
* send-now : { action }
*/
export async function POST(request: NextRequest) {
try {
const auth = await requireAdminAuth();
if ('error' in auth) return auth.error;
const ip = getClientIP(request);
const body = (await request.json().catch(() => null)) as
| { action?: string; consent?: string; endpoint?: string }
| null;
if (!body || typeof body.action !== 'string') {
return NextResponse.json({ error: 'action required' }, { status: 400 });
}
const { source } = await effectiveConsent();
if (body.action === 'set-consent') {
if (source === 'env') {
return NextResponse.json(
{ error: 'consent is overridden by BULWARK_TELEMETRY env var' },
{ status: 409 },
);
}
if (body.consent !== 'on' && body.consent !== 'off') {
return NextResponse.json({ error: 'consent must be "on" or "off"' }, { status: 400 });
}
const state = await loadState();
const before = state.consent;
state.consent = body.consent;
if (body.consent === 'on' && !state.consentedAt) {
state.consentedAt = new Date().toISOString();
}
await saveState(state);
await reschedule();
await auditLog('telemetry.set-consent', { from: before, to: body.consent }, ip);
return NextResponse.json({ ok: true });
}
if (body.action === 'set-endpoint') {
if (typeof body.endpoint !== 'string') {
return NextResponse.json({ error: 'endpoint required' }, { status: 400 });
}
const trimmed = body.endpoint.trim();
if (trimmed) {
const check = await resolveEndpointAllowed(trimmed);
if (!check.ok) {
return NextResponse.json({ error: check.reason }, { status: 400 });
}
}
const state = await loadState();
const before = state.endpoint;
state.endpoint = trimmed || DEFAULT_ENDPOINT;
await saveState(state);
await auditLog('telemetry.set-endpoint', { from: before, to: state.endpoint }, ip);
return NextResponse.json({ ok: true, endpoint: state.endpoint });
}
if (body.action === 'send-now') {
const result = await sendOnce({ reason: 'admin-manual' });
await auditLog(
'telemetry.send-now',
{ ok: result.ok, status: result.status ?? null, error: result.error ?? null },
ip,
);
return NextResponse.json(result, { status: result.ok ? 200 : 502 });
}
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
} catch (err) {
logger.error('telemetry POST error', {
error: err instanceof Error ? err.message : 'unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+3
View File
@@ -10,6 +10,7 @@ import {
setStalwartAuthContextInStore,
} from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager';
import { recordLogin } from '@/lib/telemetry/login-tracker';
const COOKIE_OPTIONS = {
...getCookieOptions(),
@@ -50,6 +51,8 @@ export async function POST(request: NextRequest) {
authHeader,
});
void recordLogin(username, normalizedServerUrl);
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
+3
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
import { recordLogin } from '@/lib/telemetry/login-tracker';
function getSlot(request: NextRequest, bodySlot: unknown): number {
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
@@ -32,6 +33,8 @@ export async function POST(request: NextRequest) {
authHeader,
});
void recordLogin(username, normalizedServerUrl);
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
+36 -12
View File
@@ -6,6 +6,8 @@ import { refreshTokenCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
/**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
@@ -84,22 +86,39 @@ export async function POST(request: NextRequest) {
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
// Use the server-side JMAP_SERVER_URL if set (may differ from the
// public URL the browser uses, e.g. inside Docker).
const internalServerUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || serverUrl;
// Pin the upstream URL to the configured JMAP server so an unauthenticated
// caller cannot point this route at internal hosts. Only when no server
// URL is configured (and the deployment explicitly allows custom JMAP
// endpoints) do we fall back to the user-supplied URL - and even then
// it must resolve to a public address.
await configManager.ensureLoaded();
const configuredServerUrl =
configManager.get<string>('jmapServerUrl', '') ||
process.env.JMAP_SERVER_URL ||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
'';
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
const tokenEndpoint = await findTokenEndpoint(internalServerUrl);
if (!tokenEndpoint) {
// Also try with the client-provided URL in case the internal one differs
const clientEndpoint = internalServerUrl !== serverUrl ? await findTokenEndpoint(serverUrl) : null;
if (!clientEndpoint) {
logger.warn('TOTP token exchange: no token endpoint found', { serverUrl, internalServerUrl });
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
let upstreamUrl: string;
if (configuredServerUrl) {
upstreamUrl = configuredServerUrl;
} else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) {
logger.warn('TOTP token exchange: rejected non-public server URL');
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
}
return await attemptAllStrategies(clientEndpoint, username, password, slot);
upstreamUrl = serverUrl;
} else {
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
}
return await attemptAllStrategies(tokenEndpoint, username, password, slot);
const tokenEndpoint = await findTokenEndpoint(upstreamUrl);
if (!tokenEndpoint) {
logger.warn('TOTP token exchange: no token endpoint found');
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
}
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot);
} catch (error) {
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
@@ -108,6 +127,7 @@ export async function POST(request: NextRequest) {
async function attemptAllStrategies(
tokenEndpoint: string,
serverUrl: string,
username: string,
password: string,
slot: number,
@@ -126,6 +146,7 @@ async function attemptAllStrategies(
const result = await tryTokenRequest(tokenEndpoint, params);
if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
@@ -137,6 +158,7 @@ async function attemptAllStrategies(
const result = await tryTokenRequest(tokenEndpoint, params);
if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
@@ -148,6 +170,7 @@ async function attemptAllStrategies(
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
if (result.ok) {
logger.info('TOTP token exchange succeeded (Basic Auth header)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'Basic Auth header', error: result.error });
@@ -159,6 +182,7 @@ async function attemptAllStrategies(
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
if (result.ok) {
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
+10 -1
View File
@@ -20,6 +20,7 @@ interface CalendarDayViewProps {
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
onCreateAtTime: (date: Date, endDate?: Date) => void;
timeFormat?: "12h" | "24h";
isMobile?: boolean;
@@ -39,6 +40,7 @@ export function CalendarDayView({
onHoverEvent,
onHoverLeave,
onContextMenuEvent,
onContextMenuEmpty,
onCreateAtTime,
timeFormat = "24h",
isMobile,
@@ -144,7 +146,13 @@ export function CalendarDayView({
</div>
{(allDayEvents.length > 0 || dayTasks.length > 0) && (
<div className="px-4 py-2 border-b border-border">
<div
className="px-4 py-2 border-b border-border"
onContextMenu={onContextMenuEmpty ? (e) => {
if ((e.target as HTMLElement).closest("[data-calendar-event],button")) return;
onContextMenuEmpty(e, selectedDate, undefined, true);
} : undefined}
>
{allDayEvents.length > 0 && (
<>
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
@@ -240,6 +248,7 @@ export function CalendarDayView({
aria-label={formatHour(h)}
onClick={() => handleSlotClick(selectedDate, h)}
onDoubleClick={() => handleSlotDoubleClick(selectedDate, h)}
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, selectedDate, h, false) : undefined}
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
style={{ height: HOUR_HEIGHT }}
/>
@@ -24,6 +24,7 @@ interface CalendarMonthViewProps {
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
onCreateAtTime?: (date: Date) => void;
firstDayOfWeek?: number;
isMobile?: boolean;
@@ -39,6 +40,7 @@ export function CalendarMonthView({
onHoverEvent,
onHoverLeave,
onContextMenuEvent,
onContextMenuEmpty,
onCreateAtTime,
firstDayOfWeek = 1,
isMobile,
@@ -174,6 +176,7 @@ export function CalendarMonthView({
aria-label={fullDateLabel}
onClick={() => onSelectDate(day)}
onDoubleClick={() => onCreateAtTime?.(day)}
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
onDragOver={(e) => handleCellDragOver(e, key)}
onDragLeave={handleCellDragLeave}
onDrop={(e) => handleCellDrop(e, day)}
+8 -1
View File
@@ -23,6 +23,7 @@ interface CalendarWeekViewProps {
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
onCreateAtTime: (date: Date, endDate?: Date) => void;
firstDayOfWeek?: number;
timeFormat?: "12h" | "24h";
@@ -44,6 +45,7 @@ export function CalendarWeekView({
onHoverEvent,
onHoverLeave,
onContextMenuEvent,
onContextMenuEmpty,
onCreateAtTime,
firstDayOfWeek = 1,
timeFormat = "24h",
@@ -219,7 +221,11 @@ export function CalendarWeekView({
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
>
{weekDays.map((day) => (
<div key={format(day, "yyyy-MM-dd")} className="bg-background min-h-[28px]" />
<div
key={format(day, "yyyy-MM-dd")}
className="bg-background min-h-[28px]"
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
/>
))}
<div className="absolute inset-0 pointer-events-none">
@@ -378,6 +384,7 @@ export function CalendarWeekView({
aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`}
onClick={() => handleSlotClick(day, h)}
onDoubleClick={() => handleSlotDoubleClick(day, h)}
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined}
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
style={{ height: HOUR_HEIGHT }}
/>
@@ -0,0 +1,67 @@
"use client";
import { useTranslations } from "next-intl";
import {
ContextMenu,
ContextMenuItem,
ContextMenuSeparator,
} from "@/components/ui/context-menu";
import { Plus, CalendarDays, CheckSquare, Clock } from "lucide-react";
interface Position {
x: number;
y: number;
}
interface EmptySpaceContextMenuProps {
position: Position;
isOpen: boolean;
onClose: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
onNewEvent: () => void;
onNewAllDayEvent: () => void;
onNewTask?: () => void;
onGoToToday: () => void;
showAllDayOption?: boolean;
}
export function EmptySpaceContextMenu({
position,
isOpen,
onClose,
menuRef,
onNewEvent,
onNewAllDayEvent,
onNewTask,
onGoToToday,
showAllDayOption = true,
}: EmptySpaceContextMenuProps) {
const t = useTranslations("calendar");
const handle = (fn: () => void) => () => {
fn();
onClose();
};
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem icon={Plus} label={t("events.new_event")} onClick={handle(onNewEvent)} />
{showAllDayOption && (
<ContextMenuItem
icon={CalendarDays}
label={t("events.new_all_day_event")}
onClick={handle(onNewAllDayEvent)}
/>
)}
{onNewTask && (
<ContextMenuItem
icon={CheckSquare}
label={t("events.new_task")}
onClick={handle(onNewTask)}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem icon={Clock} label={t("events.go_to_today")} onClick={handle(onGoToToday)} />
</ContextMenu>
);
}
+3 -1
View File
@@ -35,6 +35,7 @@ interface EventModalProps {
calendars: Calendar[];
defaultDate?: Date;
defaultEndDate?: Date;
defaultAllDay?: boolean;
defaultCalendarId?: string;
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
@@ -114,6 +115,7 @@ export function EventModal({
calendars,
defaultDate,
defaultEndDate,
defaultAllDay,
defaultCalendarId,
onSave,
onDelete,
@@ -199,7 +201,7 @@ export function EventModal({
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd()));
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
const [allDay, setAllDay] = useState(event?.showWithoutTime || defaultAllDay || false);
const [calendarId, setCalendarId] = useState<string>(() => {
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId;
+1 -1
View File
@@ -335,7 +335,7 @@ export function NavigationRail({
);
})}
{/* Admin (Stalwart admins) hard nav because /admin lives outside the [locale] tree */}
{/* Admin (Stalwart admins) - hard nav because /admin lives outside the [locale] tree */}
{isStalwartAdmin && (
<a
href="/admin"
+4
View File
@@ -13,6 +13,9 @@ services:
- bulwark-settings:/app/data/settings
# Admin dashboard state: config, password hash, plugins, audit logs (ADMIN_DATA_DIR).
- bulwark-admin:/app/data/admin
# Anonymous telemetry: instance id, consent state, login HMACs (TELEMETRY_DATA_DIR).
# Persisting this preserves the admin's consent choice and stable instance id across upgrades.
- bulwark-telemetry:/app/data/telemetry
healthcheck:
test:
[
@@ -32,3 +35,4 @@ services:
volumes:
bulwark-settings:
bulwark-admin:
bulwark-telemetry:
+10 -6
View File
@@ -39,6 +39,7 @@ export interface UseKeyboardShortcutsOptions {
enabled?: boolean;
emails: Email[];
selectedEmailId?: string;
selectionCount?: number;
handlers: KeyboardShortcutHandlers;
}
@@ -58,6 +59,7 @@ export function useKeyboardShortcuts({
enabled = true,
emails,
selectedEmailId,
selectionCount = 0,
handlers,
}: UseKeyboardShortcutsOptions) {
const handlersRef = useRef(handlers);
@@ -90,6 +92,8 @@ export function useKeyboardShortcuts({
// Shortcuts that should NOT work with modifiers
if (hasModifier) return;
const hasBatchTarget = !!selectedEmailId || selectionCount > 0;
switch (key) {
// Navigation
case "j":
@@ -152,7 +156,7 @@ export function useKeyboardShortcuts({
break;
case "e":
if (selectedEmailId) {
if (hasBatchTarget) {
event.preventDefault();
h.onArchive?.();
}
@@ -161,28 +165,28 @@ export function useKeyboardShortcuts({
case "#":
case "delete":
case "backspace":
if (selectedEmailId && (key === "#" || key === "delete" || key === "backspace")) {
if (hasBatchTarget) {
event.preventDefault();
h.onDelete?.();
}
break;
case "u":
if (selectedEmailId) {
if (hasBatchTarget) {
event.preventDefault();
h.onMarkAsUnread?.();
}
break;
case "i":
if (selectedEmailId && event.shiftKey) {
if (hasBatchTarget && event.shiftKey) {
event.preventDefault();
h.onMarkAsRead?.();
}
break;
case "!":
if (selectedEmailId) {
if (hasBatchTarget) {
event.preventDefault();
h.onToggleSpam?.();
}
@@ -220,7 +224,7 @@ export function useKeyboardShortcuts({
break;
}
},
[selectedEmailId]
[selectedEmailId, selectionCount]
);
useEffect(() => {
+8
View File
@@ -51,6 +51,14 @@ configManager.load()
.then(() => {
console.info("Admin dashboard initialized");
})
.then(async () => {
// Anonymous telemetry - on by default. Admins can disable via the
// admin UI, the BULWARK_TELEMETRY env var, or by clearing the endpoint.
// See https://bulwarkmail.org/docs/legal/privacy/telemetry
const { startScheduler, markProcessStart } = await import("./lib/telemetry");
markProcessStart();
await startScheduler();
})
.catch((err) => {
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
});
+126 -15
View File
@@ -28,23 +28,11 @@ describe('extractTheme', () => {
});
it('rejects oversized theme', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'big-theme',
name: 'Big',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light'],
}));
// Make a large file > 1MB
zip.file('theme.css', 'x'.repeat(1024 * 1024 + 1));
// Manually create oversized File
const oversizedFile = new File([new ArrayBuffer(1024 * 1024 + 1)], 'big.zip');
// Theme size limit is 2 MB; create a file just past it.
const oversizedFile = new File([new ArrayBuffer(2 * 1024 * 1024 + 1)], 'big.zip');
const result = await extractTheme(oversizedFile);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Theme ZIP exceeds 1 MB size limit');
expect(result.errors).toContain('Theme ZIP exceeds 2 MB size limit');
});
it('rejects non-ZIP file', async () => {
@@ -139,6 +127,129 @@ describe('extractTheme', () => {
expect(result.valid).toBe(true);
expect(result.manifest!.id).toBe('nested-theme');
});
// ── Theme API v2 (advanced manifest) ──────────────────────────────
it('compiles a v2 manifest with tokens and no theme.css', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'tokens-only',
name: 'Tokens Only',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light', 'dark'],
apiVersion: 2,
tokens: {
light: { primary: '#1373d9', background: '#ffffff' },
dark: { primary: '#58c9ff', background: '#1a202c' },
},
}));
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(true);
expect(result.css).toContain('--color-primary: #1373d9');
expect(result.css).toContain('--color-primary: #58c9ff');
});
it('concatenates compiled tokens with author-supplied theme.css', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'tokens-plus-css',
name: 'Tokens + CSS',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light'],
apiVersion: 2,
tokens: { light: { primary: '#000' } },
}));
zip.file('theme.css', '@font-face { font-family: "X"; src: local("X"); }');
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(true);
expect(result.css).toContain('--color-primary: #000');
expect(result.css).toContain('@font-face');
});
it('extracts a skin.css when shipped with a v2 manifest', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'with-skin',
name: 'With Skin',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light'],
apiVersion: 2,
tokens: { light: { primary: '#000' } },
}));
zip.file('skin.css', '[data-tour="email-list"] { font-size: 13px; }');
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(true);
expect(result.skin).not.toBeNull();
expect(result.skin!).toContain('[data-tour="email-list"]');
});
it('strips dangerous patterns from skin.css', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'evil-skin',
name: 'Evil',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light'],
apiVersion: 2,
tokens: { light: { primary: '#000' } },
}));
zip.file('skin.css', '@import url("https://x.com/p.css"); button { background: javascript:alert(1); }');
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(true);
expect(result.skin).not.toBeNull();
expect(result.skin!).not.toContain('javascript:');
expect(result.skin!).not.toContain('@import');
expect(result.warnings.some((w) => w.toLowerCase().includes('skin'))).toBe(true);
});
it('ignores skin.css when manifest is not v2', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'v1-with-skin',
name: 'V1',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light'],
}));
zip.file('theme.css', ':root { --color-primary: #000; }');
zip.file('skin.css', 'body { display: none; }');
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(true);
expect(result.skin).toBeNull();
expect(result.warnings.some((w) => w.includes('skin.css ignored'))).toBe(true);
});
it('rejects a v2 manifest with invalid density', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'bad-density',
name: 'Bad',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light'],
density: 'gigantic',
tokens: { light: { primary: '#000' } },
}));
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes('density'))).toBe(true);
});
});
describe('extractPlugin', () => {
+181
View File
@@ -0,0 +1,181 @@
import { describe, it, expect } from 'vitest';
import { compileAdvancedTheme, isAdvancedManifest } from '../theme-compiler';
import type { ThemeManifest } from '../plugin-types';
const baseManifest = (overrides: Partial<ThemeManifest> = {}): ThemeManifest => ({
id: 't',
name: 'T',
version: '1.0.0',
author: 'tester',
description: '',
type: 'theme',
variants: ['light', 'dark'],
...overrides,
});
describe('isAdvancedManifest', () => {
it('returns false for plain v1 manifests', () => {
expect(isAdvancedManifest(baseManifest())).toBe(false);
});
it.each([
{ apiVersion: 2 as const },
{ tokens: { light: { primary: '#000' } } },
{ extends: 'builtin-nord' },
{ derive: true },
{ density: 'compact' as const },
{ radii: { md: '6px' } },
{ typography: { fontSans: 'Inter' } },
])('returns true when manifest has %p', (extra) => {
expect(isAdvancedManifest(baseManifest(extra))).toBe(true);
});
});
describe('compileAdvancedTheme', () => {
it('emits :root and .dark blocks from token sets', () => {
const { css, errors } = compileAdvancedTheme(
baseManifest({
tokens: {
light: { primary: '#1373d9', background: '#ffffff' },
dark: { primary: '#58c9ff', background: '#1a202c' },
},
}),
);
expect(errors).toHaveLength(0);
expect(css).toMatch(/:root\s*\{[\s\S]*--color-primary:\s*#1373d9/);
expect(css).toMatch(/\.dark\s*\{[\s\S]*--color-primary:\s*#58c9ff/);
});
it('omits .dark block for light-only themes', () => {
const { css } = compileAdvancedTheme(
baseManifest({
variants: ['light'],
tokens: { light: { primary: '#000' }, dark: { primary: '#fff' } },
}),
);
expect(css).toContain(':root');
expect(css).not.toContain('.dark');
});
it('emits common tokens into both :root and .dark', () => {
const { css } = compileAdvancedTheme(
baseManifest({
tokens: {
common: { ring: '#abc' },
light: { background: '#fff' },
dark: { background: '#000' },
},
}),
);
const rootMatch = css.match(/:root\s*\{([\s\S]*?)\}/)?.[1] ?? '';
const darkMatch = css.match(/\.dark\s*\{([\s\S]*?)\}/)?.[1] ?? '';
expect(rootMatch).toContain('--color-ring: #abc');
expect(darkMatch).toContain('--color-ring: #abc');
});
it('derives a contrasting *-foreground when derive: true', () => {
const { css } = compileAdvancedTheme(
baseManifest({
derive: true,
tokens: { light: { primary: '#000000' }, dark: { primary: '#ffffff' } },
}),
);
expect(css).toMatch(/:root\s*\{[\s\S]*--color-primary-foreground:\s*#ffffff/);
expect(css).toMatch(/\.dark\s*\{[\s\S]*--color-primary-foreground:\s*#0f172a/);
});
it('respects an author-provided *-foreground over derive', () => {
const { css } = compileAdvancedTheme(
baseManifest({
derive: true,
tokens: {
light: { primary: '#000000', 'primary-foreground': '#ff00ff' },
},
}),
);
expect(css).toContain('--color-primary-foreground: #ff00ff');
});
it('emits radii, typography, and density vars', () => {
const { css } = compileAdvancedTheme(
baseManifest({
tokens: { light: { primary: '#000' } },
radii: { sm: '2px', md: '6px', full: '9999px' },
typography: { fontSans: 'Inter, sans-serif', baseFontSize: '15px' },
density: 'compact',
}),
);
expect(css).toContain('--radius-sm: 2px');
expect(css).toContain('--radius-full: 9999px');
expect(css).toContain('--font-sans: Inter, sans-serif');
expect(css).toContain('--font-size-base: 15px');
expect(css).toContain('--density-row-height: 28px');
});
it('drops tokens with unsafe values and warns', () => {
const { css, warnings } = compileAdvancedTheme(
baseManifest({
tokens: {
light: {
primary: '#000',
evil: 'red; background: url("https://x.com/track.png")',
},
},
}),
);
expect(css).toContain('--color-primary: #000');
expect(css).not.toContain('https://x.com');
expect(warnings.some((w) => w.includes('evil'))).toBe(true);
});
it('drops tokens with unsafe keys and warns', () => {
const { css, warnings } = compileAdvancedTheme(
baseManifest({
tokens: { light: { 'primary }; body { background: red': '#fff', primary: '#000' } },
}),
);
expect(css).toContain('--color-primary: #000');
expect(css).not.toContain('body { background');
expect(warnings.some((w) => w.includes('invalid key'))).toBe(true);
});
it('errors when no structured fields are present', () => {
const { errors } = compileAdvancedTheme(baseManifest());
expect(errors.length).toBeGreaterThan(0);
});
it('inlines parent CSS when extends + resolver supplied', () => {
const { css, warnings } = compileAdvancedTheme(
baseManifest({
extends: 'parent-theme',
tokens: { light: { primary: '#fff' } },
}),
{ resolveExtends: (id) => (id === 'parent-theme' ? ':root { --x: 1; }' : null) },
);
expect(css).toContain('--x: 1');
expect(css).toContain('--color-primary: #fff');
expect(warnings).toHaveLength(0);
});
it('warns when extends parent cannot be resolved', () => {
const { warnings } = compileAdvancedTheme(
baseManifest({
extends: 'missing',
tokens: { light: { primary: '#fff' } },
}),
{ resolveExtends: () => null },
);
expect(warnings.some((w) => w.includes('missing'))).toBe(true);
});
it('appends user-supplied CSS after compiled output', () => {
const { css } = compileAdvancedTheme(
baseManifest({ tokens: { light: { primary: '#fff' } } }),
{ userCSS: '@font-face { font-family: "X"; src: local("X"); }' },
);
const compiledIdx = css.indexOf('--color-primary');
const userIdx = css.indexOf('@font-face');
expect(compiledIdx).toBeGreaterThanOrEqual(0);
expect(userIdx).toBeGreaterThan(compiledIdx);
});
});
+61
View File
@@ -1,9 +1,12 @@
import { describe, it, expect, afterEach } from 'vitest';
import {
sanitizeThemeCSS,
sanitizeSkinCSS,
validateThemeSelectors,
injectThemeCSS,
removeThemeCSS,
injectThemeSkinCSS,
removeThemeSkinCSS,
validateThemeCSSSafety,
} from '../theme-loader';
@@ -144,6 +147,64 @@ describe('theme-loader', () => {
});
});
describe('injectThemeSkinCSS / removeThemeSkinCSS', () => {
afterEach(() => {
removeThemeSkinCSS();
});
it('injects a separate <style> tag from the colour block', () => {
injectThemeCSS(':root { --color-primary: red; }');
injectThemeSkinCSS('button { padding: 4px; }', 'thunderbird');
expect(document.getElementById('active-theme')).not.toBeNull();
expect(document.getElementById('active-theme-skin')).not.toBeNull();
expect(document.getElementById('active-theme-skin')?.textContent).toContain('button');
});
it('sets data-theme-skin on body to the active theme id', () => {
injectThemeSkinCSS('button { padding: 4px; }', 'my-theme');
expect(document.body.getAttribute('data-theme-skin')).toBe('my-theme');
});
it('removes the skin tag and body attribute on remove', () => {
injectThemeSkinCSS('button { padding: 4px; }', 'my-theme');
removeThemeSkinCSS();
expect(document.getElementById('active-theme-skin')).toBeNull();
expect(document.body.getAttribute('data-theme-skin')).toBeNull();
});
it('does not throw when removing without a prior inject', () => {
expect(() => removeThemeSkinCSS()).not.toThrow();
});
});
describe('sanitizeSkinCSS', () => {
it('preserves component-level selectors', () => {
const css = '[data-tour="email-list"] { font-size: 13px; } button { padding: 4px; }';
const { css: cleaned, warnings } = sanitizeSkinCSS(css);
expect(cleaned).toBe(css);
expect(warnings).toHaveLength(0);
});
it('strips dangerous patterns', () => {
const { css: cleaned, warnings } = sanitizeSkinCSS(
'@import url("https://x.com/p.css"); button { background: javascript:alert(1); }',
);
expect(cleaned).not.toContain('@import');
expect(cleaned).not.toContain('javascript:');
expect(warnings.length).toBeGreaterThanOrEqual(2);
});
it('strips @charset and @namespace', () => {
const { css: cleaned, warnings } = sanitizeSkinCSS(
'@charset "utf-8"; @namespace url(http://www.w3.org/1999/xhtml); button { padding: 4px; }',
);
expect(cleaned).not.toContain('@charset');
expect(cleaned).not.toContain('@namespace');
expect(cleaned).toContain('button');
expect(warnings.length).toBeGreaterThanOrEqual(2);
});
});
describe('validateThemeCSSSafety', () => {
it('accepts valid theme CSS', () => {
const css = ':root { --color-primary: #3b82f6; --color-background: #fff; }';
+8 -2
View File
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
import { CONFIG_ENV_MAP, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
@@ -35,6 +35,7 @@ class ConfigManager {
this.policyCache = {
...DEFAULT_POLICY,
...policy,
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
};
} else {
@@ -143,7 +144,12 @@ class ConfigManager {
* Update the settings policy. Writes to disk.
*/
async setPolicy(policy: SettingsPolicy): Promise<void> {
this.policyCache = { ...DEFAULT_POLICY, ...policy };
this.policyCache = {
...DEFAULT_POLICY,
...policy,
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
};
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
}
+2 -2
View File
@@ -5,7 +5,7 @@
* domains in the host CSP.
*
* Origins are validated at install time and re-validated here as defense in
* depth any malformed value is dropped so a corrupted registry can never
* depth - any malformed value is dropped so a corrupted registry can never
* inject arbitrary CSP fragments.
*/
@@ -65,7 +65,7 @@ const CACHE_TTL_MS = 5_000;
* the server-side registry, deduped and validated.
*
* Returns an empty array on any failure (missing file, parse error, …) so
* a broken registry only ever shrinks the CSP never widens it.
* a broken registry only ever shrinks the CSP - never widens it.
*/
export async function getEnabledPluginFrameOrigins(): Promise<string[]> {
const now = Date.now();
+2
View File
@@ -39,6 +39,7 @@ export interface FeatureGates {
folderIconsEnabled: boolean;
hoverActionsConfigEnabled: boolean;
filesEnabled: boolean;
contactsEnabled: boolean;
}
export const DEFAULT_FEATURE_GATES: FeatureGates = {
@@ -58,6 +59,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
folderIconsEnabled: true,
hoverActionsConfigEnabled: true,
filesEnabled: true,
contactsEnabled: true,
};
export interface ThemePolicy {
+14 -1
View File
@@ -7,8 +7,21 @@ export interface OAuthMetadata {
}
const CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_MAX_ENTRIES = 64;
const metadataCache = new Map<string, { metadata: OAuthMetadata; expiresAt: number }>();
function rememberMetadata(serverUrl: string, metadata: OAuthMetadata): void {
// Bound the cache so callers that can supply arbitrary serverUrl values
// (e.g. unauthenticated routes that fall back to user input) cannot
// exhaust memory. Map preserves insertion order, so the oldest entry is
// always the first one yielded by keys().
if (metadataCache.size >= CACHE_MAX_ENTRIES) {
const oldest = metadataCache.keys().next().value;
if (oldest !== undefined) metadataCache.delete(oldest);
}
metadataCache.set(serverUrl, { metadata, expiresAt: Date.now() + CACHE_TTL_MS });
}
export async function discoverOAuth(serverUrl: string): Promise<OAuthMetadata | null> {
const cached = metadataCache.get(serverUrl);
if (cached && cached.expiresAt > Date.now()) return cached.metadata;
@@ -38,7 +51,7 @@ export async function discoverOAuth(serverUrl: string): Promise<OAuthMetadata |
revocation_endpoint: data.revocation_endpoint,
end_session_endpoint: data.end_session_endpoint,
};
metadataCache.set(serverUrl, { metadata, expiresAt: Date.now() + CACHE_TTL_MS });
rememberMetadata(serverUrl, metadata);
return metadata;
}
errors.push(`${url} response missing required endpoints`);
+12
View File
@@ -365,6 +365,18 @@ export const themeHooks = {
onThemeChange: new HookBus(),
onCustomThemeChange: new HookBus(),
onLocaleChange: new HookBus(),
/**
* Transform hook fired immediately before a theme's compiled CSS is
* injected into the document.
*
* handler(css: string, ctx: { themeId: string | null; variant: 'light' | 'dark' }): string | undefined
*
* Return a new CSS string to override what gets injected, or `undefined`
* to pass through unchanged. Use this to inject extra `@font-face` rules,
* patch a third-party theme's variables for accessibility, or implement
* site-wide design-token overrides.
*/
onThemeBeforeApply: new HookBus(),
};
// §7.15 Toast Hooks
+18 -1
View File
@@ -1,9 +1,11 @@
// IndexedDB storage for plugin/theme binary blobs (JS bundles, CSS, previews)
const DB_NAME = 'bulwark-plugins';
const DB_VERSION = 1;
// Bumped to 2 to add the theme-skin store; existing stores are preserved.
const DB_VERSION = 2;
const STORE_PLUGINS = 'plugin-code';
const STORE_THEMES = 'theme-css';
const STORE_THEME_SKINS = 'theme-skin';
const STORE_PREVIEWS = 'previews';
function openDB(): Promise<IDBDatabase> {
@@ -18,6 +20,9 @@ function openDB(): Promise<IDBDatabase> {
if (!db.objectStoreNames.contains(STORE_THEMES)) {
db.createObjectStore(STORE_THEMES);
}
if (!db.objectStoreNames.contains(STORE_THEME_SKINS)) {
db.createObjectStore(STORE_THEME_SKINS);
}
if (!db.objectStoreNames.contains(STORE_PREVIEWS)) {
db.createObjectStore(STORE_PREVIEWS);
}
@@ -83,6 +88,18 @@ export const pluginStorage = {
await deleteItem(STORE_THEMES, themeId);
},
// Theme skin CSS - separate store so it can be present/absent independently
// of the colour-token CSS (e.g. some v2 themes ship colours only).
async saveThemeSkin(themeId: string, skin: string): Promise<void> {
await putItem(STORE_THEME_SKINS, themeId, skin);
},
async getThemeSkin(themeId: string): Promise<string | null> {
return getItem<string>(STORE_THEME_SKINS, themeId);
},
async deleteThemeSkin(themeId: string): Promise<void> {
await deleteItem(STORE_THEME_SKINS, themeId);
},
// Preview images (stored as data URIs)
async savePreview(id: string, dataUri: string): Promise<void> {
await putItem(STORE_PREVIEWS, id, dataUri);
+76 -2
View File
@@ -11,6 +11,41 @@ export type ThemeVariant = 'light' | 'dark';
// ─── Manifests ───────────────────────────────────────────────
/**
* Advanced theme fields ("Theme API v2"). All optional and additive - a
* legacy theme that ships only `:root`/`.dark` CSS continues to work.
*
* When `apiVersion >= 2` (or any of `tokens`/`extends`/`derive`/`density`/
* `radii`/`typography` is present), the theme compiler runs at install time
* and produces a single CSS string from the structured fields, optionally
* concatenated with a hand-written `theme.css` for fine-grained overrides.
*/
export interface ThemeTokenSet {
/** Tokens applied regardless of variant (emitted into `:root`). */
common?: Record<string, string>;
/** Tokens applied in light mode (emitted into `:root`). */
light?: Record<string, string>;
/** Tokens applied in dark mode (emitted into `.dark`). */
dark?: Record<string, string>;
}
export type ThemeDensity = 'compact' | 'normal' | 'touch';
export interface ThemeRadii {
sm?: string;
md?: string;
lg?: string;
xl?: string;
full?: string;
}
export interface ThemeTypography {
fontSans?: string;
fontMono?: string;
fontDisplay?: string;
baseFontSize?: string;
}
export interface ThemeManifest {
id: string;
name: string;
@@ -21,6 +56,22 @@ export interface ThemeManifest {
preview?: string;
variants: ThemeVariant[];
minAppVersion?: string;
// ─── Advanced (Theme API v2) ─────────────────────────────────
/** Theme API version. Defaults to 1 (raw-CSS only). */
apiVersion?: 1 | 2;
/** Inherit tokens/CSS from another installed (or built-in) theme by id. */
extends?: string;
/** Structured colour tokens - compiled into CSS at install time. */
tokens?: ThemeTokenSet;
/** When true, missing standard tokens are derived (e.g. *-foreground from contrast). */
derive?: boolean;
/** Default UI density preset (compact / normal / touch). */
density?: ThemeDensity;
/** Border-radius scale, emitted as `--radius-*` vars. */
radii?: ThemeRadii;
/** Font stacks + base size, emitted as `--font-*` vars. */
typography?: ThemeTypography;
}
export interface PluginManifest {
@@ -70,12 +121,29 @@ export interface InstalledTheme {
author: string;
description: string;
preview?: string; // data: URI or blob URL
css: string; // raw CSS text
css: string; // compiled CSS text - what gets injected
/**
* Optional "skin" CSS shipped by Theme API v2 themes that need to restyle
* actual UI components (toolbars, lists, buttons, etc.) - not just colour
* tokens. Injected into a separate `<style>` tag so it can be stripped
* cleanly when the theme is deactivated. Stored in IndexedDB with the same
* lifecycle as `css` to keep localStorage small.
*/
skin?: string;
variants: ThemeVariant[];
enabled: boolean;
builtIn: boolean;
managed?: boolean;
forceEnabled?: boolean;
// ─── Advanced (Theme API v2) ─ carried over from the manifest ─
apiVersion?: 1 | 2;
extends?: string;
tokens?: ThemeTokenSet;
derive?: boolean;
density?: ThemeDensity;
radii?: ThemeRadii;
typography?: ThemeTypography;
}
export interface InstalledPlugin {
@@ -527,7 +595,13 @@ export const IMPLICIT_PERMISSIONS: Permission[] = ['ui:observe', 'app:lifecycle'
// ─── Validation ──────────────────────────────────────────────
export const MAX_PLUGIN_SIZE = 5 * 1024 * 1024; // 5 MB
export const MAX_THEME_SIZE = 1 * 1024 * 1024; // 1 MB
export const MAX_THEME_SIZE = 2 * 1024 * 1024; // 2 MB (was 1 MB; v2 themes may ship a skin.css)
/**
* Maximum size of an individual `skin.css` payload after extraction.
* Skins are component-level CSS, not images - anything bigger than this is
* almost certainly bundling assets the validator will refuse anyway.
*/
export const MAX_THEME_SKIN_BYTES = 256 * 1024; // 256 KB
export const ALLOWED_PLUGIN_FILES = new Set([
'.js', '.mjs', '.css', '.json', '.png', '.svg', '.woff2', '.jpg', '.jpeg', '.webp',
+91 -14
View File
@@ -8,9 +8,11 @@ import {
ALL_PERMISSIONS,
MAX_PLUGIN_SIZE,
MAX_THEME_SIZE,
MAX_THEME_SKIN_BYTES,
ALLOWED_PLUGIN_FILES,
} from './plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from './theme-loader';
import { sanitizeThemeCSS, sanitizeSkinCSS, validateThemeCSSSafety } from './theme-loader';
import { compileAdvancedTheme, isAdvancedManifest } from './theme-compiler';
export interface ValidationResult {
valid: boolean;
@@ -21,6 +23,11 @@ export interface ValidationResult {
export interface ThemeExtractionResult extends ValidationResult {
manifest: ThemeManifest | null;
css: string;
/**
* Optional skin CSS - component-level overrides extracted from `skin.css`.
* Only populated for Theme API v2 manifests; v1 themes ignore the file.
*/
skin: string | null;
preview: string | null; // data URI
}
@@ -63,6 +70,29 @@ function validateThemeManifest(manifest: Record<string, unknown>): { result: The
if (!valid) errors.push('Variants must be "light" or "dark"');
}
// ── Theme API v2 fields (all optional) ──
if (manifest.apiVersion !== undefined && manifest.apiVersion !== 1 && manifest.apiVersion !== 2) {
errors.push('"apiVersion" must be 1 or 2 if present');
}
if (manifest.extends !== undefined && typeof manifest.extends !== 'string') {
errors.push('"extends" must be a string (the parent theme id)');
}
if (manifest.tokens !== undefined && (typeof manifest.tokens !== 'object' || manifest.tokens === null)) {
errors.push('"tokens" must be an object with optional "common"/"light"/"dark" maps');
}
if (manifest.density !== undefined && !['compact', 'normal', 'touch'].includes(manifest.density as string)) {
errors.push('"density" must be "compact", "normal", or "touch"');
}
if (manifest.derive !== undefined && typeof manifest.derive !== 'boolean') {
errors.push('"derive" must be a boolean');
}
if (manifest.radii !== undefined && (typeof manifest.radii !== 'object' || manifest.radii === null)) {
errors.push('"radii" must be an object');
}
if (manifest.typography !== undefined && (typeof manifest.typography !== 'object' || manifest.typography === null)) {
errors.push('"typography" must be an object');
}
if (errors.length > 0) return { result: null, errors };
return {
@@ -157,7 +187,12 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
// Size check
if (file.size > MAX_THEME_SIZE) {
return { valid: false, errors: ['Theme ZIP exceeds 1 MB size limit'], warnings: [], manifest: null, css: '', preview: null };
return {
valid: false,
errors: [`Theme ZIP exceeds ${Math.round(MAX_THEME_SIZE / (1024 * 1024))} MB size limit`],
warnings: [],
manifest: null, css: '', skin: null, preview: null,
};
}
let zip: JSZip;
@@ -165,7 +200,7 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
const buffer = await file.arrayBuffer();
zip = await JSZip.loadAsync(buffer);
} catch {
return { valid: false, errors: ['Invalid ZIP file'], warnings: [], manifest: null, css: '', preview: null };
return { valid: false, errors: ['Invalid ZIP file'], warnings: [], manifest: null, css: '', skin: null, preview: null };
}
const root = findZipRoot(zip);
@@ -173,7 +208,7 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
// Read manifest
const manifestFile = zip.file(root + 'manifest.json');
if (!manifestFile) {
return { valid: false, errors: ['Missing manifest.json'], warnings: [], manifest: null, css: '', preview: null };
return { valid: false, errors: ['Missing manifest.json'], warnings: [], manifest: null, css: '', skin: null, preview: null };
}
let manifestData: Record<string, unknown>;
@@ -181,28 +216,49 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
const raw = await manifestFile.async('string');
manifestData = JSON.parse(raw);
} catch {
return { valid: false, errors: ['Invalid manifest.json (not valid JSON)'], warnings: [], manifest: null, css: '', preview: null };
return { valid: false, errors: ['Invalid manifest.json (not valid JSON)'], warnings: [], manifest: null, css: '', skin: null, preview: null };
}
const { result: manifest, errors: manifestErrors } = validateThemeManifest(manifestData);
errors.push(...manifestErrors);
if (!manifest) {
return { valid: false, errors, warnings, manifest: null, css: '', preview: null };
return { valid: false, errors, warnings, manifest: null, css: '', skin: null, preview: null };
}
// Read theme.css
// Read theme.css - required for v1 themes, optional when the manifest
// declares Theme API v2 fields (tokens/extends/derive/density/radii/typography),
// since the compiler can produce CSS purely from the manifest.
const cssFile = zip.file(root + 'theme.css');
if (!cssFile) {
const isAdvanced = isAdvancedManifest(manifest);
let userCSS = '';
if (cssFile) {
userCSS = await cssFile.async('string');
const safety = validateThemeCSSSafety(userCSS);
if (!safety.valid) {
// Sanitize instead of rejecting
const sanitized = sanitizeThemeCSS(userCSS);
userCSS = sanitized.css;
warnings.push(...sanitized.warnings);
}
} else if (!isAdvanced) {
errors.push('Missing theme.css');
return { valid: false, errors, warnings, manifest, css: '', preview: null };
return { valid: false, errors, warnings, manifest, css: '', skin: null, preview: null };
}
let rawCSS = await cssFile.async('string');
// Compile advanced tokens into CSS (for v2 manifests). The compiled output
// is concatenated with any user-supplied theme.css for fine-grained overrides.
let rawCSS = userCSS;
if (isAdvanced) {
const compiled = compileAdvancedTheme(manifest, { userCSS });
if (compiled.errors.length > 0) {
errors.push(...compiled.errors);
return { valid: false, errors, warnings, manifest, css: '', skin: null, preview: null };
}
warnings.push(...compiled.warnings);
rawCSS = compiled.css;
// Validate CSS safety
const safety = validateThemeCSSSafety(rawCSS);
if (!safety.valid) {
// Sanitize instead of rejecting
// Run sanitizer over the final compiled output as a defence-in-depth check.
const sanitized = sanitizeThemeCSS(rawCSS);
rawCSS = sanitized.css;
warnings.push(...sanitized.warnings);
@@ -222,12 +278,33 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
}
}
// Read skin.css if present (Theme API v2 only). Skins target real
// component selectors and bypass the strict :root/.dark selector check -
// they still go through the dangerous-pattern sanitizer.
let skin: string | null = null;
const skinFile = zip.file(root + 'skin.css');
if (skinFile) {
if (!isAdvanced) {
warnings.push('skin.css ignored - only Theme API v2 manifests can ship a skin');
} else {
const rawSkin = await skinFile.async('string');
if (rawSkin.length > MAX_THEME_SKIN_BYTES) {
warnings.push(`skin.css exceeds ${Math.round(MAX_THEME_SKIN_BYTES / 1024)} KB and was dropped`);
} else {
const sanitized = sanitizeSkinCSS(rawSkin);
skin = sanitized.css;
warnings.push(...sanitized.warnings);
}
}
}
return {
valid: errors.length === 0,
errors,
warnings,
manifest,
css: rawCSS,
skin,
preview,
};
}
+115
View File
@@ -0,0 +1,115 @@
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
// Block telemetry endpoints from pointing at internal/loopback addresses.
// Required because the admin UI lets an authenticated admin set an arbitrary
// URL; without this an attacker with a session (or a hostile admin in a
// multi-tenant deploy) could redirect heartbeats at internal hosts.
//
// Set BULWARK_TELEMETRY_ALLOW_PRIVATE=1 to bypass - useful only for local
// dev where the collector is on the loopback.
const PRIVATE_V4: RegExp[] = [
/^0\./, // 0.0.0.0/8
/^10\./, // 10.0.0.0/8
/^127\./, // loopback
/^169\.254\./, // link-local + cloud metadata
/^172\.(1[6-9]|2\d|3[0-1])\./, // 172.16.0.0/12
/^192\.168\./, // 192.168.0.0/16
/^192\.0\.0\./, // IETF reserved
/^198\.(1[8-9])\./, // benchmarking 198.18.0.0/15
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // 100.64.0.0/10 CGNAT
/^22[4-9]\./, // 224.0.0.0/4 multicast
/^23\d\./,
/^2[4-5]\d\./, // 240.0.0.0/4 reserved
];
function isPrivateV4(ip: string): boolean {
return PRIVATE_V4.some((re) => re.test(ip));
}
function isPrivateV6(ip: string): boolean {
const lower = ip.toLowerCase();
if (lower === '::1' || lower === '::') return true;
if (/^fe[89ab][0-9a-f]:/.test(lower)) return true; // fe80::/10 link-local
if (/^f[cd][0-9a-f]{2}:/.test(lower)) return true; // fc00::/7 ULA
const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (mapped) return isPrivateV4(mapped[1]);
return false;
}
export function isPrivateAddress(ip: string): boolean {
const family = isIP(ip);
if (family === 4) return isPrivateV4(ip);
if (family === 6) return isPrivateV6(ip);
return false;
}
const BAD_HOSTS = new Set([
'localhost',
'localhost.localdomain',
'ip6-localhost',
'ip6-loopback',
]);
function bypassEnabled(): boolean {
return process.env.BULWARK_TELEMETRY_ALLOW_PRIVATE === '1';
}
export type EndpointCheck = { ok: true } | { ok: false; reason: string };
// Sync URL/host shape check. Catches the obvious cases without DNS.
export function validateEndpointUrl(raw: string): EndpointCheck {
let url: URL;
try {
url = new URL(raw);
} catch {
return { ok: false, reason: 'invalid URL' };
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { ok: false, reason: 'must be http(s)://' };
}
if (bypassEnabled()) return { ok: true };
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (!host) return { ok: false, reason: 'host required' };
if (BAD_HOSTS.has(host)) {
return { ok: false, reason: 'localhost endpoints are not allowed' };
}
if (host.endsWith('.local') || host.endsWith('.internal') || host.endsWith('.localhost')) {
return { ok: false, reason: 'private TLDs are not allowed' };
}
if (isIP(host) && isPrivateAddress(host)) {
return { ok: false, reason: 'private/loopback IP is not allowed' };
}
return { ok: true };
}
// Async check that additionally resolves DNS hostnames. Use this on
// set-endpoint AND immediately before fetch to defeat DNS-rebinding tricks
// where a hostname resolves to a public IP at validation time and a private
// one at fetch time.
export async function resolveEndpointAllowed(raw: string): Promise<EndpointCheck> {
const initial = validateEndpointUrl(raw);
if (!initial.ok) return initial;
if (bypassEnabled()) return { ok: true };
const host = new URL(raw).hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (isIP(host)) return { ok: true };
try {
const addrs = await lookup(host, { all: true });
for (const a of addrs) {
if (isPrivateAddress(a.address)) {
return { ok: false, reason: `host ${host} resolves to private address ${a.address}` };
}
}
return { ok: true };
} catch {
// Don't block on transient DNS failures - fetch will fail loudly anyway,
// and we don't want to lock admins out of their config when the resolver
// is flaky. The literal-IP check above already covers the direct-attack
// case.
return { ok: true };
}
}
+15
View File
@@ -0,0 +1,15 @@
export { startScheduler, stopScheduler, reschedule, sendOnce } from './sender';
export { buildPayload, markProcessStart } from './payload';
export {
loadState, saveState, getInstanceId, effectiveConsent,
} from './state';
export { recordLogin, getLoginCounts } from './login-tracker';
export {
validateEndpointUrl, resolveEndpointAllowed, isPrivateAddress,
} from './endpoint-guard';
export type { EndpointCheck } from './endpoint-guard';
export type {
TelemetryPayload, TelemetryStateFile, ConsentState,
Platform, OsFamily, CountBucket, TelemetryFeatures,
} from './types';
export { DEFAULT_ENDPOINT } from './types';
+125
View File
@@ -0,0 +1,125 @@
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { createHmac } from 'node:crypto';
import { logger } from '@/lib/logger';
import { getInstanceId } from './state';
// We never store usernames or server URLs in the clear. Each login is
// recorded as HMAC-SHA256(username + '@' + serverUrl, instance_id), so the
// file on disk cannot be cross-correlated with any other instance and is
// not PII even if leaked.
interface LoginRecord {
id: string;
lastLoginAt: string;
}
interface LoginsFile {
records: LoginRecord[];
}
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
const RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
let cache: LoginsFile | null = null;
function getDir(): string {
return process.env.TELEMETRY_DATA_DIR || path.join(process.cwd(), 'data', 'telemetry');
}
function loginsPath(): string {
return path.join(getDir(), 'logins.json');
}
async function ensureDir(): Promise<void> {
const dir = getDir();
if (!existsSync(dir)) await mkdir(dir, { recursive: true });
}
async function loadFile(): Promise<LoginsFile> {
if (cache) return cache;
try {
const raw = await readFile(loginsPath(), 'utf8');
const parsed = JSON.parse(raw) as Partial<LoginsFile>;
cache = Array.isArray(parsed?.records) ? { records: parsed.records as LoginRecord[] } : { records: [] };
} catch {
cache = { records: [] };
}
return cache;
}
async function saveFile(file: LoginsFile): Promise<void> {
await ensureDir();
cache = file;
const tmp = loginsPath() + '.tmp';
await writeFile(tmp, JSON.stringify(file), 'utf8');
await rename(tmp, loginsPath());
}
function normalizeServer(serverUrl: string): string {
return serverUrl.trim().replace(/\/+$/, '').toLowerCase();
}
async function hashIdentity(username: string, serverUrl: string): Promise<string> {
const instanceId = await getInstanceId();
const subject = `${username.trim().toLowerCase()}@${normalizeServer(serverUrl)}`;
return createHmac('sha256', instanceId).update(subject).digest('hex').slice(0, 32);
}
/**
* Record a successful login. Best-effort; never throws. Updates the
* existing record's timestamp if the same identity has logged in before,
* otherwise appends a new record. Records older than the retention window
* are pruned on every write.
*/
export async function recordLogin(username: string, serverUrl: string): Promise<void> {
if (!username || !serverUrl) return;
try {
const id = await hashIdentity(username, serverUrl);
const file = await loadFile();
const now = new Date().toISOString();
const cutoff = Date.now() - RETENTION_MS;
const next: LoginRecord[] = [];
let updated = false;
for (const rec of file.records) {
const ts = new Date(rec.lastLoginAt).getTime();
if (Number.isNaN(ts) || ts < cutoff) continue;
if (rec.id === id) {
next.push({ id, lastLoginAt: now });
updated = true;
} else {
next.push(rec);
}
}
if (!updated) next.push({ id, lastLoginAt: now });
await saveFile({ records: next });
} catch (err) {
logger.debug?.('telemetry: recordLogin failed', {
error: err instanceof Error ? err.message : String(err),
});
}
}
/**
* Total distinct accounts seen in the 90-day retention window, plus those
* with a login in the last 7 days.
*/
export async function getLoginCounts(): Promise<{ total: number; active7d: number }> {
try {
const file = await loadFile();
const cutoff = Date.now() - RETENTION_MS;
const sevenAgo = Date.now() - SEVEN_DAYS_MS;
let total = 0;
let active7d = 0;
for (const rec of file.records) {
const ts = new Date(rec.lastLoginAt).getTime();
if (Number.isNaN(ts) || ts < cutoff) continue;
total++;
if (ts >= sevenAgo) active7d++;
}
return { total, active7d };
} catch {
return { total: 0, active7d: 0 };
}
}
+173
View File
@@ -0,0 +1,173 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger';
import { resolveEndpointAllowed } from './endpoint-guard';
import { getInstanceId } from './state';
import { getLoginCounts } from './login-tracker';
import type {
TelemetryPayload,
TelemetryFeatures,
Platform,
OsFamily,
CountBucket,
} from './types';
let processStartedAt = Date.now();
export function markProcessStart(): void {
processStartedAt = Date.now();
}
function readPackage(): { version: string; build: string | null } {
try {
const pkg = JSON.parse(
readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'),
) as { version?: string };
return { version: pkg.version ?? '0.0.0', build: process.env.BULWARK_BUILD ?? 'release' };
} catch {
return { version: '0.0.0', build: null };
}
}
function detectPlatform(): Platform {
if (process.env.KUBERNETES_SERVICE_HOST) return 'k8s';
// /.dockerenv is the standard Docker container marker.
try {
readFileSync('/.dockerenv');
return 'docker';
} catch { /* not in docker */ }
return 'bare';
}
function detectOs(): OsFamily {
switch (process.platform) {
case 'linux': return 'linux';
case 'darwin': return 'darwin';
case 'win32': return 'windows';
default: return 'unknown';
}
}
export function bucketCount(n: number): CountBucket {
if (n <= 0) return '0';
if (n === 1) return '1';
if (n <= 5) return '2-5';
if (n <= 10) return '6-10';
if (n <= 50) return '11-50';
if (n <= 200) return '51-200';
return '201+';
}
async function readFeatures(): Promise<TelemetryFeatures> {
await configManager.ensureLoaded();
const gates = configManager.getPolicy().features;
const cfg = configManager.getAll();
return {
// Booleans only. We read whether a feature is enabled - never any
// config value beyond a presence check.
calendar: gates.calendarTasksEnabled === true,
contacts: gates.contactsEnabled === true,
files: gates.filesEnabled === true,
extensions: gates.pluginsEnabled === true,
oauth_enabled: cfg['oauthEnabled'] === true,
smime_enabled: gates.smimeEnabled === true,
};
}
const STALWART_VERSION_TTL_MS = 24 * 60 * 60 * 1000;
let stalwartVersionCache: { version: string | null; fetchedAt: number } | null = null;
// Stalwart returns the version in the Server response header
// (e.g. "Stalwart Mail Server v0.16.0"). The /.well-known/jmap endpoint
// requires auth, but the header is on the 401 response too, so an
// unauthenticated GET is enough. Cached for a day to avoid hammering
// the JMAP server on every payload preview.
async function detectStalwartVersion(): Promise<string | null> {
if (process.env.STALWART_VERSION) return process.env.STALWART_VERSION;
if (stalwartVersionCache &&
Date.now() - stalwartVersionCache.fetchedAt < STALWART_VERSION_TTL_MS) {
return stalwartVersionCache.version;
}
await configManager.ensureLoaded();
const serverUrl = configManager.get<string>('jmapServerUrl', '').trim();
if (!serverUrl) {
stalwartVersionCache = { version: null, fetchedAt: Date.now() };
return null;
}
const wellKnown = `${serverUrl.replace(/\/+$/, '')}/.well-known/jmap`;
// Reuse the SSRF guard so a misconfigured JMAP_SERVER_URL pointing at an
// internal host doesn't get probed from telemetry context either.
const guard = await resolveEndpointAllowed(wellKnown);
if (!guard.ok) {
stalwartVersionCache = { version: null, fetchedAt: Date.now() };
return null;
}
try {
const res = await fetch(wellKnown, {
method: 'GET',
signal: AbortSignal.timeout(3000),
});
const server = res.headers.get('server') ?? '';
const m = server.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/);
const version = m?.[1] ?? null;
stalwartVersionCache = { version, fetchedAt: Date.now() };
return version;
} catch (err) {
logger.debug?.('telemetry: stalwart version probe failed', {
error: err instanceof Error ? err.message : String(err),
});
stalwartVersionCache = { version: null, fetchedAt: Date.now() };
return null;
}
}
// Account counts come from the local login tracker, which records a per-
// instance HMAC of every successful login plus the timestamp. Total = unique
// identities seen in the last 90 days; active7d = identities with a login in
// the last 7 days.
async function countExtensions(): Promise<{ extensions: number; themes: number }> {
try {
const { getPluginRegistry, getThemeRegistry } = await import('@/lib/admin/plugin-registry');
const [plugins, themes] = await Promise.all([getPluginRegistry(), getThemeRegistry()]);
return {
extensions: plugins.plugins.length,
themes: themes.themes.length,
};
} catch {
return { extensions: 0, themes: 0 };
}
}
export async function buildPayload(): Promise<TelemetryPayload> {
const instance_id = await getInstanceId();
const { version, build } = readPackage();
const features = await readFeatures();
const accounts = await getLoginCounts();
const exts = await countExtensions();
const stalwart_version = await detectStalwartVersion();
const uptime_days = Math.min(
365,
Math.floor((Date.now() - processStartedAt) / 86_400_000),
);
return {
schema: '1',
instance_id,
ts: new Date().toISOString(),
version,
build,
platform: detectPlatform(),
node_version: process.versions.node,
os_family: detectOs(),
stalwart_version,
features,
counts: {
accounts: bucketCount(accounts.total),
accounts_active_7d: bucketCount(accounts.active7d),
extensions_installed: exts.extensions,
themes_installed: exts.themes,
},
uptime_days,
};
}
+108
View File
@@ -0,0 +1,108 @@
import { logger } from '@/lib/logger';
import { effectiveConsent, endpointEnabled, loadState, saveState } from './state';
import { buildPayload } from './payload';
import { resolveEndpointAllowed } from './endpoint-guard';
import { DEFAULT_ENDPOINT } from './types';
const DAY_MS = 24 * 60 * 60 * 1000;
const JITTER_MS = 2 * 60 * 60 * 1000; // ± 2 hours
const FIRST_DELAY_MS = 60 * 60 * 1000; // 1 hour after consent
let currentTimer: NodeJS.Timeout | null = null;
function jitteredDelay(base: number): number {
const j = (Math.random() * 2 - 1) * JITTER_MS;
return Math.max(60_000, base + j);
}
export async function sendOnce(opts?: { reason?: string }): Promise<{
ok: boolean;
status?: number;
error?: string;
}> {
const { consent, source, state } = await effectiveConsent();
if (consent !== 'on') return { ok: false, error: `consent ${consent} (source ${source})` };
const endpoint = state.endpoint || DEFAULT_ENDPOINT;
if (!endpointEnabled(endpoint)) return { ok: false, error: 'endpoint blank' };
// Re-check at fetch time: defeats DNS rebinding, and catches the case
// where state.json was edited out-of-band to bypass the admin API.
const guard = await resolveEndpointAllowed(endpoint);
if (!guard.ok) {
logger.warn('telemetry: endpoint blocked', { reason: guard.reason });
return { ok: false, error: `endpoint blocked: ${guard.reason}` };
}
const payload = await buildPayload();
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(5000),
});
const ok = res.ok;
if (ok) {
const next = await loadState();
next.lastSentAt = new Date().toISOString();
await saveState(next);
}
logger.info('telemetry: heartbeat', {
ok, status: res.status, reason: opts?.reason ?? 'scheduled',
});
return { ok, status: res.status };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn('telemetry: heartbeat failed', { error: msg });
return { ok: false, error: msg };
}
}
async function scheduleNext(delayMs: number): Promise<void> {
if (currentTimer) clearTimeout(currentTimer);
const at = new Date(Date.now() + delayMs).toISOString();
const state = await loadState();
state.nextScheduledAt = at;
await saveState(state);
currentTimer = setTimeout(() => { void tick(); }, delayMs);
// Don't keep the process alive just for this.
currentTimer.unref?.();
}
async function tick(): Promise<void> {
await sendOnce({ reason: 'scheduled' });
await scheduleNext(jitteredDelay(DAY_MS));
}
// Called from instrumentation. Idempotent.
export async function startScheduler(): Promise<void> {
const { consent } = await effectiveConsent();
if (consent !== 'on') {
logger.info('telemetry: scheduler not started', { consent });
return;
}
const state = await loadState();
// If we have a next-scheduled time in the future use it; otherwise schedule
// FIRST_DELAY_MS out. This means after a restart we don't fire immediately.
let delay = FIRST_DELAY_MS;
if (state.nextScheduledAt) {
const remaining = new Date(state.nextScheduledAt).getTime() - Date.now();
if (remaining > 0) delay = Math.min(remaining, DAY_MS + JITTER_MS);
}
await scheduleNext(delay);
logger.info('telemetry: scheduler started', {
nextInMs: delay,
endpoint: state.endpoint,
});
}
export async function stopScheduler(): Promise<void> {
if (currentTimer) clearTimeout(currentTimer);
currentTimer = null;
}
// Called when consent flips on/off via the UI.
export async function reschedule(): Promise<void> {
await stopScheduler();
await startScheduler();
}
+101
View File
@@ -0,0 +1,101 @@
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { logger } from '@/lib/logger';
import type { TelemetryStateFile, ConsentState } from './types';
import { DEFAULT_ENDPOINT } from './types';
function getDir(): string {
return process.env.TELEMETRY_DATA_DIR ||
path.join(process.cwd(), 'data', 'telemetry');
}
function statePath(): string { return path.join(getDir(), 'state.json'); }
function idPath(): string { return path.join(getDir(), '.telemetry-id'); }
function envOverride(): ConsentState | null {
const v = (process.env.BULWARK_TELEMETRY ?? '').toLowerCase();
if (v === 'off' || v === 'false' || v === '0' || v === 'no') return 'off';
if (process.env.BULWARK_TELEMETRY_DISABLED) {
const d = process.env.BULWARK_TELEMETRY_DISABLED.toLowerCase();
if (d === '1' || d === 'true' || d === 'yes') return 'off';
}
return null;
}
export async function ensureDir(): Promise<void> {
if (!existsSync(getDir())) await mkdir(getDir(), { recursive: true });
}
export async function getInstanceId(): Promise<string> {
await ensureDir();
try {
const id = (await readFile(idPath(), 'utf8')).trim();
if (/^[0-9a-f-]{36}$/i.test(id)) return id;
} catch { /* generate fresh */ }
const fresh = randomUUID();
const tmp = idPath() + '.tmp';
await writeFile(tmp, fresh, 'utf8');
await rename(tmp, idPath());
return fresh;
}
// Default consent is 'on' - telemetry is anonymous and enabled by default.
// Admins can disable via the UI, the BULWARK_TELEMETRY env var, or by clearing
// the endpoint. See https://bulwarkmail.org/docs/legal/privacy/telemetry.
const DEFAULTS: TelemetryStateFile = {
consent: 'on',
endpoint: DEFAULT_ENDPOINT,
consentedAt: null,
lastSentAt: null,
nextScheduledAt: null,
};
export async function loadState(): Promise<TelemetryStateFile> {
await ensureDir();
try {
const raw = await readFile(statePath(), 'utf8');
const parsed = JSON.parse(raw) as Partial<TelemetryStateFile>;
return { ...DEFAULTS, ...parsed };
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.warn('telemetry: state read failed', {
error: err instanceof Error ? err.message : String(err),
});
}
// First-ever load on a fresh install: persist the default-on state with
// an autoEnabledAt stamp so the admin UI can show "telemetry was
// auto-enabled at <time>; disable here" without re-arming on restart.
const fresh: TelemetryStateFile = {
...DEFAULTS,
consentedAt: new Date().toISOString(),
};
await saveState(fresh);
return fresh;
}
}
export async function saveState(state: TelemetryStateFile): Promise<void> {
await ensureDir();
const tmp = statePath() + '.tmp';
await writeFile(tmp, JSON.stringify(state, null, 2), 'utf8');
await rename(tmp, statePath());
}
// Effective consent: env var wins over file. UI changes are blocked
// when env override is active so the user knows where it's coming from.
export async function effectiveConsent(): Promise<{
consent: ConsentState;
source: 'env' | 'file';
state: TelemetryStateFile;
}> {
const envState = envOverride();
const state = await loadState();
if (envState) return { consent: envState, source: 'env', state };
return { consent: state.consent, source: 'file', state };
}
export function endpointEnabled(endpoint: string | undefined): boolean {
return !!endpoint && endpoint.trim().length > 0;
}
+47
View File
@@ -0,0 +1,47 @@
// Schema v1 of the anonymous heartbeat. Documented at
// https://bulwarkmail.org/docs/legal/privacy/telemetry
export type ConsentState = 'pending' | 'on' | 'off';
export type Platform = 'docker' | 'bare' | 'k8s' | 'unknown';
export type OsFamily = 'linux' | 'darwin' | 'windows' | 'unknown';
export type CountBucket = '0' | '1' | '2-5' | '6-10' | '11-50' | '51-200' | '201+';
export interface TelemetryFeatures {
calendar: boolean;
contacts: boolean;
files: boolean;
extensions: boolean;
oauth_enabled: boolean;
smime_enabled: boolean;
}
export interface TelemetryPayload {
schema: '1';
instance_id: string;
ts: string;
version: string;
build: string | null;
platform: Platform;
node_version: string;
os_family: OsFamily;
stalwart_version: string | null;
features: TelemetryFeatures;
counts: {
accounts: CountBucket;
accounts_active_7d: CountBucket;
extensions_installed: number;
themes_installed: number;
};
uptime_days: number;
}
export interface TelemetryStateFile {
consent: ConsentState;
endpoint: string;
consentedAt: string | null;
lastSentAt: string | null;
nextScheduledAt: string | null;
}
export const DEFAULT_ENDPOINT = 'https://telemetry.bulwarkmail.org/v1/heartbeat';
+295
View File
@@ -0,0 +1,295 @@
// Advanced Theme API v2 - compiles structured manifest fields (tokens,
// radii, typography, density, extends) into a single CSS string that the
// existing `injectThemeCSS` pipeline can apply unchanged.
import type {
ThemeDensity,
ThemeManifest,
ThemeRadii,
ThemeTokenSet,
ThemeTypography,
} from './plugin-types';
import { getLuminance, parseColor } from './color-transform';
export interface CompiledTheme {
css: string;
warnings: string[];
errors: string[];
}
/**
* Standard tokens whose `*-foreground` counterpart can be auto-derived from
* contrast when `derive: true` and only the base colour is supplied.
*/
const DERIVE_PAIRS: Array<[base: string, fg: string]> = [
['primary', 'primary-foreground'],
['secondary', 'secondary-foreground'],
['muted', 'muted-foreground'],
['accent', 'accent-foreground'],
['destructive', 'destructive-foreground'],
['popover', 'popover-foreground'],
['card', 'card-foreground'],
['sidebar', 'sidebar-foreground'],
['success', 'success-foreground'],
['warning', 'warning-foreground'],
['info', 'info-foreground'],
];
/** Pick a foreground colour (white or near-black) by background luminance. */
function pickForeground(bg: string): string {
const rgb = parseColor(bg);
if (!rgb) return '#ffffff';
return getLuminance(rgb.r, rgb.g, rgb.b) >= 0.55 ? '#0f172a' : '#ffffff';
}
/**
* Resolve a manifest token key to a fully-qualified CSS custom property:
* "primary" "--color-primary"
* "color-primary" "--color-primary"
* "--color-primary" "--color-primary"
* "font-sans" "--font-sans"
*/
const PREFIXED_NAMESPACES = ['color-', 'font-', 'radius-', 'density-'];
function tokenName(key: string): string {
if (key.startsWith('--')) return key;
if (PREFIXED_NAMESPACES.some((ns) => key.startsWith(ns))) return `--${key}`;
return `--color-${key}`;
}
function emitTokens(
tokens: Record<string, string>,
derive: boolean,
): { lines: string[]; warnings: string[] } {
const warnings: string[] = [];
const expanded: Record<string, string> = { ...tokens };
if (derive) {
for (const [base, fg] of DERIVE_PAIRS) {
if (expanded[base] && !expanded[fg]) {
expanded[fg] = pickForeground(expanded[base]);
}
}
// Common alias: --color-foreground used as page text colour.
if (expanded.background && !expanded.foreground) {
expanded.foreground = pickForeground(expanded.background);
}
}
const lines: string[] = [];
for (const [rawKey, value] of Object.entries(expanded)) {
if (typeof value !== 'string' || !value.trim()) continue;
if (!isSafeTokenKey(rawKey)) {
warnings.push(`Token "${rawKey}" dropped - invalid key (only [a-z0-9-] allowed)`);
continue;
}
if (!isSafeTokenValue(value)) {
warnings.push(`Token "${rawKey}" dropped - value contains unsafe characters`);
continue;
}
lines.push(` ${tokenName(rawKey)}: ${value.trim()};`);
}
return { lines, warnings };
}
const SAFE_KEY_PATTERN = /^(--)?[a-z][a-z0-9-]*$/;
function isSafeTokenKey(key: string): boolean {
return SAFE_KEY_PATTERN.test(key);
}
/**
* Token values are emitted verbatim into CSS, so they must not contain
* anything that could break out of the declaration (`{`, `}`, `;`,
* `<`/`>`) or pull in remote/scripted content.
*/
function isSafeTokenValue(value: string): boolean {
if (/[{}<>]/.test(value)) return false;
if (value.includes(';')) return false;
if (/url\s*\(\s*['"]?(https?|data|javascript):/i.test(value)) return false;
if (/expression\s*\(/i.test(value)) return false;
if (/-moz-binding/i.test(value)) return false;
if (/javascript\s*:/i.test(value)) return false;
return true;
}
function emitRadii(radii: ThemeRadii): string[] {
const out: string[] = [];
for (const [k, v] of Object.entries(radii)) {
if (typeof v === 'string' && isSafeTokenValue(v)) {
out.push(` --radius-${k}: ${v.trim()};`);
}
}
return out;
}
function emitTypography(typography: ThemeTypography): string[] {
const out: string[] = [];
if (typography.fontSans && isSafeTokenValue(typography.fontSans)) {
out.push(` --font-sans: ${typography.fontSans.trim()};`);
}
if (typography.fontMono && isSafeTokenValue(typography.fontMono)) {
out.push(` --font-mono: ${typography.fontMono.trim()};`);
}
if (typography.fontDisplay && isSafeTokenValue(typography.fontDisplay)) {
out.push(` --font-display: ${typography.fontDisplay.trim()};`);
}
if (typography.baseFontSize && isSafeTokenValue(typography.baseFontSize)) {
out.push(` --font-size-base: ${typography.baseFontSize.trim()};`);
}
return out;
}
const DENSITY_VARS: Record<ThemeDensity, Record<string, string>> = {
compact: {
'--density-row-height': '28px',
'--density-control-height': '28px',
'--density-spacing-1': '2px',
'--density-spacing-2': '4px',
'--density-spacing-3': '6px',
},
normal: {
'--density-row-height': '36px',
'--density-control-height': '32px',
'--density-spacing-1': '4px',
'--density-spacing-2': '8px',
'--density-spacing-3': '12px',
},
touch: {
'--density-row-height': '44px',
'--density-control-height': '40px',
'--density-spacing-1': '6px',
'--density-spacing-2': '12px',
'--density-spacing-3': '18px',
},
};
function emitDensity(density: ThemeDensity): string[] {
return Object.entries(DENSITY_VARS[density]).map(([k, v]) => ` ${k}: ${v};`);
}
export interface CompileOptions {
/**
* Resolves a `extends: <id>` chain to that base theme's compiled CSS.
* Implementations should return null for unknown ids; circular refs are
* the caller's problem (we don't recurse - just one level of inheritance).
*/
resolveExtends?: (id: string) => string | null;
/**
* Optional hand-written CSS appended after compiled tokens. Use this for
* the rare overrides the structured API can't express (extra `@font-face`,
* `@keyframes`, `@media (prefers-contrast)` blocks, etc.).
*/
userCSS?: string;
}
/**
* Compile an advanced theme manifest into a single safe CSS string.
*
* Output layout:
* 1. parent (extends) CSS, if any
* 2. `:root { common + light + radii + typography + density }`
* 3. `.dark { common + dark }` (only when the theme declares a dark variant)
* 4. user-supplied `theme.css` content (sanitized upstream)
*
* The compiler never emits selectors other than `:root` and `.dark`, so the
* existing CSS sanitizer/selector validator continues to apply.
*/
export function compileAdvancedTheme(
manifest: ThemeManifest,
opts: CompileOptions = {},
): CompiledTheme {
const warnings: string[] = [];
const errors: string[] = [];
if (!isAdvancedManifest(manifest)) {
return { css: '', warnings, errors: ['Manifest does not declare any advanced theme fields'] };
}
const tokens: ThemeTokenSet = manifest.tokens ?? {};
const derive = manifest.derive === true;
const wantsDark = manifest.variants.includes('dark');
const wantsLight = manifest.variants.includes('light');
const sections: string[] = [];
// 1. extends - prepend parent CSS verbatim
if (manifest.extends && opts.resolveExtends) {
const parentCSS = opts.resolveExtends(manifest.extends);
if (parentCSS == null) {
warnings.push(`extends: parent theme "${manifest.extends}" not found - skipping`);
} else {
sections.push(`/* inherited from ${manifest.extends} */\n${parentCSS}`);
}
} else if (manifest.extends) {
warnings.push(`extends: no resolver provided - "${manifest.extends}" ignored`);
}
// 2. :root block (light + common + structural)
const rootLines: string[] = [];
if (tokens.common) {
const { lines, warnings: w } = emitTokens(tokens.common, derive);
rootLines.push(...lines);
warnings.push(...w);
}
if (wantsLight && tokens.light) {
const { lines, warnings: w } = emitTokens(tokens.light, derive);
rootLines.push(...lines);
warnings.push(...w);
}
if (manifest.radii) rootLines.push(...emitRadii(manifest.radii));
if (manifest.typography) rootLines.push(...emitTypography(manifest.typography));
if (manifest.density) rootLines.push(...emitDensity(manifest.density));
if (rootLines.length > 0) {
sections.push(`:root {\n${rootLines.join('\n')}\n}`);
}
// 3. .dark block
if (wantsDark) {
const darkLines: string[] = [];
if (tokens.common) {
const { lines, warnings: w } = emitTokens(tokens.common, derive);
darkLines.push(...lines);
warnings.push(...w);
}
if (tokens.dark) {
const { lines, warnings: w } = emitTokens(tokens.dark, derive);
darkLines.push(...lines);
warnings.push(...w);
}
if (darkLines.length > 0) {
sections.push(`.dark {\n${darkLines.join('\n')}\n}`);
}
}
// 4. hand-written overrides
if (opts.userCSS && opts.userCSS.trim()) {
sections.push(`/* user overrides */\n${opts.userCSS.trim()}`);
}
if (sections.length === 0) {
errors.push('Compiled theme is empty - no tokens, radii, typography, or density supplied');
}
return {
css: sections.join('\n\n'),
warnings,
errors,
};
}
/**
* True if a manifest opts into Theme API v2 by setting `apiVersion: 2` or by
* declaring any of the structured fields.
*/
export function isAdvancedManifest(manifest: ThemeManifest): boolean {
return (
manifest.apiVersion === 2 ||
!!manifest.tokens ||
!!manifest.extends ||
!!manifest.derive ||
!!manifest.density ||
!!manifest.radii ||
!!manifest.typography
);
}
+63
View File
@@ -3,6 +3,8 @@
import { DISALLOWED_CSS_PATTERNS } from './plugin-types';
const THEME_STYLE_ID = 'active-theme';
const THEME_SKIN_STYLE_ID = 'active-theme-skin';
const THEME_SKIN_BODY_ATTR = 'data-theme-skin';
/**
* Sanitize theme CSS: strip dangerous patterns like @import, external url(),
@@ -88,6 +90,67 @@ export function removeThemeCSS(): void {
}
}
/**
* Inject a theme's *skin* CSS - component-level overrides shipped by Theme
* API v2 themes via `skin.css`. Lives in a separate `<style>` tag so it can
* be removed cleanly without touching the colour-token block, and is placed
* AFTER the colour block so component rules win specificity.
*
* Also sets `body[data-theme-skin="<themeId>"]` so authors can scope their
* own `:not(...)` overrides if they want belt-and-braces specificity.
*/
export function injectThemeSkinCSS(css: string, themeId: string): void {
if (typeof document === 'undefined') return;
let styleEl = document.getElementById(THEME_SKIN_STYLE_ID) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = THEME_SKIN_STYLE_ID;
document.head.appendChild(styleEl);
}
styleEl.textContent = css;
if (document.body) {
document.body.setAttribute(THEME_SKIN_BODY_ATTR, themeId);
}
}
export function removeThemeSkinCSS(): void {
if (typeof document === 'undefined') return;
const styleEl = document.getElementById(THEME_SKIN_STYLE_ID);
if (styleEl) styleEl.remove();
if (document.body) document.body.removeAttribute(THEME_SKIN_BODY_ATTR);
}
/**
* Sanitize a theme *skin* - looser than `sanitizeThemeCSS` because skins
* intentionally target real component selectors (toolbars, lists, buttons),
* not just `:root`/`.dark`. The same script-injection / external-resource
* prohibitions still apply.
*/
export function sanitizeSkinCSS(css: string): { css: string; warnings: string[] } {
const warnings: string[] = [];
let cleaned = css;
for (const pattern of DISALLOWED_CSS_PATTERNS) {
if (pattern.test(cleaned)) {
warnings.push(`Skin: removed disallowed pattern: ${pattern.source}`);
cleaned = cleaned.replace(new RegExp(pattern.source, 'gi'), '/* [removed] */');
}
}
// `@import` is already covered by DISALLOWED_CSS_PATTERNS, but skins also
// get an explicit no-`@charset`/`@namespace` policy so they can't change
// how the host stylesheet parses subsequent rules.
cleaned = cleaned.replace(/@(charset|namespace)\b[^;]*;?/gi, () => {
warnings.push('Skin: removed @charset/@namespace directive');
return '/* [removed] */';
});
return { css: cleaned, warnings };
}
/**
* Check if a theme CSS string is valid and safe.
*/
+5 -1
View File
@@ -2136,7 +2136,11 @@
"tomorrow_header": "Tomorrow",
"export_ics": "Export as .ics",
"copy_title": "Copy title",
"copy_link": "Copy meeting link"
"copy_link": "Copy meeting link",
"new_event": "New event",
"new_all_day_event": "New all-day event",
"new_task": "New task",
"go_to_today": "Go to today"
},
"detail": {
"add_note": "Add a note...",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-webmail",
"version": "1.5.2",
"version": "1.5.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-webmail",
"version": "1.5.2",
"version": "1.5.3",
"license": "AGPL-3.0-only",
"dependencies": {
"@tanstack/react-virtual": "^3.13.24",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
"version": "1.5.2",
"version": "1.5.3",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",
+92 -4
View File
@@ -2,11 +2,18 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { InstalledTheme, ThemeVariant } from '@/lib/plugin-types';
import { pluginStorage } from '@/lib/plugin-storage';
import { injectThemeCSS, removeThemeCSS, sanitizeThemeCSS } from '@/lib/theme-loader';
import {
injectThemeCSS,
removeThemeCSS,
sanitizeThemeCSS,
injectThemeSkinCSS,
removeThemeSkinCSS,
} from '@/lib/theme-loader';
import { extractTheme } from '@/lib/plugin-validator';
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
import { usePolicyStore } from '@/stores/policy-store';
import { apiFetch } from '@/lib/browser-navigation';
import { themeHooks } from '@/lib/plugin-hooks';
type Theme = 'light' | 'dark' | 'system';
@@ -173,9 +180,22 @@ export const useThemeStore = create<ThemeState>()(
return { success: false, error: result.errors.join('; '), warnings: result.warnings };
}
const { manifest, css, preview } = result;
const { manifest, css, skin, preview } = result;
const { installedThemes } = get();
// Carry advanced (Theme API v2) fields from the manifest through
// to the InstalledTheme so the activate/sync paths can re-compile
// or re-apply tokens later if needed.
const advancedFields = {
apiVersion: manifest.apiVersion,
extends: manifest.extends,
tokens: manifest.tokens,
derive: manifest.derive,
density: manifest.density,
radii: manifest.radii,
typography: manifest.typography,
};
// Check for duplicate
if (installedThemes.some(t => t.id === manifest.id)) {
// Update existing
@@ -188,12 +208,19 @@ export const useThemeStore = create<ThemeState>()(
description: manifest.description || '',
preview: preview || undefined,
css: sanitized.css,
skin: skin ?? undefined,
variants: manifest.variants,
enabled: true,
builtIn: false,
...advancedFields,
};
await pluginStorage.saveThemeCSS(manifest.id, sanitized.css);
if (skin) {
await pluginStorage.saveThemeSkin(manifest.id, skin);
} else {
await pluginStorage.deleteThemeSkin(manifest.id);
}
if (preview) await pluginStorage.savePreview(manifest.id, preview);
set({
@@ -215,12 +242,15 @@ export const useThemeStore = create<ThemeState>()(
description: manifest.description || '',
preview: preview || undefined,
css: sanitized.css,
skin: skin ?? undefined,
variants: manifest.variants,
enabled: true,
builtIn: false,
...advancedFields,
};
await pluginStorage.saveThemeCSS(manifest.id, sanitized.css);
if (skin) await pluginStorage.saveThemeSkin(manifest.id, skin);
if (preview) await pluginStorage.savePreview(manifest.id, preview);
set({ installedThemes: [...installedThemes, theme] });
@@ -237,11 +267,13 @@ export const useThemeStore = create<ThemeState>()(
// Deactivate if active
if (activeThemeId === id) {
removeThemeCSS();
removeThemeSkinCSS();
set({ activeThemeId: null });
}
// Clean up storage
pluginStorage.deleteThemeCSS(id);
pluginStorage.deleteThemeSkin(id);
pluginStorage.deletePreview(id);
set({
@@ -264,6 +296,7 @@ export const useThemeStore = create<ThemeState>()(
if (id === null) {
removeThemeCSS();
removeThemeSkinCSS();
set({ activeThemeId: null });
return;
}
@@ -407,10 +440,11 @@ export const useThemeStore = create<ThemeState>()(
partialize: (state) => ({
theme: state.theme,
activeThemeId: state.activeThemeId,
// Store theme metadata but NOT full CSS (that goes in IndexedDB)
// Store theme metadata but NOT full CSS / skin (those go in IndexedDB)
installedThemes: state.installedThemes.map(t => ({
...t,
css: t.builtIn ? t.css : '', // only keep CSS for built-in themes
skin: undefined, // skins also in IndexedDB
preview: undefined, // previews also in IndexedDB
})),
}),
@@ -434,14 +468,68 @@ export const useThemeStore = create<ThemeState>()(
)
);
/** Apply a custom theme's CSS, filtering to the appropriate variant */
/**
* Apply a custom theme's CSS, filtering to the appropriate variant.
*
* Fires the `themeHooks.onThemeBeforeApply` transform hook so plugins can
* post-process the CSS (e.g. inject extra `@font-face` rules or override
* specific tokens). The hook is fire-and-forget - we inject the original
* CSS synchronously first to avoid a flash, then re-inject the transformed
* version once handlers settle.
*
* If the theme also has a `skin` (Theme API v2 component-level overrides),
* it's injected into a separate `<style>` tag after the colour block so
* skin rules win specificity. The skin is hydrated lazily from IndexedDB
* on first activation.
*/
function applyCustomThemeCSS(theme: InstalledTheme, resolvedTheme: 'light' | 'dark'): void {
// If theme only supports one variant and current mode doesn't match, skip
if (!theme.variants.includes(resolvedTheme as ThemeVariant)) {
removeThemeCSS();
removeThemeSkinCSS();
return;
}
injectThemeCSS(theme.css);
// Apply skin if the theme ships one. Hydrate from IndexedDB if the cached
// copy was stripped from localStorage on persist.
if (theme.skin) {
injectThemeSkinCSS(theme.skin, theme.id);
} else if (theme.apiVersion === 2) {
pluginStorage.getThemeSkin(theme.id).then((skin) => {
// Bail out if the user switched themes mid-flight.
if (useThemeStore.getState().activeThemeId !== theme.id) return;
if (skin) {
injectThemeSkinCSS(skin, theme.id);
useThemeStore.setState((state) => ({
installedThemes: state.installedThemes.map((it) =>
it.id === theme.id ? { ...it, skin } : it,
),
}));
} else {
removeThemeSkinCSS();
}
});
} else {
removeThemeSkinCSS();
}
// Run plugin transforms asynchronously and re-inject if any handler
// modified the CSS. No handlers → no extra work.
if (themeHooks.onThemeBeforeApply.size === 0) return;
const themeId = theme.id;
themeHooks.onThemeBeforeApply
.transform(theme.css, { themeId, variant: resolvedTheme })
.then((transformed) => {
// Bail out if the user switched themes while we were awaiting handlers.
if (useThemeStore.getState().activeThemeId !== themeId) return;
if (transformed && transformed !== theme.css) {
injectThemeCSS(transformed);
}
})
.catch(() => {
// Hook failures are tracked by the hook bus; nothing to do here.
});
}
// ─── Server Theme Sync Helpers ───────────────────────────────