Merge remote-tracking branch 'origin/main' into feature/scheduled-send

# Conflicts:
#	app/[locale]/page.tsx
#	components/email/email-composer.tsx
#	components/email/email-viewer.tsx
This commit is contained in:
Lucas Gaitzsch
2026-05-07 18:01:41 +02:00
120 changed files with 7033 additions and 4882 deletions
+1 -1
View File
@@ -123,7 +123,7 @@ describe('JMAPClient.sendEmail threading headers', () => {
const create = setCall[1].create as Record<string, Record<string, unknown>>;
const draft = Object.values(create)[0];
// Bare msg-ids per RFC 8621 angle brackets stripped.
// Bare msg-ids per RFC 8621 - angle brackets stripped.
expect(draft.inReplyTo).toEqual(['parent@example.com']);
expect(draft.references).toEqual(['root@example.com', 'parent@example.com']);
});
+40 -2
View File
@@ -62,5 +62,43 @@ export function getAccountScopedKey(baseKey: string, accountId: string): string
return `${baseKey}::${accountId}`;
}
/** Maximum number of accounts allowed */
export const MAX_ACCOUNTS = 5;
/**
* Hard upper bound on cookie slots. Each slot can hold up to ~3 cookies
* (session, refresh token, server id, auth context), so 50 slots ≈ 125
* cookies on average — within Firefox's per-domain limit of 150.
*/
export const MAX_ACCOUNT_SLOTS = 50;
/**
* UX cap for browsers using HTTP/1.1. Each account holds one persistent
* SSE connection for JMAP push; HTTP/1.1 caps origins at 6 concurrent
* connections, so 5 accounts leave one connection free for normal traffic.
* On HTTP/2+ this cap doesn't apply because streams are multiplexed.
*/
export const MAX_ACCOUNTS_HTTP1 = 5;
/**
* Detect whether the page has observed any HTTP/2 or HTTP/3 traffic.
*
* We walk recent resource-timing entries and treat a single h2/h3 sighting
* as a positive signal. Cross-origin entries may report an empty
* `nextHopProtocol` without `Timing-Allow-Origin`, in which case we
* under-detect and fall back to the conservative cap — that's safe.
*/
export function isHttp2Available(): boolean {
if (typeof performance === 'undefined') return false;
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
for (let i = entries.length - 1; i >= 0; i--) {
const proto = entries[i].nextHopProtocol;
if (proto === 'h2' || proto === 'h3') return true;
}
return false;
}
/**
* Effective per-browser account cap. Lifts to {@link MAX_ACCOUNT_SLOTS}
* once HTTP/2+ is observed, otherwise returns {@link MAX_ACCOUNTS_HTTP1}.
*/
export function getMaxAccounts(): number {
return isHttp2Available() ? MAX_ACCOUNT_SLOTS : MAX_ACCOUNTS_HTTP1;
}
+6
View File
@@ -13,6 +13,12 @@ function parseEnvValue(value: string, type: string): unknown {
switch (type) {
case 'boolean':
return value === 'true';
case 'json':
try {
return JSON.parse(value);
} catch {
return null;
}
case 'string':
case 'url':
case 'enum':
+8
View File
@@ -52,6 +52,14 @@ export function sanitizeFrameOrigins(input: unknown): string[] {
return out;
}
/**
* Same syntax + validation as `sanitizeFrameOrigins`, but for the
* `httpOrigins` manifest field. Kept as a separate exported function so the
* intent is explicit at every call site (frame embedding vs. HTTP fetch).
*/
export const sanitizeHttpOrigins = sanitizeFrameOrigins;
export const isValidHttpOrigin = isValidFrameOrigin;
// In-memory cache. The proxy fires on every page navigation; reading the
// registry JSON every time is fine but cheap to skip when nothing has
// changed. Five seconds is short enough to make plugin install/uninstall
+168
View File
@@ -0,0 +1,168 @@
/**
* Multi-server JMAP support: schema, parsing, lookup, and redaction helpers.
*/
export interface JmapServerOAuthConfig {
clientId?: string;
issuerUrl?: string;
clientSecret?: string;
}
export interface JmapServerEntry {
id: string;
label: string;
url: string;
domains?: string[];
oauth?: JmapServerOAuthConfig;
}
export interface PublicJmapServerEntry {
id: string;
label: string;
url: string;
domains: string[];
oauth?: {
clientId?: string;
issuerUrl?: string;
};
}
const ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
function trimUrl(url: string): string {
return url.trim().replace(/\/+$/, '');
}
function isHttpUrl(url: string): boolean {
try {
const u = new URL(url);
return u.protocol === 'https:' || u.protocol === 'http:';
} catch {
return false;
}
}
/** Parse the raw config value (may be array, string JSON, or null). */
export function parseJmapServers(raw: unknown): JmapServerEntry[] {
if (!raw) return [];
let value = raw;
if (typeof value === 'string') {
if (!value.trim()) return [];
try {
value = JSON.parse(value);
} catch {
return [];
}
}
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const out: JmapServerEntry[] = [];
for (const item of value) {
if (!item || typeof item !== 'object') continue;
const e = item as Record<string, unknown>;
const id = typeof e.id === 'string' ? e.id.trim() : '';
const label = typeof e.label === 'string' ? e.label.trim() : '';
const url = typeof e.url === 'string' ? trimUrl(e.url) : '';
if (!id || !ID_RE.test(id) || seen.has(id)) continue;
if (!url || !isHttpUrl(url)) continue;
seen.add(id);
const domains = Array.isArray(e.domains)
? e.domains
.filter((d): d is string => typeof d === 'string')
.map((d) => d.trim().toLowerCase())
.filter(Boolean)
: [];
let oauth: JmapServerOAuthConfig | undefined;
if (e.oauth && typeof e.oauth === 'object') {
const o = e.oauth as Record<string, unknown>;
const clientId = typeof o.clientId === 'string' ? o.clientId.trim() : '';
const issuerUrl = typeof o.issuerUrl === 'string' ? trimUrl(o.issuerUrl) : '';
const clientSecret = typeof o.clientSecret === 'string' ? o.clientSecret : '';
if (clientId || issuerUrl || clientSecret) {
oauth = {};
if (clientId) oauth.clientId = clientId;
if (issuerUrl && isHttpUrl(issuerUrl)) oauth.issuerUrl = issuerUrl;
if (clientSecret) oauth.clientSecret = clientSecret;
}
}
out.push({
id,
label: label || id,
url,
...(domains.length > 0 ? { domains } : {}),
...(oauth ? { oauth } : {}),
});
}
return out;
}
/** Strip secrets for client-side exposure. */
export function redactJmapServers(servers: JmapServerEntry[]): PublicJmapServerEntry[] {
return servers.map((s) => ({
id: s.id,
label: s.label,
url: s.url,
domains: s.domains ?? [],
...(s.oauth && (s.oauth.clientId || s.oauth.issuerUrl)
? {
oauth: {
...(s.oauth.clientId ? { clientId: s.oauth.clientId } : {}),
...(s.oauth.issuerUrl ? { issuerUrl: s.oauth.issuerUrl } : {}),
},
}
: {}),
}));
}
export function findServerById(servers: JmapServerEntry[], id: string | null | undefined): JmapServerEntry | undefined {
if (!id) return undefined;
return servers.find((s) => s.id === id);
}
function normalizeUrl(url: string): string {
try {
const u = new URL(trimUrl(url));
return `${u.protocol}//${u.host.toLowerCase()}${u.pathname.replace(/\/+$/, '')}`;
} catch {
return trimUrl(url).toLowerCase();
}
}
export function findServerByUrl(servers: JmapServerEntry[], url: string | null | undefined): JmapServerEntry | undefined {
if (!url) return undefined;
const target = normalizeUrl(url);
return servers.find((s) => normalizeUrl(s.url) === target);
}
/** Find the server whose `domains` array matches the given email's domain (case-insensitive). */
export function findServerByEmailDomain(servers: JmapServerEntry[], email: string | null | undefined): JmapServerEntry | undefined {
if (!email || !email.includes('@')) return undefined;
const domain = email.split('@')[1]?.trim().toLowerCase();
if (!domain) return undefined;
return servers.find((s) => (s.domains ?? []).some((d) => d.toLowerCase() === domain));
}
/**
* Resolve a client-supplied JMAP URL to a trusted upstream URL by checking it
* against the configured server list and the global `jmapServerUrl`. Returns
* null when no match is found. Used by API routes that need to forward auth
* requests upstream without being tricked into hitting internal hosts.
*/
export function resolveTrustedJmapUrl(
requestedUrl: string | null | undefined,
globalServerUrl: string | null | undefined,
servers: JmapServerEntry[],
): string | null {
if (!requestedUrl) {
return globalServerUrl ? trimUrl(globalServerUrl) : null;
}
const target = normalizeUrl(requestedUrl);
if (globalServerUrl && normalizeUrl(globalServerUrl) === target) {
return trimUrl(globalServerUrl);
}
const matched = servers.find((s) => normalizeUrl(s.url) === target);
if (matched) return matched.url;
// No match - caller decides whether to honor the request anyway (e.g. when
// allowCustomJmapEndpoint is enabled).
return null;
}
+233
View File
@@ -0,0 +1,233 @@
import { readFile, readdir, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { logger } from '@/lib/logger';
import type { ServerPlugin } from './plugin-registry';
import { sanitizeFrameOrigins, sanitizeHttpOrigins } from './csp-frame-origins';
/**
* Dev-mode plugin loading.
*
* Set PLUGIN_DEV_DIR to a directory whose immediate subfolders are plugin
* sources. Each subfolder must contain a `manifest.json`. The bundle file
* (declared as `entrypoint` in the manifest) is resolved in this order:
*
* 1. `src/<entrypoint>` → bundled on-demand via esbuild (preferred).
* Lets you edit source files directly and just refresh the browser.
* 2. `<entrypoint>` at the plugin root → served raw.
* 3. `dist/<entrypoint>` → served raw (output of a manual build).
*
* Bundles are recomputed on every request so any save in `src/` propagates
* to all connected clients on their next page refresh. The content hash
* doubles as the HTTP ETag and the `?v=` cache-buster.
*/
export interface DevPluginEntry {
plugin: ServerPlugin;
/** Absolute path to either a source file (needs bundling) or a built file. */
bundlePath: string;
manifestPath: string;
/** True when bundlePath points at an unbundled source file under `src/`. */
needsBundle: boolean;
}
const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
export function getPluginDevDir(): string | null {
const dir = process.env.PLUGIN_DEV_DIR;
if (!dir) return null;
const resolved = path.resolve(dir);
if (!existsSync(resolved)) {
logger.warn(`PLUGIN_DEV_DIR is set but does not exist: ${resolved}`);
return null;
}
return resolved;
}
function asString(v: unknown, fallback = ''): string {
return typeof v === 'string' ? v : fallback;
}
async function readManifest(manifestPath: string): Promise<Record<string, unknown> | null> {
try {
const raw = await readFile(manifestPath, 'utf-8');
const parsed = JSON.parse(raw);
return typeof parsed === 'object' && parsed !== null ? parsed : null;
} catch {
return null;
}
}
interface ResolvedBundle {
bundlePath: string;
needsBundle: boolean;
}
function resolveBundlePath(pluginDir: string, entrypoint: string): ResolvedBundle | null {
const srcCandidate = path.join(pluginDir, 'src', entrypoint);
if (existsSync(srcCandidate)) return { bundlePath: srcCandidate, needsBundle: true };
const rootCandidate = path.join(pluginDir, entrypoint);
if (existsSync(rootCandidate)) return { bundlePath: rootCandidate, needsBundle: false };
const distCandidate = path.join(pluginDir, 'dist', entrypoint);
if (existsSync(distCandidate)) return { bundlePath: distCandidate, needsBundle: false };
return null;
}
/**
* Load and bundle a dev plugin's code. For `src/` sources this runs esbuild
* on every call so saves are reflected immediately. Errors are surfaced as
* a JS module that throws at activation time - that way the dev sees the
* failure in the browser console instead of a silent 404.
*/
export async function readDevBundle(entry: DevPluginEntry): Promise<string> {
if (!entry.needsBundle) {
return readFile(entry.bundlePath, 'utf-8');
}
try {
const esbuild = await import('esbuild');
const result = await esbuild.build({
entryPoints: [entry.bundlePath],
bundle: true,
format: 'esm',
write: false,
logLevel: 'silent',
sourcemap: 'inline',
target: ['es2020'],
// React/ReactDOM are exposed on globalThis.__PLUGIN_EXTERNALS__ by the
// host, so we mark them external - the bundle won't try to ship them.
external: ['react', 'react-dom', 'react/jsx-runtime'],
});
const out = result.outputFiles?.[0]?.text;
if (!out) throw new Error('esbuild produced no output');
return out;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.warn(`[plugin-dev] esbuild failed for ${entry.plugin.id}`, { error: message });
// Return a module that throws on load so the dev sees the error.
return `throw new Error(${JSON.stringify(`[plugin-dev:${entry.plugin.id}] esbuild failed: ${message}`)});`;
}
}
async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null> {
// Prefer the root manifest.json. Fall back to dist/manifest.json for
// pre-built plugins that don't keep a manifest at the root.
let manifestPath = path.join(pluginDir, 'manifest.json');
if (!existsSync(manifestPath)) {
manifestPath = path.join(pluginDir, 'dist', 'manifest.json');
}
if (!existsSync(manifestPath)) {
logger.warn(`[plugin-dev] no manifest.json at root or dist/ in ${pluginDir}`);
return null;
}
const manifest = await readManifest(manifestPath);
if (!manifest) {
logger.warn(`[plugin-dev] manifest unreadable or not a JSON object: ${manifestPath}`);
return null;
}
const id = asString(manifest.id);
if (!PLUGIN_ID_RE.test(id)) {
logger.warn(`[plugin-dev] manifest id "${id}" rejected by id regex (${manifestPath})`);
return null;
}
const entrypoint = asString(manifest.entrypoint, 'index.js');
const resolved = resolveBundlePath(pluginDir, entrypoint);
if (!resolved) {
logger.warn(`[plugin-dev] entrypoint "${entrypoint}" not found at src/, root, or dist/ for ${id}`);
return null;
}
// Hash from the on-disk source so any edit propagates. For src/ sources
// we hash the source - close enough for dev-time change detection (we
// don't need to re-hash transitive imports).
let bundleHash: string;
try {
const code = await readFile(resolved.bundlePath);
bundleHash = createHash('sha256').update(code).digest('hex').slice(0, 16);
} catch (err) {
logger.warn(`[plugin-dev] failed to read ${resolved.bundlePath} for ${id}`, {
error: err instanceof Error ? err.message : String(err),
});
return null;
}
let installedAt = new Date().toISOString();
try {
const stats = await stat(resolved.bundlePath);
installedAt = stats.mtime.toISOString();
} catch {
/* ignore */
}
const permissions = Array.isArray(manifest.permissions)
? manifest.permissions.filter((p): p is string => typeof p === 'string')
: [];
const frameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
const httpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
const plugin: ServerPlugin = {
id,
name: asString(manifest.name, id),
version: asString(manifest.version, '0.0.0-dev'),
author: asString(manifest.author),
description: asString(manifest.description),
type: asString(manifest.type, 'hook'),
permissions,
entrypoint,
enabled: true,
forceEnabled: false,
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
: {}),
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
: {}),
...(frameOrigins.length > 0 ? { frameOrigins } : {}),
...(httpOrigins.length > 0 ? { httpOrigins } : {}),
installedAt,
updatedAt: new Date().toISOString(),
bundleHash,
};
return { plugin, bundlePath: resolved.bundlePath, manifestPath, needsBundle: resolved.needsBundle };
}
export async function listDevPlugins(): Promise<DevPluginEntry[]> {
const dir = getPluginDevDir();
if (!dir) return [];
let entries: string[];
try {
entries = await readdir(dir);
} catch (error) {
logger.warn('Failed to read PLUGIN_DEV_DIR', {
dir,
error: error instanceof Error ? error.message : String(error),
});
return [];
}
const out: DevPluginEntry[] = [];
for (const name of entries) {
if (name.startsWith('.') || name === 'node_modules') continue;
const fullPath = path.join(dir, name);
let isDir = false;
try { isDir = (await stat(fullPath)).isDirectory(); } catch { continue; }
if (!isDir) continue;
const entry = await loadDevPlugin(fullPath);
if (entry) out.push(entry);
}
return out;
}
export async function getDevPlugin(id: string): Promise<DevPluginEntry | null> {
if (!PLUGIN_ID_RE.test(id)) return null;
const list = await listDevPlugins();
return list.find(e => e.plugin.id === id) ?? null;
}
+42 -3
View File
@@ -1,5 +1,6 @@
import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { logger } from '@/lib/logger';
@@ -27,6 +28,21 @@ export interface PluginConfigField {
options?: { label: string; value: string }[];
}
/**
* Per-user setting field, mirrors the manifest's `settingsSchema` shape
* (see lib/plugin-types.ts SettingFieldSchema). The server passes these
* through unchanged so the client can render the per-user settings UI.
*/
export interface PluginSettingsField {
type: 'boolean' | 'string' | 'number' | 'select';
label: string;
description?: string;
default: unknown;
options?: string[];
min?: number;
max?: number;
}
export interface ServerPlugin {
id: string;
name: string;
@@ -39,13 +55,25 @@ export interface ServerPlugin {
enabled: boolean;
forceEnabled?: boolean;
configSchema?: Record<string, PluginConfigField>;
settingsSchema?: Record<string, PluginSettingsField>;
installedAt: string;
updatedAt: string;
/**
* SHA-256 hex of the bundle code (first 16 chars). Refreshed every save so
* the same version re-uploaded with new code still appears as a change to
* the client. Also doubles as the HTTP ETag for the bundle endpoint.
*/
bundleHash?: string;
/**
* Validated CSP origins (https-only, single-origin form) the plugin may
* embed. Merged into the host frame-src by the proxy.
*/
frameOrigins?: string[];
/**
* Validated HTTPS origins the plugin may target via `api.http.fetch()`.
* Same syntax as `frameOrigins`. Surfaced to clients via /api/plugins.
*/
httpOrigins?: string[];
}
export interface ServerTheme {
@@ -120,13 +148,24 @@ export async function savePlugin(
const bundlePath = path.join(dir, `${plugin.id}.js`);
await writeFile(bundlePath, code, 'utf-8');
// Update registry
// Stamp content hash + updatedAt so clients can detect re-uploads even
// when the manifest version hasn't changed. Preserve the original
// installedAt across re-uploads.
const bundleHash = createHash('sha256').update(code).digest('hex').slice(0, 16);
const now = new Date().toISOString();
const registry = await getPluginRegistry();
const idx = registry.plugins.findIndex(p => p.id === plugin.id);
const next: ServerPlugin = {
...plugin,
bundleHash,
updatedAt: now,
installedAt: idx >= 0 ? registry.plugins[idx].installedAt : plugin.installedAt,
};
if (idx >= 0) {
registry.plugins[idx] = plugin;
registry.plugins[idx] = next;
} else {
registry.plugins.push(plugin);
registry.plugins.push(next);
}
await writeJsonFile(pluginRegistryPath(), registry);
}
+3 -1
View File
@@ -108,7 +108,7 @@ export interface AuditEntry {
}
/** Config keys that map to environment variables */
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; type: 'string' | 'boolean' | 'url' | 'enum'; defaultValue: unknown; enumValues?: string[] }> = {
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; type: 'string' | 'boolean' | 'url' | 'enum' | 'json'; defaultValue: unknown; enumValues?: string[] }> = {
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
@@ -129,6 +129,8 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', fileEnvVar: 'OAUTH_CLIENT_SECRET_FILE', type: 'string', defaultValue: '' },
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
+1 -1
View File
@@ -1,7 +1,7 @@
export const SESSION_COOKIE = 'jmap_session';
export const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
/** Get the cookie name for a given account slot. Slot 0 uses the legacy name. */
export function sessionCookieName(slot: number): string {
return slot === 0 ? SESSION_COOKIE : `${SESSION_COOKIE}_${slot}`;
}
+1 -1
View File
@@ -7,7 +7,7 @@
* In-Reply-To = parent.Message-ID
* References = parent.References (if any) + parent.Message-ID
*
* Bare msg-ids only angle brackets are stripped because JMAP RFC 8621
* Bare msg-ids only - angle brackets are stripped because JMAP RFC 8621
* §4.1.2.3 stores Message-IDs without them.
*/
+1 -1
View File
@@ -331,7 +331,7 @@ function stripMessageIdBrackets(id: string): string {
// form: `Display Name <addr@example.com>`. Re-emitting that as the JMAP
// from.name field produces a doubled From header (`"Name <addr>" <addr>`)
// whose display-name is invalid per RFC 5322 §3.4 and gets rejected by the
// submission validator the email then sits forever in Drafts.
// submission validator - the email then sits forever in Drafts.
function sanitizeIdentityDisplayName(name: string | undefined | null): string {
if (!name) return '';
return name.replace(/\s*<[^>]*>\s*$/, '').trim();
+36 -15
View File
@@ -3,17 +3,31 @@ import { discoverOAuth } from '@/lib/oauth/discovery';
import type { OAuthMetadata } from '@/lib/oauth/discovery';
import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, findServerById } from '@/lib/admin/jmap-servers';
function getClientSecret(): string {
function getGlobalClientSecret(): string {
const adminSecret = configManager.get<string>('oauthClientSecret', '');
if (adminSecret) return adminSecret;
return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
}
export function getRequiredConfig() {
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
const serverUrl = configManager.get<string>('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
const issuerUrl = configManager.get<string>('oauthIssuerUrl', '') || process.env.OAUTH_ISSUER_URL;
function getServerEntry(serverId?: string | null) {
if (!serverId) return undefined;
const servers = parseJmapServers(configManager.get<unknown>('jmapServers', []));
return findServerById(servers, serverId);
}
export function getRequiredConfig(serverId?: string | null) {
const entry = getServerEntry(serverId);
const globalClientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
const globalServerUrl = configManager.get<string>('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
const globalIssuerUrl = configManager.get<string>('oauthIssuerUrl', '') || process.env.OAUTH_ISSUER_URL;
const clientId = entry?.oauth?.clientId || globalClientId;
const serverUrl = entry?.url || globalServerUrl;
const issuerUrl = entry?.oauth?.issuerUrl || globalIssuerUrl;
if (!clientId || !serverUrl) {
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
}
@@ -21,11 +35,17 @@ export function getRequiredConfig() {
if (issuerUrl !== undefined && issuerUrl !== '' && !issuerUrl.trim()) {
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
}
return { clientId, serverUrl, discoveryUrl };
return { clientId, serverUrl, discoveryUrl, serverId: entry?.id };
}
export async function getTokenEndpoint(): Promise<string> {
const { discoveryUrl } = getRequiredConfig();
function getClientSecret(serverId?: string | null): string {
const entry = getServerEntry(serverId);
if (entry?.oauth?.clientSecret) return entry.oauth.clientSecret;
return getGlobalClientSecret();
}
export async function getTokenEndpoint(serverId?: string | null): Promise<string> {
const { discoveryUrl } = getRequiredConfig(serverId);
const metadata = await discoverOAuth(discoveryUrl);
if (!metadata?.token_endpoint) {
throw new Error('OAuth token endpoint not found');
@@ -33,15 +53,15 @@ export async function getTokenEndpoint(): Promise<string> {
return metadata.token_endpoint;
}
export async function getMetadata(): Promise<OAuthMetadata | null> {
const { discoveryUrl } = getRequiredConfig();
export async function getMetadata(serverId?: string | null): Promise<OAuthMetadata | null> {
const { discoveryUrl } = getRequiredConfig(serverId);
return discoverOAuth(discoveryUrl);
}
export function buildOAuthParams(base: Record<string, string>): URLSearchParams {
const { clientId } = getRequiredConfig();
export function buildOAuthParams(base: Record<string, string>, serverId?: string | null): URLSearchParams {
const { clientId } = getRequiredConfig(serverId);
const params = new URLSearchParams({ ...base, client_id: clientId });
const secret = getClientSecret();
const secret = getClientSecret(serverId);
if (secret) {
params.set('client_secret', secret);
}
@@ -58,15 +78,16 @@ export async function exchangeCodeForTokens(
code: string,
codeVerifier: string,
redirectUri: string,
serverId?: string | null,
): Promise<TokenResult> {
const tokenEndpoint = await getTokenEndpoint();
const tokenEndpoint = await getTokenEndpoint(serverId);
const params = buildOAuthParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
});
}, serverId);
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
+7 -1
View File
@@ -2,8 +2,14 @@ const DEFAULT_SCOPES = 'openid email profile';
const EXTRA_SCOPES = process.env.OAUTH_EXTRA_SCOPES || '';
export const OAUTH_SCOPES = process.env.OAUTH_SCOPES || (EXTRA_SCOPES ? `${DEFAULT_SCOPES} ${EXTRA_SCOPES}`.trim() : DEFAULT_SCOPES);
export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
export const REFRESH_TOKEN_SERVER_COOKIE = 'jmap_rts';
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
/** Get the cookie name for a given account slot. Slot 0 uses the legacy name. */
export function refreshTokenCookieName(slot: number): string {
return slot === 0 ? REFRESH_TOKEN_COOKIE : `${REFRESH_TOKEN_COOKIE}_${slot}`;
}
/** Companion cookie storing which server entry id minted the refresh token at this slot. */
export function refreshTokenServerCookieName(slot: number): string {
return slot === 0 ? REFRESH_TOKEN_SERVER_COOKIE : `${REFRESH_TOKEN_SERVER_COOKIE}_${slot}`;
}
+131
View File
@@ -108,6 +108,75 @@ function createPluginLogger(pluginId: string) {
};
}
// --- Cross-origin fetch helpers ------------------------------
/**
* Returns true when `url`'s origin is allowed by one of the plugin's
* declared `httpOrigins` patterns. Patterns are either a literal origin
* (`https://host[:port]`) or a wildcard subdomain form (`https://*.host`).
*
* Wildcards match exactly one subdomain layer above `host` - e.g.
* `https://*.example.com` matches `https://a.example.com` but NOT
* `https://example.com` and NOT `https://a.b.example.com`. This mirrors how
* the CSP frame-src handles wildcards and avoids accidentally widening
* access when the manifest only intended a single tier.
*/
function originMatchesAllowlist(url: URL, allowlist: string[]): boolean {
if (url.protocol !== 'https:') return false;
for (const entry of allowlist) {
let parsed: URL;
try {
parsed = new URL(entry.replace('*.', ''));
} catch {
continue;
}
if (parsed.protocol !== 'https:') continue;
const port = url.port || '';
const expectedPort = parsed.port || '';
if (port !== expectedPort) continue;
if (entry.includes('*.')) {
const suffix = '.' + parsed.hostname.toLowerCase();
if (url.hostname.toLowerCase().endsWith(suffix)) {
const prefix = url.hostname.slice(0, url.hostname.length - suffix.length);
// Require exactly one non-empty subdomain label.
if (prefix.length > 0 && !prefix.includes('.')) return true;
}
} else {
if (url.hostname.toLowerCase() === parsed.hostname.toLowerCase()) return true;
}
}
return false;
}
// --- Cross-origin fetch types --------------------------------
export interface PluginFetchInit {
/** HTTP method. Defaults to GET. */
method?: string;
/** Request headers. Plain object only - no Headers / cookies forwarded. */
headers?: Record<string, string>;
/** Body. Plain string, ArrayBuffer, Uint8Array, Blob, or FormData. */
body?: string | ArrayBuffer | ArrayBufferView | Blob | FormData | null;
/** Optional AbortSignal for cancellation. */
signal?: AbortSignal;
}
export interface PluginFetchResponse {
ok: boolean;
status: number;
statusText: string;
/** Response headers, lower-cased keys. */
headers: Record<string, string>;
/** Resolves the body as text. */
text: () => Promise<string>;
/** Resolves the body as parsed JSON, or null on parse error. */
json: () => Promise<unknown>;
/** Resolves the body as raw bytes. */
arrayBuffer: () => Promise<ArrayBuffer>;
/** Resolves the body as a Blob. */
blob: () => Promise<Blob>;
}
// --- PluginAPI interface -------------------------------------
export interface PluginAPI {
@@ -137,6 +206,16 @@ export interface PluginAPI {
};
http: {
post: (path: string, body: Record<string, unknown>) => Promise<{ ok: boolean; status: number; data: unknown }>;
/**
* Cross-origin fetch against an origin declared in the manifest's
* `httpOrigins` allowlist. Requires `http:fetch` permission.
*
* No webmail credentials are forwarded - the plugin must supply its own
* `Authorization` (or other auth) header. Each call is gated on origin
* even when the URL came from plugin settings, so a user-pasted URL
* outside the allowlist is rejected at the boundary.
*/
fetch: (url: string, init?: PluginFetchInit) => Promise<PluginFetchResponse>;
};
storage: ReturnType<typeof createPluginStorage>;
log: ReturnType<typeof createPluginLogger>;
@@ -759,6 +838,58 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
const data = await res.json().catch(() => null);
return { ok: res.ok, status: res.status, data };
},
fetch: async (rawUrl: string, init?: PluginFetchInit) => {
requirePermission(plugin, 'http:fetch');
if (typeof rawUrl !== 'string') {
throw new Error('url must be a string');
}
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new Error('url must be an absolute https:// URL');
}
const allowlist = plugin.httpOrigins ?? [];
if (allowlist.length === 0) {
throw new Error(`Plugin "${plugin.id}" has no httpOrigins declared`);
}
if (!originMatchesAllowlist(url, allowlist)) {
throw new Error(`Origin ${url.origin} not in plugin httpOrigins allowlist`);
}
// Defence-in-depth: don't let the plugin smuggle a header that the
// host's same-origin /api flow uses to authenticate the user.
const safeHeaders: Record<string, string> = {};
if (init?.headers) {
for (const [k, v] of Object.entries(init.headers)) {
const lower = k.toLowerCase();
if (lower === 'cookie' || lower === 'x-jmap-username') continue;
safeHeaders[k] = v;
}
}
const res = await fetch(url.toString(), {
method: init?.method ?? 'GET',
headers: safeHeaders,
// eslint-disable-next-line no-undef
body: (init?.body ?? undefined) as BodyInit | undefined,
signal: init?.signal,
credentials: 'omit',
mode: 'cors',
redirect: 'follow',
});
const headersOut: Record<string, string> = {};
res.headers.forEach((value, key) => { headersOut[key.toLowerCase()] = value; });
return {
ok: res.ok,
status: res.status,
statusText: res.statusText,
headers: headersOut,
text: () => res.text(),
json: () => res.json().catch(() => null),
arrayBuffer: () => res.arrayBuffer(),
blob: () => res.blob(),
};
},
},
storage: createPluginStorage(plugin.id),
+5 -1
View File
@@ -59,6 +59,10 @@ export const pluginErrorTracker = new PluginErrorTracker();
// ─── Timeout Helper ──────────────────────────────────────────
const DEFAULT_TIMEOUT_MS = 5000;
// Intercept hooks frequently block on user confirmation modals (send,
// reply-all, mailto, attachment upload), so they need a much longer budget
// than observer / transform hooks.
const INTERCEPT_TIMEOUT_MS = 60_000;
function withTimeout<T>(promise: T | Promise<T>, ms: number = DEFAULT_TIMEOUT_MS): Promise<T> {
if (!(promise instanceof Promise)) return Promise.resolve(promise);
@@ -137,7 +141,7 @@ export class HookBus<T extends (...args: any[]) => any> {
for (const { pluginId, handler } of this.handlers) {
if (pluginErrorTracker.isDisabled(pluginId)) continue;
try {
const result = await withTimeout(handler(...args));
const result = await withTimeout(handler(...args), INTERCEPT_TIMEOUT_MS);
if (result === false) return false;
} catch (err) {
pluginErrorTracker.record(pluginId, err);
+25
View File
@@ -0,0 +1,25 @@
// Projection helpers - convert host-internal types into the read-only views
// that plugins consume. Keeping this in one place ensures every slot/hook
// hands plugins the same shape declared in plugin-types.ts.
import type { Email } from '@/lib/jmap/types';
import type { EmailReadView } from '@/lib/plugin-types';
export function emailToReadView(email: Email): EmailReadView {
return {
id: email.id,
threadId: email.threadId,
mailboxIds: Object.keys(email.mailboxIds || {}).filter(k => email.mailboxIds[k]),
from: (email.from || []).map(a => ({ name: a.name || '', email: a.email })),
to: (email.to || []).map(a => ({ name: a.name || '', email: a.email })),
cc: (email.cc || []).map(a => ({ name: a.name || '', email: a.email })),
subject: email.subject || '',
receivedAt: email.receivedAt,
isRead: !!email.keywords?.['$seen'],
isFlagged: !!email.keywords?.['$flagged'],
hasAttachment: email.hasAttachment,
preview: email.preview || '',
keywords: Object.keys(email.keywords || {}).filter(k => email.keywords[k]),
auth: email.authenticationResults,
};
}
+71 -2
View File
@@ -53,7 +53,23 @@ export interface ThemeManifest {
author: string;
description: string;
type: 'theme';
/** @deprecated kept as alias for `banner` so existing themes still work. */
preview?: string;
/**
* Path inside the source repo (relative to manifest.json) to a square
* brand icon shown in marketplace cards and the host's theme picker.
*/
icon?: string;
/**
* Path to a wide promo image shown as the hero on the theme detail
* page. PNG/JPG/WebP, ≤512 KB.
*/
banner?: string;
/**
* Up to 6 screenshot paths shown in the gallery on the detail page.
* Themes typically use this to show light + dark variants.
*/
screenshots?: string[];
variants: ThemeVariant[];
minAppVersion?: string;
@@ -100,6 +116,36 @@ export interface PluginManifest {
* Validated at install time and merged into the host CSP `frame-src`.
*/
frameOrigins?: string[];
/**
* External HTTPS origins this plugin may make `api.http.fetch()` requests
* to. Same syntax as `frameOrigins`. Validated at install time. Each
* `api.http.fetch` call's URL must resolve to one of these origins (exact
* host or a `*.host` wildcard match).
*
* Use for plugins that talk directly to a third-party service (e.g.
* Nextcloud, Slack) instead of going through a same-origin /api/* route.
* The remote host must serve CORS headers permitting the webmail origin.
*/
httpOrigins?: string[];
// ─── Marketplace media (NOT shipped in the runtime zip) ──────
/**
* Path inside the source repo (relative to manifest.json) to a square
* brand icon. PNG/SVG/WebP, ≤256 KB, 128×128 or larger recommended.
* The extension directory ingests this from git and serves it on
* marketplace cards and the host's plugin admin UI.
*/
icon?: string;
/**
* Path to a wide promo image (16:9 recommended), shown as the hero on
* the extension detail page. PNG/JPG/WebP, ≤512 KB.
*/
banner?: string;
/**
* Up to 6 screenshot paths shown in the gallery on the detail page.
* Each ≤512 KB; total ≤2 MB. Order is preserved.
*/
screenshots?: string[];
}
export interface SettingFieldSchema {
@@ -168,6 +214,16 @@ export interface InstalledPlugin {
settings: Record<string, unknown>;
/** Bundled translations, carried over from the manifest on install. */
locales?: Record<string, Record<string, string>>;
/**
* Content hash of the installed bundle, mirrored from the server. Used to
* detect re-uploads of the same version so clients re-download the JS.
*/
bundleHash?: string;
/**
* Validated allowlist of external HTTPS origins this plugin may target via
* `api.http.fetch()`. Carried over from the manifest at install time.
*/
httpOrigins?: string[];
}
// ─── UI Slots ────────────────────────────────────────────────
@@ -295,6 +351,17 @@ export interface EmailReadView {
hasAttachment: boolean;
preview: string;
keywords: string[];
/**
* Parsed Authentication-Results header (SPF, DKIM, DMARC, reverse-DNS).
* Absent on stores that didn't parse the header (e.g. bodies not yet
* fetched). Mirrors the structured shape exposed by the host.
*/
auth?: {
spf?: { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror'; domain?: string };
dkim?: { result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror'; domain?: string; selector?: string };
dmarc?: { result: 'pass' | 'fail' | 'none'; policy?: 'reject' | 'quarantine' | 'none'; domain?: string };
iprev?: { result: 'pass' | 'fail'; ip?: string };
};
}
export interface DraftView {
@@ -536,6 +603,8 @@ export interface OutgoingEmail {
htmlBody: string;
textBody: string;
identityId: string;
/** Sender email derived from the active identity (incl. sub-address tag, when set) */
fromEmail?: string;
attachments: { name: string; type: string; size: number }[];
/** Original message id when this is a reply or forward */
inReplyTo?: string;
@@ -609,7 +678,7 @@ export interface SelectionContext {
export interface ConflictWarning {
/** Stable unique key per warning, used as React key */
key: string;
/** Short message e.g. "Conflicts with: Team Standup" */
/** Short message - e.g. "Conflicts with: Team Standup" */
message: string;
severity?: 'info' | 'warning' | 'error';
}
@@ -708,7 +777,7 @@ export const ALL_PERMISSIONS = [
'settings:read', 'settings:write',
'security:read',
'auth:observe',
'http:post',
'http:post', 'http:fetch',
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
'ui:composer-toolbar', 'ui:composer-sidebar',
'ui:sidebar-widget', 'ui:settings-section',
+5 -2
View File
@@ -2,6 +2,7 @@ import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
export interface StalwartCredentials {
/** URL of the JMAP server (used for JMAP + management method calls) */
@@ -15,14 +16,16 @@ export interface StalwartCredentials {
function parseSlot(raw: string | null): number | null {
if (raw === null) return null;
const slot = parseInt(raw, 10);
return Number.isNaN(slot) || slot < 0 || slot > 4 ? null : slot;
return Number.isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS ? null : slot;
}
const ALL_SLOTS = Array.from({ length: MAX_ACCOUNT_SLOTS }, (_, i) => i);
function getCandidateSlots(request: NextRequest): number[] {
const requestedSlot = parseSlot(request.headers.get('X-JMAP-Cookie-Slot'))
?? parseSlot(request.nextUrl.searchParams.get('slot'));
return requestedSlot === null ? [0, 1, 2, 3, 4] : [requestedSlot];
return requestedSlot === null ? ALL_SLOTS : [requestedSlot];
}
export async function getStalwartCredentials(request: NextRequest): Promise<StalwartCredentials | null> {
+1 -1
View File
@@ -19,7 +19,7 @@ export function isSupportedSubAddressDelimiter(value: string): value is SubAddre
}
// RFC 5321 atext "special" characters, minus alphanumerics and "@". A custom
// delimiter must be exactly one of these they're safe to embed in a local
// delimiter must be exactly one of these - they're safe to embed in a local
// part and unambiguously separate the user from the tag.
const VALID_DELIMITER_REGEX = /^[!#$%&'*+\-./=?^_`{|}~]$/;
+1 -1
View File
@@ -51,7 +51,7 @@ export async function fetchStatus(
if (!endpoint) return { ok: false, error: 'endpoint blank' };
if (!currentVersion) return { ok: false, error: 'current version blank' };
// Build the URL safely never inject the version as a raw path component.
// Build the URL safely - never inject the version as a raw path component.
let url: URL;
try {
url = new URL(endpoint);
+1 -1
View File
@@ -71,7 +71,7 @@ async function tick(): Promise<void> {
await scheduleNext(delay);
}
// Idempotent safe to call from instrumentation hot-reload in dev.
// Idempotent - safe to call from instrumentation hot-reload in dev.
export async function startScheduler(): Promise<void> {
if (disabledByEnv()) {
logger.info('version-check: scheduler not started (disabled by env)');
+1 -1
View File
@@ -25,7 +25,7 @@ const SUBSCRIPTION_REFRESH_THRESHOLD_DAYS = 7;
// Only `EmailDelivery` state-changes when new mail is actually delivered.
// `Email` fires for any mutation (sending, drafting, moving, marking read,
// deleting) and `Mailbox` fires for mailbox edits both produced spurious
// deleting) and `Mailbox` fires for mailbox edits - both produced spurious
// system notifications, so we keep them out of the push subscription.
// In-app sync uses a separate StateChange channel and is unaffected.
const PUSH_TYPES = ['EmailDelivery'] as const;