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:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,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}`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 = /^[!#$%&'*+\-./=?^_`{|}~]$/;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user