Merge branch 'main' into feature/scheduled-send

# Conflicts:
#	app/(main)/[locale]/page.tsx
#	components/layout/sidebar.tsx
#	stores/email-store.ts
#	stores/settings-store.ts
This commit is contained in:
Lucas Gaitzsch
2026-05-22 12:31:06 +02:00
155 changed files with 4702 additions and 869 deletions
+1
View File
@@ -24,6 +24,7 @@ const ALLOWED_MIME_TYPES = new Set([
/** Slots that correspond to branding config keys */
const VALID_SLOTS = new Set([
'faviconUrl',
'pwaIconUrl',
'appLogoLightUrl',
'appLogoDarkUrl',
'loginLogoLightUrl',
+1 -1
View File
@@ -6,7 +6,7 @@ import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
import { parseJmapServers } from '@/lib/admin/jmap-servers';
import { logger } from '@/lib/logger';
// Strings that count as "no real secret configured" used so the dashboard
// Strings that count as "no real secret configured" - used so the dashboard
// can warn about a placeholder session secret without us ever returning the
// raw value to the client.
const SENSITIVE_PLACEHOLDERS = new Set(['your-secret-key-here']);
+16 -8
View File
@@ -7,8 +7,12 @@ import {
} from '@/lib/admin/plugin-registry';
import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
import { configManager } from '@/lib/admin/config-manager';
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
async function getDirectoryUrl(): Promise<string> {
await configManager.ensureLoaded();
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
}
const MAX_PREVIEW_SOURCE_LEN = 100_000;
@@ -27,9 +31,10 @@ export async function GET(
if ('error' in result) return result.error;
const { slug } = await params;
const directoryUrl = await getDirectoryUrl();
// 1. Extension metadata + screenshots + theme previews from the directory
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, DIRECTORY_URL);
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, directoryUrl);
const detailRes = await fetch(detailUrl.toString(), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10000),
@@ -63,7 +68,7 @@ export async function GET(
try {
const bundleUrl = new URL(
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
DIRECTORY_URL,
directoryUrl,
);
const bundleRes = await fetch(bundleUrl.toString(), {
signal: AbortSignal.timeout(30000),
@@ -144,14 +149,16 @@ export async function GET(
getPluginRegistry(),
getThemeRegistry(),
]);
const installed = type === 'theme'
? themeRegistry.themes.some((t) => t.id === slug)
: pluginRegistry.plugins.some((p) => p.id === slug);
const installedEntry = type === 'theme'
? themeRegistry.themes.find((t) => t.id === slug)
: pluginRegistry.plugins.find((p) => p.id === slug);
const installed = installedEntry !== undefined;
const installedVersion = installedEntry?.version ?? null;
// 4. Build screenshot URLs (proxy through the directory's public files endpoint).
const screenshots = Array.isArray(extension.screenshots)
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
url: new URL(`/api/v1/files/${s.path}`, DIRECTORY_URL).toString(),
url: new URL(`/api/v1/files/${s.path}`, directoryUrl).toString(),
altText: s.altText ?? null,
}))
: [];
@@ -170,7 +177,7 @@ export async function GET(
const fileUrl = (path: unknown): string | null =>
typeof path === 'string' && path
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString()
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
: null;
return NextResponse.json(
@@ -206,6 +213,7 @@ export async function GET(
error: bundleError,
},
installed,
installedVersion,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
+83 -24
View File
@@ -5,6 +5,8 @@ import { logger } from '@/lib/logger';
import {
savePlugin,
saveTheme,
getPlugin,
getTheme,
getPluginRegistry,
getThemeRegistry,
type ServerPlugin,
@@ -19,8 +21,12 @@ import {
import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
import { configManager } from '@/lib/admin/config-manager';
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
async function getDirectoryUrl(): Promise<string> {
await configManager.ensureLoaded();
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
}
/**
* GET /api/admin/marketplace - Search/browse the extension directory
@@ -31,8 +37,9 @@ export async function GET(request: NextRequest) {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const directoryUrl = await getDirectoryUrl();
const { searchParams } = request.nextUrl;
const url = new URL('/api/v1/extensions', DIRECTORY_URL);
const url = new URL('/api/v1/extensions', directoryUrl);
// Forward all search params
for (const [key, value] of searchParams.entries()) {
@@ -59,23 +66,32 @@ export async function GET(request: NextRequest) {
getThemeRegistry(),
]);
const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id));
const installedThemes = new Set(themeRegistry.themes.map(t => t.id));
const installedPluginVersions = new Map(
pluginRegistry.plugins.map(p => [p.id, p.version] as const),
);
const installedThemeVersions = new Map(
themeRegistry.themes.map(t => [t.id, t.version] as const),
);
const fileUrl = (path: unknown): string | null =>
typeof path === 'string' && path
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString()
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
: null;
if (data.data) {
data.data = data.data.map((ext: Record<string, unknown>) => ({
...ext,
iconUrl: fileUrl(ext.iconPath),
bannerUrl: fileUrl(ext.bannerPath),
installed: ext.type === 'theme'
? installedThemes.has(ext.slug as string)
: installedPlugins.has(ext.slug as string),
}));
data.data = data.data.map((ext: Record<string, unknown>) => {
const slug = ext.slug as string;
const installedVersion = ext.type === 'theme'
? installedThemeVersions.get(slug) ?? null
: installedPluginVersions.get(slug) ?? null;
return {
...ext,
iconUrl: fileUrl(ext.iconPath),
bannerUrl: fileUrl(ext.bannerPath),
installed: installedVersion !== null,
installedVersion,
};
});
}
return NextResponse.json(data, {
@@ -108,7 +124,8 @@ export async function POST(request: NextRequest) {
}
// Download the bundle from the directory
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, DIRECTORY_URL);
const directoryUrl = await getDirectoryUrl();
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, directoryUrl);
const bundleRes = await fetch(bundleUrl.toString(), {
signal: AbortSignal.timeout(30000),
});
@@ -194,22 +211,43 @@ export async function POST(request: NextRequest) {
warnings.push(...sanitized.warnings);
}
const existingTheme = await getTheme(resolvedId);
const isUpdate = existingTheme !== null;
const theme: ServerTheme = {
id: resolvedId,
name: (manifest.name as string) || slug,
version: (manifest.version as string) || version,
// Prefer the directory-published version (what we requested) over
// manifest.version. Publishers sometimes forget to bump the version
// inside the bundle's manifest.json; trusting it would make the
// update never appear to "stick" — the registry would keep showing
// the older version even after a successful update.
version: version || (manifest.version as string),
author: (manifest.author as string) || 'Unknown',
description: (manifest.description as string) || '',
variants: (manifest.variants as string[]) || ['light', 'dark'],
enabled: true,
installedAt: now,
enabled: existingTheme?.enabled ?? true,
...(existingTheme?.forceEnabled !== undefined
? { forceEnabled: existingTheme.forceEnabled }
: {}),
installedAt: existingTheme?.installedAt ?? now,
updatedAt: now,
};
await saveTheme(theme, css);
await auditLog('marketplace.install_theme', { id: theme.id, name: theme.name, version: theme.version, slug }, ip);
await auditLog(
isUpdate ? 'marketplace.update_theme' : 'marketplace.install_theme',
{
id: theme.id,
name: theme.name,
version: theme.version,
slug,
...(isUpdate ? { previousVersion: existingTheme.version } : {}),
},
ip,
);
return NextResponse.json({ success: true, theme, warnings });
return NextResponse.json({ success: true, theme, warnings, updated: isUpdate });
} else {
// Plugin installation
// Read entrypoint JS
@@ -291,17 +329,25 @@ export async function POST(request: NextRequest) {
);
}
const existingPlugin = await getPlugin(resolvedId);
const isUpdate = existingPlugin !== null;
const plugin: ServerPlugin = {
id: resolvedId,
name: (manifest.name as string) || slug,
version: (manifest.version as string) || version,
// See theme branch: trust the directory-published version, not
// manifest.version, so updates actually stick in the registry.
version: version || (manifest.version as string),
author: (manifest.author as string) || 'Unknown',
description: (manifest.description as string) || '',
type: (manifest.type as string) || 'hook',
permissions,
entrypoint,
enabled: true,
installedAt: now,
enabled: existingPlugin?.enabled ?? true,
...(existingPlugin?.forceEnabled !== undefined
? { forceEnabled: existingPlugin.forceEnabled }
: {}),
installedAt: existingPlugin?.installedAt ?? now,
updatedAt: now,
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
@@ -322,9 +368,22 @@ export async function POST(request: NextRequest) {
await savePlugin(plugin, code);
invalidateFrameOriginsCache();
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip);
await auditLog(
isUpdate ? 'marketplace.update_plugin' : 'marketplace.install_plugin',
{
id: plugin.id,
name: plugin.name,
version: plugin.version,
slug,
frameOrigins: declaredFrameOrigins,
httpOrigins: declaredHttpOrigins,
apiPostPaths: declaredApiPostPaths,
...(isUpdate ? { previousVersion: existingPlugin.version } : {}),
},
ip,
);
return NextResponse.json({ success: true, plugin, warnings });
return NextResponse.json({ success: true, plugin, warnings, updated: isUpdate });
}
} catch (error) {
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
+11 -2
View File
@@ -240,9 +240,18 @@ export async function PATCH(request: NextRequest) {
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
const updated = await updatePluginMeta(id, updates);
let updated = await updatePluginMeta(id, updates);
if (!updated) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
// Dev plugins (PLUGIN_DEV_DIR) aren't in the persisted registry, but
// forceEnabled is canonical-stored in policy.forceEnabledPlugins on the
// client. Skip the registry write and return the live dev plugin so the
// policy save path can proceed.
const devEntries = await listDevPlugins();
const devEntry = devEntries.find(e => e.plugin.id === id);
if (!devEntry) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
updated = { ...devEntry.plugin, ...updates };
}
// Enable/disable changes the set of plugins contributing frame origins.
+3 -3
View File
@@ -23,7 +23,7 @@ const IMPERSONATION_SLOT = 0;
/**
* Impersonation cookies deliberately omit Max-Age so the browser treats
* them as session cookies the impersonated session ends when the user
* them as session cookies - the impersonated session ends when the user
* closes the browser, not 30 days later. Impersonation is a temporary
* support handoff; a normal password login is the only thing that should
* survive a browser restart.
@@ -48,7 +48,7 @@ function impersonationCookieOptions() {
export async function GET(request: NextRequest) {
const config = readImpersonationConfig();
if (!config) {
// Not configured behave exactly like an unknown route.
// Not configured - behave exactly like an unknown route.
return new NextResponse('Not found', { status: 404 });
}
@@ -112,7 +112,7 @@ export async function GET(request: NextRequest) {
authHeader,
});
// Structured audit log operators rely on this for security review.
// Structured audit log - operators rely on this for security review.
logger.info('Impersonation session granted', {
event: 'impersonation_granted',
jti: claims.jti,
+1 -1
View File
@@ -74,7 +74,7 @@ export async function POST(request: NextRequest) {
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
// For the mobile handoff flow the tokens are handed back to the app
// verbatim we deliberately don't write any cookies on the webmail
// verbatim - we deliberately don't write any cookies on the webmail
// origin (the mobile browser tab disposes of the session after the
// redirect anyway, but the cookie would still get committed to the
// user's main webmail session if they happened to be logged in there).
+1 -1
View File
@@ -77,7 +77,7 @@ export async function POST(request: NextRequest) {
// /complete handler reaches the same OAuth endpoint we used to authorize.
// Mobile params are captured here so /complete knows to return tokens to
// the caller (in the JSON response) instead of writing the usual server
// cookies and so the callback page can redirect back to the app.
// cookies - and so the callback page can redirect back to the app.
const pendingData = {
state,
code_verifier: codeVerifier,
+1 -1
View File
@@ -7,7 +7,7 @@ import { logger } from '@/lib/logger';
*
* Returns the host's Ed25519 public key (base64-encoded raw 32 bytes) so the
* sandboxed plugin loader can verify bundle signatures before evaluation.
* Public every logged-in user needs to fetch it on app boot.
* Public - every logged-in user needs to fetch it on app boot.
*
* The response is long-cache-eligible (the key rotates only when an operator
* deletes the on-disk PEM), but we keep it `no-store` for simplicity. The
+10 -1
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
import { listDevPlugins } from '@/lib/admin/plugin-dev';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger';
/**
@@ -11,6 +12,10 @@ import { logger } from '@/lib/logger';
*/
export async function GET() {
try {
await configManager.ensureLoaded();
const policy = configManager.getPolicy();
const policyForceEnabledIds = new Set(policy.forceEnabledPlugins || []);
const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([
getPluginRegistry(),
getThemeRegistry(),
@@ -34,7 +39,11 @@ export async function GET() {
type: p.type,
permissions: p.permissions,
entrypoint: p.entrypoint,
forceEnabled: p.forceEnabled || false,
// Policy is the canonical source for force-enable. The per-plugin field
// can drift for dev plugins (manifest always loads forceEnabled:false)
// and during pending policy saves; OR'ing here unifies the signal so
// the client's auto-enable path triggers consistently.
forceEnabled: p.forceEnabled || policyForceEnabledIds.has(p.id),
// Content hash + updatedAt let clients detect re-uploads even when
// the manifest version is unchanged.
bundleHash: p.bundleHash,
+23 -6
View File
@@ -2,11 +2,14 @@ import { NextRequest, NextResponse } from 'next/server';
import sharp from 'sharp';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { configManager } from '@/lib/admin/config-manager';
import { getConfigDir } from '@/lib/admin/paths';
const VALID_SIZES = new Set([192, 512]);
// Cache resized images in memory to avoid reprocessing on every request
const cache = new Map<number, Blob>();
// Cache resized images keyed by (size, source URL) so admin re-uploads or URL
// changes invalidate the prior render instead of serving stale bytes forever.
const cache = new Map<string, Blob>();
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
// Absolute URL (http/https)
@@ -16,6 +19,14 @@ async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
return Buffer.from(await res.arrayBuffer());
}
// Admin-uploaded branding asset: served from /api/admin/branding/<file>
// but stored on disk under getConfigDir()/branding/.
const ADMIN_BRANDING_PREFIX = '/api/admin/branding/';
if (iconUrl.startsWith(ADMIN_BRANDING_PREFIX)) {
const filename = path.basename(iconUrl.slice(ADMIN_BRANDING_PREFIX.length));
return readFile(path.join(getConfigDir(), 'branding', filename));
}
// Path relative to public/ directory
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
return readFile(publicPath);
@@ -32,7 +43,11 @@ export async function GET(
return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 });
}
const iconUrl = process.env.PWA_ICON_URL || process.env.FAVICON_URL;
await configManager.ensureLoaded();
const sources = configManager.getAllWithSources();
const iconUrl =
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
if (!iconUrl) {
return new NextResponse('No PWA icon configured', { status: 404 });
}
@@ -42,9 +57,11 @@ export async function GET(
'Cache-Control': 'public, max-age=86400',
};
const cacheKey = `${size}|${iconUrl}`;
try {
if (cache.has(size)) {
return new NextResponse(cache.get(size)!, { headers: pngHeaders });
if (cache.has(cacheKey)) {
return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
}
const sourceBuffer = await fetchSourceImage(iconUrl);
@@ -56,7 +73,7 @@ export async function GET(
const ab = new ArrayBuffer(resized.byteLength);
new Uint8Array(ab).set(resized);
const blob = new Blob([ab], { type: 'image/png' });
cache.set(size, blob);
cache.set(cacheKey, blob);
return new NextResponse(blob, { headers: pngHeaders });
} catch (err) {
+1 -1
View File
@@ -60,7 +60,7 @@ export async function POST(request: NextRequest) {
try {
// 1. Provision the admin account. An admin.json file may already exist
// from a previous ADMIN_PASSWORD env var or an aborted earlier wizard
// run while setupComplete is still false accept the wizard's
// run while setupComplete is still false - accept the wizard's
// password as authoritative in that case. The finish route is gated
// by the bootstrap state + one-time setup token, so this is safe.
const created = await setInitialAdminPassword(adminPassword, { allowOverwrite: true });
+1 -1
View File
@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
}
const response = NextResponse.json({ ok: true });
const attrs = buildSessionCookieAttributes();
const attrs = buildSessionCookieAttributes(request);
response.cookies.set(attrs.name, submitted, {
httpOnly: attrs.httpOnly,
sameSite: attrs.sameSite,