Merge branch 'main' into feature/scheduled-send

This commit is contained in:
Lucas Gaitzsch
2026-05-20 08:17:46 +02:00
244 changed files with 20839 additions and 3479 deletions
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { plainTextToComposerBody } from "../email-composer-utils";
describe("plainTextToComposerBody", () => {
it("returns an empty string for empty input", () => {
expect(plainTextToComposerBody("")).toBe("");
});
it("escapes HTML before building composer paragraphs", () => {
expect(plainTextToComposerBody("<script>alert('x') & \"q\"</script>")).toBe(
"<p>&lt;script&gt;alert(&#39;x&#39;) &amp; &quot;q&quot;&lt;/script&gt;</p>"
);
});
it("normalizes line endings and preserves single line breaks", () => {
expect(plainTextToComposerBody("line1\r\nline2\rline3")).toBe(
"<p>line1<br>line2<br>line3</p>"
);
});
it("splits paragraphs on blank lines", () => {
expect(plainTextToComposerBody("first\n\nsecond\nthird")).toBe(
"<p>first</p><p>second<br>third</p>"
);
});
});
+79 -9
View File
@@ -78,11 +78,67 @@ describe('email-sanitization', () => {
expect(clean).toContain('John Doe');
});
it('should remove images from signatures', () => {
const signature = '<p>John</p><img src="logo.png" alt="Logo">';
it('should allow img with https src', () => {
const signature = '<p>John</p><img src="https://cdn.example.com/logo.png" alt="Logo" width="120" height="40">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).toContain('<img');
expect(clean).toContain('src="https://cdn.example.com/logo.png"');
expect(clean).toContain('alt="Logo"');
expect(clean).toContain('width="120"');
expect(clean).toContain('height="40"');
});
it('should allow img with data:image/png;base64 src', () => {
const dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/AAAZ4gk3AAAAAXRSTlPM0jRW/QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAA1JREFUCNdjYGBgAAAABAABc7Rs9wAAAABJRU5ErkJggg==';
const signature = `<img src="${dataUri}" alt="Logo">`;
const clean = sanitizeSignatureHtml(signature);
expect(clean).toContain('<img');
expect(clean).toContain('data:image/png;base64,');
});
it('should allow img with data:image/jpeg, gif, webp', () => {
const cases = ['data:image/jpeg;base64,AAA', 'data:image/jpg;base64,AAA', 'data:image/gif;base64,AAA', 'data:image/webp;base64,AAA'];
for (const src of cases) {
const clean = sanitizeSignatureHtml(`<img src="${src}" alt="x">`);
expect(clean).toContain('<img');
expect(clean).toContain(src);
}
});
it('should strip img with http: src (https only)', () => {
const signature = '<img src="http://insecure.example.com/logo.png" alt="Logo">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('http://insecure.example.com');
expect(clean).not.toContain('<img');
expect(clean).toContain('John');
});
it('should strip img with javascript: src', () => {
const signature = '<img src="javascript:alert(1)" alt="x">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('javascript:');
expect(clean).not.toContain('<img');
});
it('should strip img with data:image/svg+xml src (SVG forbidden)', () => {
const signature = '<img src="data:image/svg+xml;base64,PHN2Zy8+" alt="x">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('data:image/svg');
expect(clean).not.toContain('<img');
});
it('should strip img with non-image data: URI', () => {
const signature = '<img src="data:text/html;base64,PHA+aGk8L3A+" alt="x">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('data:text/html');
expect(clean).not.toContain('<img');
});
it('should strip event handlers on img', () => {
const signature = '<img src="https://cdn.example.com/logo.png" alt="x" onerror="alert(1)" onload="alert(2)">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('onerror');
expect(clean).not.toContain('onload');
expect(clean).toContain('https://cdn.example.com/logo.png');
});
it('should remove video and audio tags', () => {
@@ -112,17 +168,31 @@ describe('email-sanitization', () => {
expect(sanitizeSignatureHtml(' ')).toBe('');
});
it('should be stricter than email sanitization', () => {
const html = '<p>Text</p><img src="pic.jpg"><table><tr><td>Data</td></tr></table>';
it('should be stricter than email sanitization for script-bearing tags', () => {
const html = '<p>Text</p><table><tr><td>Data</td></tr></table><video src="v.mp4"></video><iframe src="x"></iframe>';
const emailClean = sanitizeEmailHtml(html);
const signatureClean = sanitizeSignatureHtml(html);
// Email allows img and table
expect(emailClean).toContain('<img');
// Both preserve tables (signatures are universally table-based)
expect(emailClean).toContain('<table>');
expect(signatureClean).toContain('<table');
expect(signatureClean).toContain('Data');
// Signature blocks img but may allow some tables (verify in implementation)
expect(signatureClean).not.toContain('<img');
// Signature still blocks media and frames
expect(signatureClean).not.toContain('<video');
expect(signatureClean).not.toContain('<iframe');
expect(signatureClean).toContain('Text');
});
it('should preserve table layout attributes used by email signatures', () => {
const signature = '<table cellpadding="0" cellspacing="0" border="0"><tr><td valign="top" align="left" bgcolor="#fafafa" colspan="2">Name</td></tr></table>';
const clean = sanitizeSignatureHtml(signature);
expect(clean).toContain('cellpadding');
expect(clean).toContain('cellspacing');
expect(clean).toContain('valign');
expect(clean).toContain('align');
expect(clean).toContain('bgcolor');
expect(clean).toContain('colspan');
});
});
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { createHmac } from 'node:crypto';
import {
ImpersonationJwtError,
verifyImpersonationJwt,
impersonationReplayCache,
} from '@/lib/impersonation/jwt';
const SECRET = 'a'.repeat(64);
const ISSUER = 'platform-api/webmail';
function base64Url(input: Buffer | string): string {
return Buffer.from(input)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
function sign(payload: Record<string, unknown>, secret: string = SECRET, header: Record<string, unknown> = { alg: 'HS256', typ: 'JWT' }): string {
const h = base64Url(JSON.stringify(header));
const p = base64Url(JSON.stringify(payload));
const sig = createHmac('sha256', secret).update(`${h}.${p}`).digest();
return `${h}.${p}.${base64Url(sig)}`;
}
function basePayload(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
const now = Math.floor(Date.now() / 1000);
return {
iss: ISSUER,
iat: now,
exp: now + 120,
jti: 'jti-' + Math.random().toString(36).slice(2),
mailbox: 'alice@example.test',
...overrides,
};
}
describe('verifyImpersonationJwt', () => {
beforeEach(() => {
impersonationReplayCache.clear();
});
it('accepts a valid HS256 token', () => {
const token = sign(basePayload());
const claims = verifyImpersonationJwt(token, SECRET, { expectedIssuer: ISSUER });
expect(claims.mailbox).toBe('alice@example.test');
});
it('rejects non-HS256 algorithms', () => {
const header = { alg: 'none', typ: 'JWT' };
const h = base64Url(JSON.stringify(header));
const p = base64Url(JSON.stringify(basePayload()));
const token = `${h}.${p}.`;
expect(() => verifyImpersonationJwt(token, SECRET)).toThrow(ImpersonationJwtError);
});
it('rejects tokens with a forged signature', () => {
const token = sign(basePayload(), 'a-different-secret-that-is-also-long-enough-32');
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/signature/i);
});
it('rejects when secret is too short', () => {
const token = sign(basePayload());
expect(() => verifyImpersonationJwt(token, 'short')).toThrowError(/32 characters/);
});
it('rejects expired tokens', () => {
const now = Math.floor(Date.now() / 1000);
const token = sign(basePayload({ iat: now - 600, exp: now - 300 }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/expired/i);
});
it('rejects tokens with lifetime over the 300s ceiling', () => {
const now = Math.floor(Date.now() / 1000);
const token = sign(basePayload({ iat: now, exp: now + 3600 }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/lifetime/i);
});
it('rejects tokens with iss mismatch when expectedIssuer is set', () => {
const token = sign(basePayload({ iss: 'someone-else' }));
expect(() =>
verifyImpersonationJwt(token, SECRET, { expectedIssuer: ISSUER }),
).toThrowError(/issuer/i);
});
it("rejects mailbox containing '%'", () => {
const token = sign(basePayload({ mailbox: 'a%b@example.test' }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/'%'/);
});
it("rejects mailbox containing ':'", () => {
const token = sign(basePayload({ mailbox: 'a:b@example.test' }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/':'/);
});
it('rejects malformed tokens', () => {
expect(() => verifyImpersonationJwt('not.a.jwt.extra', SECRET)).toThrow();
expect(() => verifyImpersonationJwt('', SECRET)).toThrow();
});
it('honours nbf with skew', () => {
const now = Math.floor(Date.now() / 1000);
const token = sign(basePayload({ nbf: now + 600 }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/not yet valid/i);
});
});
describe('impersonationReplayCache', () => {
beforeEach(() => {
impersonationReplayCache.clear();
});
it('accepts a jti once and rejects it on second use', () => {
const now = Math.floor(Date.now() / 1000);
expect(impersonationReplayCache.consume('jti-1', now + 60, now)).toBe(true);
expect(impersonationReplayCache.consume('jti-1', now + 60, now)).toBe(false);
});
it('prunes expired jtis on next consume', () => {
const now = Math.floor(Date.now() / 1000);
impersonationReplayCache.consume('jti-old', now - 600, now - 600);
// Far in the future — pruning should clear the old entry.
expect(impersonationReplayCache.consume('jti-new', now + 60, now + 1000)).toBe(true);
// Re-using the old jti is allowed after pruning (security irrelevant since
// the token would fail signature/exp validation upstream).
expect(impersonationReplayCache.consume('jti-old', now + 60, now + 1000)).toBe(true);
});
});
+19
View File
@@ -155,6 +155,25 @@ describe('JMAPClient.sendEmail threading headers', () => {
expect(draft.references).toBeUndefined();
});
it('omits cc/bcc when arrays are empty so the server does not emit a bare Cc: header', async () => {
const client = createClient();
const captured = mockSendEmailFlow();
await client.sendEmail(
['recipient@example.com'],
'No copies',
'body',
[], [], 'identity-1', 'user@example.com',
);
const setCall = captured[2].methodCalls[0];
const create = setCall[1].create as Record<string, Record<string, unknown>>;
const draft = Object.values(create)[0];
expect(draft.cc).toBeUndefined();
expect(draft.bcc).toBeUndefined();
});
it('drops empty / whitespace-only ids rather than sending blank entries', async () => {
const client = createClient();
const captured = mockSendEmailFlow();
+61 -7
View File
@@ -1,6 +1,21 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { OAuthMetadata } from '../oauth/discovery';
const validateEndpoint = async (urlString: string) => {
try {
const url = new URL(urlString);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
if (url.username || url.password) return false;
const host = url.hostname.toLowerCase();
if (host === 'localhost' || host.endsWith('.local') || host.endsWith('.internal')) return false;
if (/^(127\.|169\.254\.|10\.|192\.168\.)/.test(host)) return false;
if (host === '::1' || host === '0.0.0.0') return false;
return true;
} catch {
return false;
}
};
const VALID_METADATA: OAuthMetadata = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
@@ -26,7 +41,7 @@ describe('oauth/discovery', () => {
json: () => Promise.resolve(VALID_METADATA),
}));
const result = await discoverOAuth('https://mail.example.com');
const result = await discoverOAuth('https://mail.example.com', { validateEndpoint });
expect(result).toEqual(VALID_METADATA);
expect(fetch).toHaveBeenCalledTimes(1);
@@ -43,7 +58,7 @@ describe('oauth/discovery', () => {
json: () => Promise.resolve(VALID_METADATA),
}));
const result = await discoverOAuth('https://fallback.example.com');
const result = await discoverOAuth('https://fallback.example.com', { validateEndpoint });
expect(result).toEqual(VALID_METADATA);
expect(fetch).toHaveBeenCalledTimes(2);
@@ -59,7 +74,7 @@ describe('oauth/discovery', () => {
.mockResolvedValueOnce({ ok: false, status: 404 })
.mockResolvedValueOnce({ ok: false, status: 404 }));
const result = await discoverOAuth('https://fail.example.com');
const result = await discoverOAuth('https://fail.example.com', { validateEndpoint });
expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
@@ -71,7 +86,7 @@ describe('oauth/discovery', () => {
json: () => Promise.resolve(VALID_METADATA),
}));
const result = await discoverOAuth('https://optional.example.com');
const result = await discoverOAuth('https://optional.example.com', { validateEndpoint });
expect(result?.revocation_endpoint).toBe('https://auth.example.com/revoke');
expect(result?.end_session_endpoint).toBe('https://auth.example.com/logout');
@@ -86,7 +101,46 @@ describe('oauth/discovery', () => {
})
.mockResolvedValueOnce({ ok: false, status: 404 }));
const result = await discoverOAuth('https://incomplete.example.com');
const result = await discoverOAuth('https://incomplete.example.com', { validateEndpoint });
expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
});
it('rejects metadata pointing at loopback / link-local hosts (SSRF guard)', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
issuer: 'https://evil.example.com',
authorization_endpoint: 'https://evil.example.com/authorize',
token_endpoint: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/',
}),
})
.mockResolvedValueOnce({ ok: false, status: 404 }));
const result = await discoverOAuth('https://evil.example.com', { validateEndpoint });
expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
});
it('rejects metadata pointing at private RFC1918 hosts (SSRF guard)', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
issuer: 'https://evil.example.com',
authorization_endpoint: 'https://evil.example.com/authorize',
token_endpoint: 'https://evil.example.com/token',
revocation_endpoint: 'http://127.0.0.1:9200/_cluster/state',
}),
})
.mockResolvedValueOnce({ ok: false, status: 404 }));
const result = await discoverOAuth('https://private-revoke.example.com', { validateEndpoint });
expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
@@ -98,8 +152,8 @@ describe('oauth/discovery', () => {
json: () => Promise.resolve(VALID_METADATA),
}));
const first = await discoverOAuth('https://cached.example.com');
const second = await discoverOAuth('https://cached.example.com');
const first = await discoverOAuth('https://cached.example.com', { validateEndpoint });
const second = await discoverOAuth('https://cached.example.com', { validateEndpoint });
expect(first).toEqual(VALID_METADATA);
expect(second).toEqual(VALID_METADATA);
-193
View File
@@ -1,193 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createPluginAPI, setSlotRegistrationBridge } from '../plugin-api';
import type { InstalledPlugin } from '../plugin-types';
import { clearAllHooks } from '../plugin-hooks';
function makePlugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
return {
id: 'test-plugin',
name: 'Test Plugin',
version: '1.0.0',
author: 'Test',
description: '',
type: 'ui-extension',
entrypoint: 'index.js',
permissions: [],
enabled: true,
status: 'running',
settings: {},
...overrides,
};
}
beforeEach(() => {
clearAllHooks();
localStorage.clear();
setSlotRegistrationBridge(null);
});
describe('createPluginAPI', () => {
it('exposes plugin info', () => {
const plugin = makePlugin();
const api = createPluginAPI(plugin);
expect(api.plugin.id).toBe('test-plugin');
expect(api.plugin.version).toBe('1.0.0');
});
it('returns a frozen copy of settings', () => {
const plugin = makePlugin({ settings: { key: 'val' } });
const api = createPluginAPI(plugin);
expect(api.plugin.settings).toEqual({ key: 'val' });
});
});
describe('plugin storage (scoped localStorage)', () => {
it('set and get a value', () => {
const api = createPluginAPI(makePlugin());
api.storage.set('foo', 42);
expect(api.storage.get('foo')).toBe(42);
});
it('scopes to plugin id', () => {
const api1 = createPluginAPI(makePlugin({ id: 'p1' }));
const api2 = createPluginAPI(makePlugin({ id: 'p2' }));
api1.storage.set('key', 'a');
api2.storage.set('key', 'b');
expect(api1.storage.get('key')).toBe('a');
expect(api2.storage.get('key')).toBe('b');
});
it('remove deletes a value', () => {
const api = createPluginAPI(makePlugin());
api.storage.set('x', 10);
api.storage.remove('x');
expect(api.storage.get('x')).toBeNull();
});
it('keys lists only plugin-scoped keys', () => {
const api = createPluginAPI(makePlugin({ id: 'kp' }));
api.storage.set('a', 1);
api.storage.set('b', 2);
localStorage.setItem('unrelated', 'val');
expect(api.storage.keys()).toContain('a');
expect(api.storage.keys()).toContain('b');
expect(api.storage.keys()).not.toContain('unrelated');
});
});
describe('plugin logger', () => {
it('prefixes log messages with plugin id', () => {
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
const api = createPluginAPI(makePlugin({ id: 'log-test' }));
api.log.info('hello');
expect(infoSpy).toHaveBeenCalledWith('[plugin:log-test]', 'hello');
infoSpy.mockRestore();
});
});
describe('hooks permission gating', () => {
it('returns no-op disposable without permission', () => {
const plugin = makePlugin({ permissions: [] }); // no email:read
const api = createPluginAPI(plugin);
const d = api.hooks.onEmailOpen(vi.fn());
expect(d).toBeDefined();
expect(d.dispose).toBeInstanceOf(Function);
});
it('registers handler when permission is granted', () => {
const plugin = makePlugin({ permissions: ['email:read'] });
const api = createPluginAPI(plugin);
const fn = vi.fn();
const d = api.hooks.onEmailOpen(fn);
expect(d).toBeDefined();
d.dispose(); // should not throw
});
});
describe('ui permission requirement', () => {
it('throws without ui:toolbar permission', () => {
const plugin = makePlugin({ permissions: [] });
const api = createPluginAPI(plugin);
expect(() => api.ui.registerToolbarAction({
id: 'test',
label: 'Test',
onClick: () => {},
})).toThrow('lacks permission');
});
it('does not throw with correct permission (slot bridge not set, returns no-op)', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const plugin = makePlugin({ permissions: ['ui:toolbar'] });
const api = createPluginAPI(plugin);
const d = api.ui.registerToolbarAction({ id: 'test', label: 'Test', onClick: () => {} });
expect(d.dispose).toBeInstanceOf(Function);
consoleSpy.mockRestore();
});
});
describe('slot registration bridge', () => {
it('calls bridge when set', () => {
const bridge = vi.fn((_name, _reg) => ({ dispose: () => {} }));
setSlotRegistrationBridge(bridge);
const plugin = makePlugin({ permissions: ['ui:email-footer'] });
const api = createPluginAPI(plugin);
const DummyComponent = () => null;
api.ui.registerEmailFooter(DummyComponent);
expect(bridge).toHaveBeenCalled();
});
});
describe('toast bridge', () => {
it('exposes success/error/info/warning methods', () => {
const plugin = makePlugin();
const api = createPluginAPI(plugin);
expect(api.toast.success).toBeInstanceOf(Function);
expect(api.toast.error).toBeInstanceOf(Function);
expect(api.toast.info).toBeInstanceOf(Function);
expect(api.toast.warning).toBeInstanceOf(Function);
});
});
describe('http.post path validation', () => {
function makeApi(permissions: string[] = ['http:post']) {
return createPluginAPI(makePlugin({ permissions }));
}
it('rejects protocol-relative URLs like //evil.example', async () => {
const api = makeApi();
await expect(api.http.post('//evil.example/collect', {})).rejects.toThrow('must start with /api/');
});
it('rejects absolute URLs to other origins', async () => {
const api = makeApi();
await expect(api.http.post('https://evil.example/steal', {})).rejects.toThrow('must start with /api/');
});
it('rejects paths not under /api/', async () => {
const api = makeApi();
await expect(api.http.post('/other/path', {})).rejects.toThrow('must start with /api/');
});
it('rejects paths that use backslash to bypass the check', async () => {
const api = makeApi();
await expect(api.http.post('/api/\\@evil.example', {})).rejects.toThrow();
});
it('throws without http:post permission', async () => {
const api = makeApi([]);
await expect(api.http.post('/api/jitsi', {})).rejects.toThrow('lacks permission');
});
it('accepts a valid /api/ path', async () => {
const api = makeApi();
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ url: 'https://meet.example.com/room' }),
});
const result = await api.http.post('/api/jitsi', { eventTitle: 'test' });
expect(result.ok).toBe(true);
expect(result.data).toEqual({ url: 'https://meet.example.com/room' });
});
});
+1 -45
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { SlotRegistration, InstalledPlugin } from '@/lib/plugin-types';
import type { InstalledPlugin } from '@/lib/plugin-types';
// We test the raw store by directly invoking Zustand
// Mock the external dependencies the store imports
@@ -28,10 +28,6 @@ vi.mock('@/lib/plugin-loader', () => ({
setupAutoDisable: vi.fn(),
}));
vi.mock('@/lib/plugin-api', () => ({
setSlotRegistrationBridge: vi.fn(),
}));
vi.mock('@/lib/plugin-hooks', () => ({
removeAllPluginHooks: vi.fn(),
}));
@@ -42,21 +38,6 @@ import { usePluginStore } from '@/stores/plugin-store';
function resetStore() {
usePluginStore.setState({
plugins: [],
slots: {
'toolbar-actions': [],
'email-banner': [],
'email-footer': [],
'composer-toolbar': [],
'composer-sidebar': [],
'composer-sidebar-right': [],
'sidebar-widget': [],
'email-detail-sidebar': [],
'settings-section': [],
'context-menu-email': [],
'navigation-rail-bottom': [],
'calendar-event-actions': [],
'admin-plugin-page': [],
},
initialized: false,
});
}
@@ -84,31 +65,6 @@ beforeEach(() => {
});
describe('usePluginStore', () => {
describe('registerSlot / dispose', () => {
it('adds registration to slot and removes on dispose', () => {
const { registerSlot } = usePluginStore.getState();
const reg: SlotRegistration = {
pluginId: 'p1',
component: () => null,
order: 100,
};
const disposable = registerSlot('toolbar-actions', reg);
expect(usePluginStore.getState().slots['toolbar-actions']).toHaveLength(1);
disposable.dispose();
expect(usePluginStore.getState().slots['toolbar-actions']).toHaveLength(0);
});
it('sorts registrations by order', () => {
const { registerSlot } = usePluginStore.getState();
registerSlot('email-banner', { pluginId: 'p1', component: () => null, order: 200 });
registerSlot('email-banner', { pluginId: 'p2', component: () => null, order: 50 });
registerSlot('email-banner', { pluginId: 'p3', component: () => null, order: 100 });
const regs = usePluginStore.getState().slots['email-banner'];
expect(regs.map(r => r.pluginId)).toEqual(['p2', 'p3', 'p1']);
});
});
describe('setPluginStatus', () => {
it('updates status for existing plugin', () => {
usePluginStore.setState({ plugins: [mockPlugin()] });
+170
View File
@@ -0,0 +1,170 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { parseMailto } from "../protocol-handlers/mailto";
import { listenForMailtoRequests } from "../protocol-handlers/session";
import { parseWebcal } from "../protocol-handlers/webcal";
const originalServiceWorkerDescriptor = Object.getOwnPropertyDescriptor(navigator, "serviceWorker");
function installServiceWorkerMock() {
const listeners = new Set<(event: MessageEvent) => void>();
const worker = { postMessage: vi.fn() };
const serviceWorker = {
ready: Promise.resolve({ active: worker }),
controller: worker,
addEventListener: vi.fn((type: string, listener: EventListener) => {
if (type === "message") listeners.add(listener as (event: MessageEvent) => void);
}),
removeEventListener: vi.fn((type: string, listener: EventListener) => {
if (type === "message") listeners.delete(listener as (event: MessageEvent) => void);
}),
};
Object.defineProperty(navigator, "serviceWorker", {
configurable: true,
value: serviceWorker,
});
return {
dispatch(data: unknown) {
listeners.forEach((listener) => listener(new MessageEvent("message", { data })));
},
};
}
afterEach(() => {
vi.restoreAllMocks();
if (originalServiceWorkerDescriptor) {
Object.defineProperty(navigator, "serviceWorker", originalServiceWorkerDescriptor);
return;
}
Reflect.deleteProperty(navigator, "serviceWorker");
});
describe("protocol handlers", () => {
describe("parseMailto", () => {
it("parses a single path recipient", () => {
expect(parseMailto("mailto:alice@example.com")).toEqual({
to: ["alice@example.com"],
cc: [],
bcc: [],
subject: "",
body: "",
});
});
it("parses multiple recipients with subject and body", () => {
expect(parseMailto("mailto:alice@example.com,bob@example.com?subject=Hello&body=Hi")).toMatchObject({
to: ["alice@example.com", "bob@example.com"],
subject: "Hello",
body: "Hi",
});
});
it("parses to, cc, and bcc query recipients", () => {
expect(parseMailto("mailto:?to=alice@example.com&cc=bob@example.com&bcc=eve@example.com")).toMatchObject({
to: ["alice@example.com"],
cc: ["bob@example.com"],
bcc: ["eve@example.com"],
});
});
it("decodes subject and body values", () => {
expect(parseMailto("mailto:alice@example.com?subject=Hello%20World&body=line1%0Aline2")).toMatchObject({
subject: "Hello World",
body: "line1\nline2",
});
});
it("preserves literal plus signs in query values", () => {
expect(parseMailto("mailto:?to=user+tag@example.com&subject=C++&body=a+b")).toMatchObject({
to: ["user+tag@example.com"],
subject: "C++",
body: "a+b",
});
});
it("rejects non-mailto URLs", () => {
expect(parseMailto("https://example.com")).toBeNull();
});
it("allows an empty mailto URL", () => {
expect(parseMailto("mailto:")).toEqual({
to: [],
cc: [],
bcc: [],
subject: "",
body: "",
});
});
it("removes control characters and caps recipients", () => {
const recipients = Array.from({ length: 250 }, (_, index) => `user${index}@example.com`).join(",");
const parsed = parseMailto(`mailto:${recipients}?subject=Hi%0ABcc:evil@example.com`);
expect(parsed?.to).toHaveLength(200);
expect(parsed?.subject).toBe("HiBcc:evil@example.com");
});
});
describe("parseWebcal", () => {
it("normalizes webcal to https", () => {
expect(parseWebcal("webcal://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
});
it("normalizes webcals to https", () => {
expect(parseWebcal("webcals://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
});
it("accepts https URLs", () => {
expect(parseWebcal("https://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
});
it("rejects unsupported protocols", () => {
expect(parseWebcal("ftp://example.com/calendar.ics")).toBeNull();
});
it("suggests a name from the path", () => {
expect(parseWebcal("webcal://example.com/team.ics")?.suggestedName).toBe("team");
});
it("falls back to hostname for suggested name", () => {
expect(parseWebcal("webcal://example.com/")?.suggestedName).toBe("example.com");
});
it("prefers a name query parameter", () => {
expect(parseWebcal("webcal://example.com/team.ics?name=Team%20Calendar")?.suggestedName).toBe("Team Calendar");
});
});
describe("listenForMailtoRequests", () => {
const mailtoValue = {
to: ["alice@example.com"],
cc: [],
bcc: [],
subject: "Hello",
body: "Hi",
};
it("accepts legacy service-worker mailto messages without a client id", () => {
const serviceWorker = installServiceWorkerMock();
const onMailto = vi.fn();
vi.spyOn(window, "focus").mockImplementation(() => undefined);
const cleanup = listenForMailtoRequests(onMailto, () => ({ path: "/", standalone: false }));
serviceWorker.dispatch({ type: "mailto-request", id: "legacy", value: mailtoValue });
expect(onMailto).toHaveBeenCalledWith(mailtoValue);
cleanup();
});
it("ignores service-worker mailto messages for another client", () => {
const serviceWorker = installServiceWorkerMock();
const onMailto = vi.fn();
const cleanup = listenForMailtoRequests(onMailto, () => ({ path: "/", standalone: false }));
serviceWorker.dispatch({ type: "mailto-request", id: "targeted", clientId: "other-client", value: mailtoValue });
expect(onMailto).not.toHaveBeenCalled();
cleanup();
});
});
});
+36 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { findReplyIdentityId } from '../reply-identity';
import { findReplyIdentityId, resolveReplyFrom } from '../reply-identity';
import type { Identity } from '../jmap/types';
const identities: Identity[] = [
@@ -49,4 +49,39 @@ describe('findReplyIdentityId', () => {
expect(selected).toBeNull();
});
});
describe('resolveReplyFrom', () => {
it('returns the matching identity with no override when exact match', () => {
expect(resolveReplyFrom(identities, { to: [{ email: 'harry@secondary.com' }] }))
.toEqual({ identityId: 'secondary' });
});
it('strips +tag before matching identities', () => {
expect(resolveReplyFrom(identities, { to: [{ email: 'harry+news@primary.com' }] }))
.toEqual({ identityId: 'primary' });
});
it('surfaces catch-all override when recipient is on an identity domain but not an identity', () => {
const result = resolveReplyFrom(identities, {
to: [{ email: 'stripe@primary.com', name: 'Stripe' }],
});
expect(result).toEqual({
identityId: 'primary',
overrideEmail: 'stripe@primary.com',
overrideName: 'Stripe',
});
});
it('prefers identity match over catch-all override when both appear', () => {
const result = resolveReplyFrom(identities, {
to: [{ email: 'harry@primary.com' }, { email: 'stripe@primary.com' }],
});
expect(result).toEqual({ identityId: 'primary' });
});
it('returns null when recipients are on foreign domains', () => {
expect(resolveReplyFrom(identities, { to: [{ email: 'nobody@elsewhere.com' }] }))
.toBeNull();
});
});
+252
View File
@@ -481,6 +481,258 @@ describe("round-trip: parse → generate → parse", () => {
});
});
describe("vCard 4.0 parsing (issue #289)", () => {
it("strips group prefix from property names (item1.EMAIL)", () => {
// Evolution / Apple Contacts emit grouped properties so an X-ABLABEL line
// can attach a label. We must still parse the EMAIL itself.
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Ada Lovelace",
"item1.EMAIL:ada@example.com",
"item1.X-ABLABEL:Personal",
"item2.TEL:tel:+1-555-0100",
"item2.X-ABLABEL:Mobile",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
const card = result[0];
expect(card.emails?.e0?.address).toBe("ada@example.com");
expect(card.phones?.p0?.number).toBe("+1-555-0100");
});
it("strips tel:/mailto: URI scheme from TEL/EMAIL values", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Alan Turing",
"EMAIL:mailto:alan@example.com",
"TEL;VALUE=uri:tel:+44-20-1234-5678",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].emails?.e0?.address).toBe("alan@example.com");
expect(result[0].phones?.p0?.number).toBe("+44-20-1234-5678");
});
it("maps PREF=n parameter to pref field", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Grace Hopper",
"EMAIL;PREF=1:grace@home.example",
"EMAIL;PREF=2:grace@work.example",
"TEL;PREF=1:+1-555-9999",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].emails?.e0?.pref).toBe(1);
expect(result[0].emails?.e1?.pref).toBe(2);
expect(result[0].phones?.p0?.pref).toBe(1);
});
it("decodes RFC 6868 caret-encoded parameter values", () => {
// ^n → LF, ^^ → ^, ^' → DQUOTE
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Test",
'ADR;LABEL="Line 1^nLine 2";TYPE=HOME:;;Sub St;Town;;;US',
"EMAIL:t@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].addresses?.a0?.fullAddress).toBe("Line 1\nLine 2");
expect(result[0].addresses?.a0?.contexts).toEqual({ private: true });
});
it("survives quoted parameter values containing semicolons", () => {
// Without quote-aware param splitting, the ; inside LABEL would shred
// the param list and the ADR would lose its TYPE.
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Lev",
'ADR;LABEL="Building A; Suite 12";TYPE=WORK:;;1 Plaza;NYC;NY;10001;US',
"EMAIL:lev@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].addresses?.a0?.fullAddress).toBe("Building A; Suite 12");
expect(result[0].addresses?.a0?.contexts).toEqual({ work: true });
expect(result[0].addresses?.a0?.locality).toBe("NYC");
});
it("parses BIRTHPLACE and DEATHPLACE (RFC 6474)", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Marie Curie",
"BDAY:18671107",
"BIRTHPLACE:Warsaw\\, Poland",
"DEATHDATE:19340704",
"DEATHPLACE:Passy\\, France",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
const annivs = Object.values(result[0].anniversaries || {});
const birth = annivs.find((a) => a.kind === "birth");
const death = annivs.find((a) => a.kind === "death");
expect(birth?.place?.fullAddress).toBe("Warsaw, Poland");
expect(death?.place?.fullAddress).toBe("Passy, France");
});
it("parses EXPERTISE / HOBBY / INTEREST with LEVEL (RFC 6715)", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Polymath",
"EXPERTISE;LEVEL=expert:cryptography",
"EXPERTISE;LEVEL=beginner:welding",
"HOBBY;LEVEL=high:gardening",
"INTEREST;LEVEL=medium:opera",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
const info = Object.values(result[0].personalInfo || {});
expect(info).toEqual(expect.arrayContaining([
{ kind: "expertise", value: "cryptography", level: "high" },
{ kind: "expertise", value: "welding", level: "low" },
{ kind: "hobby", value: "gardening", level: "high" },
{ kind: "interest", value: "opera", level: "medium" },
]));
});
it("parses ORG-DIRECTORY (RFC 6715) and CONTACT-URI (RFC 8605)", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Corp Person",
"ORG-DIRECTORY:https://example.com/staff/",
"CONTACT-URI;PREF=1:https://example.com/contact",
"EMAIL:c@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(Object.values(result[0].directories || {})[0]).toMatchObject({
uri: "https://example.com/staff/",
kind: "directory",
});
const links = Object.values(result[0].links || {});
expect(links[0]).toMatchObject({
uri: "https://example.com/contact",
kind: "contact",
pref: 1,
});
});
it("parses RFC 9554 CREATED, GRAMGENDER, PRONOUNS", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Modern Person",
"CREATED:20250101T120000Z",
"GRAMGENDER:neuter",
"PRONOUNS:they/them",
"PRONOUNS;PREF=2:ze/zir",
"EMAIL:m@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].created).toBe("20250101T120000Z");
expect(result[0].speakToAs?.grammaticalGender).toBe("neuter");
const pronouns = Object.values(result[0].speakToAs?.pronouns || {});
expect(pronouns).toEqual(expect.arrayContaining([
expect.objectContaining({ pronouns: "they/them" }),
expect.objectContaining({ pronouns: "ze/zir", pref: 2 }),
]));
});
it("accepts vCard 4.0 KIND values (location, device, application)", () => {
for (const k of ["location", "device", "application"] as const) {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
`KIND:${k}`,
"FN:Thing",
"END:VCARD",
].join("\r\n");
expect(parseVCard(vcf)[0].kind).toBe(k);
}
});
it("handles ADR with LABEL/GEO/TZ/CC parameters (RFC 9554)", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:GeoPerson",
'ADR;CC=DE;GEO="geo:52.5,13.4";TZ=Europe/Berlin;LABEL="Unter den Linden 1\\nBerlin":;;Unter den Linden 1;Berlin;;10117;Germany',
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
const addr = result[0].addresses?.a0;
expect(addr?.countryCode).toBe("DE");
expect(addr?.coordinates).toBe("52.5,13.4");
expect(addr?.timeZone).toBe("Europe/Berlin");
expect(addr?.fullAddress).toContain("Unter den Linden 1");
expect(addr?.locality).toBe("Berlin");
});
it("unfolds LF-only continuation lines (no CR)", () => {
// Unix exporters often use LF only; we must still unfold.
const vcf = "BEGIN:VCARD\nVERSION:4.0\nFN:John\n Doe\nEMAIL:j@d.com\nEND:VCARD";
const result = parseVCard(vcf);
expect(result[0].name?.components).toEqual(
expect.arrayContaining([{ kind: "given", value: "JohnDoe" }])
);
});
it("round-trips vCard 4.0-only properties through generateVCard", () => {
const original = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Round Trip",
"EMAIL;PREF=1:rt@example.com",
"BDAY:19700101",
"BIRTHPLACE:Somewhere",
"EXPERTISE;LEVEL=expert:vCard",
"HOBBY;LEVEL=medium:reading",
"ORG-DIRECTORY:https://example.com/dir",
"CONTACT-URI:https://example.com/contact",
"CREATED:20240101T000000Z",
"END:VCARD",
].join("\r\n");
const exported = generateVCard(parseVCard(original));
const reparsed = parseVCard(exported)[0];
expect(reparsed.emails?.e0?.pref).toBe(1);
expect(Object.values(reparsed.anniversaries || {}).find(a => a.kind === "birth")?.place?.fullAddress).toBe("Somewhere");
const info = Object.values(reparsed.personalInfo || {});
expect(info).toEqual(expect.arrayContaining([
{ kind: "expertise", value: "vCard", level: "high" },
{ kind: "hobby", value: "reading", level: "medium" },
]));
expect(Object.values(reparsed.directories || {})[0]?.uri).toBe("https://example.com/dir");
expect(Object.values(reparsed.links || {})[0]).toMatchObject({
uri: "https://example.com/contact",
kind: "contact",
});
expect(reparsed.created).toBe("20240101T000000Z");
});
});
describe("detectDuplicates", () => {
it("detects duplicates by matching email (case-insensitive)", () => {
const existing: ContactCard[] = [
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
describe('compareVersions', () => {
it('orders by major, minor, patch', () => {
expect(compareVersions('1.0.0', '1.0.0')).toBe(0);
expect(compareVersions('1.0.1', '1.0.0')).toBeGreaterThan(0);
expect(compareVersions('1.0.0', '1.0.1')).toBeLessThan(0);
expect(compareVersions('2.0.0', '1.9.9')).toBeGreaterThan(0);
expect(compareVersions('1.10.0', '1.9.0')).toBeGreaterThan(0);
});
it('treats missing segments as 0', () => {
expect(compareVersions('1', '1.0.0')).toBe(0);
expect(compareVersions('1.2', '1.2.0')).toBe(0);
});
it('tolerates a leading v', () => {
expect(compareVersions('v1.6.7', '1.6.7')).toBe(0);
});
it('ignores pre-release / build metadata', () => {
expect(compareVersions('1.6.7-rc.1', '1.6.7')).toBe(0);
expect(compareVersions('1.6.7+build.5', '1.6.7')).toBe(0);
});
});
describe('isVersionSatisfied', () => {
it('returns true when current >= required', () => {
expect(isVersionSatisfied('1.6.7', '1.6.7')).toBe(true);
expect(isVersionSatisfied('1.6.8', '1.6.7')).toBe(true);
expect(isVersionSatisfied('2.0.0', '1.9.9')).toBe(true);
});
it('returns false when current < required', () => {
expect(isVersionSatisfied('1.6.6', '1.6.7')).toBe(false);
expect(isVersionSatisfied('1.5.0', '1.6.0')).toBe(false);
expect(isVersionSatisfied('0.0.0', '1.0.0')).toBe(false);
});
it('treats empty / null / undefined required as no requirement', () => {
expect(isVersionSatisfied('1.0.0', '')).toBe(true);
expect(isVersionSatisfied('1.0.0', null)).toBe(true);
expect(isVersionSatisfied('1.0.0', undefined)).toBe(true);
});
});
+2 -2
View File
@@ -65,7 +65,7 @@ export function getAccountScopedKey(baseKey: string, accountId: string): string
/**
* 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.
* cookies on average - within Firefox's per-domain limit of 150.
*/
export const MAX_ACCOUNT_SLOTS = 50;
@@ -83,7 +83,7 @@ export const MAX_ACCOUNTS_HTTP1 = 5;
* 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.
* under-detect and fall back to the conservative cap - that's safe.
*/
export function isHttp2Available(): boolean {
if (typeof performance === 'undefined') return false;
+7 -14
View File
@@ -1,28 +1,23 @@
import { appendFile, stat, rename, mkdir } from 'node:fs/promises';
import { appendFile, stat, rename, readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
import { ensureStateDir, getStatePath } from './paths';
import type { AuditEntry } from './types';
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB
const MAX_ROTATIONS = 3;
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
const AUDIT_LOG_FILE = 'audit.log';
function getAuditLogPath(): string {
return path.join(getAdminDir(), 'audit.log');
return getStatePath(AUDIT_LOG_FILE);
}
/**
* Append an audit entry to the admin audit log.
* Append an audit entry to the admin audit log. Stored under the state dir
* so it remains writable when the config dir is mounted read-only.
*/
export async function auditLog(action: string, detail: Record<string, unknown>, ip: string): Promise<void> {
const dir = getAdminDir();
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
await ensureStateDir();
const entry: AuditEntry = {
ts: new Date().toISOString(),
@@ -64,7 +59,6 @@ async function rotateIfNeeded(logPath: string): Promise<void> {
export async function readAuditLog(page: number = 1, limit: number = 50, actionFilter?: string): Promise<{ entries: AuditEntry[]; total: number }> {
const logPath = getAuditLogPath();
try {
const { readFile } = await import('node:fs/promises');
const content = await readFile(logPath, 'utf-8');
const lines = content.trim().split('\n').filter(Boolean);
@@ -77,7 +71,6 @@ export async function readAuditLog(page: number = 1, limit: number = 50, actionF
}
const total = entries.length;
// Return newest first
entries.reverse();
const start = (page - 1) * limit;
return { entries: entries.slice(start, start + limit), total };
+36 -14
View File
@@ -1,13 +1,8 @@
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { readFile, writeFile, rename } from 'node:fs/promises';
import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
import { ensureConfigDir, getConfigPath, assertWritable } from './paths';
function parseEnvValue(value: string, type: string): unknown {
switch (type) {
@@ -127,6 +122,7 @@ class ConfigManager {
* Update admin config overrides. Writes to disk.
*/
async setAdminConfig(updates: Record<string, unknown>): Promise<void> {
assertWritable('update admin config');
Object.assign(this.adminConfig, updates);
await this.writeJsonFile('config.json', this.adminConfig);
}
@@ -135,10 +131,29 @@ class ConfigManager {
* Remove an admin override, reverting to env/default.
*/
async removeAdminOverride(key: string): Promise<void> {
assertWritable('remove admin override');
delete this.adminConfig[key];
await this.writeJsonFile('config.json', this.adminConfig);
}
/**
* Whether the setup wizard has completed. Used by middleware to gate the
* /setup routes and the rest of the app.
*/
isSetupComplete(): boolean {
return this.adminConfig.setupComplete === true;
}
/**
* Mark setup wizard as complete. Called by the wizard's finish endpoint
* after all other config has been written. Refuses in read-only mode.
*/
async markSetupComplete(): Promise<void> {
assertWritable('mark setup complete');
this.adminConfig.setupComplete = true;
await this.writeJsonFile('config.json', this.adminConfig);
}
/**
* Get the current settings policy.
*/
@@ -150,6 +165,7 @@ class ConfigManager {
* Update the settings policy. Writes to disk.
*/
async setPolicy(policy: SettingsPolicy): Promise<void> {
assertWritable('update settings policy');
this.policyCache = {
...DEFAULT_POLICY,
...policy,
@@ -167,7 +183,7 @@ class ConfigManager {
}
private async readJsonFile(filename: string): Promise<Record<string, unknown> | null> {
const filePath = path.join(getAdminDir(), filename);
const filePath = getConfigPath(filename);
try {
const raw = await readFile(filePath, 'utf-8');
return JSON.parse(raw);
@@ -179,15 +195,21 @@ class ConfigManager {
}
private async writeJsonFile(filename: string, data: Record<string, unknown>): Promise<void> {
const dir = getAdminDir();
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
const targetPath = path.join(dir, filename);
await ensureConfigDir();
const targetPath = getConfigPath(filename);
const tmpPath = targetPath + '.tmp';
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
await rename(tmpPath, targetPath);
}
}
export const configManager = new ConfigManager();
// Stash the singleton on globalThis so HMR / multiple module-evaluation
// boundaries (middleware vs route handlers in dev with turbopack) all share
// the same in-memory state. Without this, marking setupComplete=true in a
// route handler is invisible to the next middleware run, and the wizard
// redirect after finish never fires.
const SINGLETON_KEY = Symbol.for('bulwark.admin.configManager');
type GlobalWithConfig = typeof globalThis & { [SINGLETON_KEY]?: ConfigManager };
const g = globalThis as GlobalWithConfig;
export const configManager: ConfigManager =
g[SINGLETON_KEY] ?? (g[SINGLETON_KEY] = new ConfigManager());
+30
View File
@@ -60,6 +60,36 @@ export function sanitizeFrameOrigins(input: unknown): string[] {
export const sanitizeHttpOrigins = sanitizeFrameOrigins;
export const isValidHttpOrigin = isValidFrameOrigin;
// ─── apiPostPaths (manifest field) ────────────────────────────
/**
* Validates an `/api/...` path entry. Must start with `/api/`, contain only
* URL-path-safe characters, and have no `..` segment. The trailing slash is
* meaningful (treated as a prefix at enforcement time).
*/
export function isValidApiPostPath(path: unknown): path is string {
if (typeof path !== 'string') return false;
if (path.length === 0 || path.length > 200) return false;
if (!path.startsWith('/api/')) return false;
if (path.includes('..')) return false;
if (/[\s'"`;,()?#]/.test(path)) return false;
if (!/^[/A-Za-z0-9._-]+$/.test(path)) return false;
return true;
}
export function sanitizeApiPostPaths(input: unknown): string[] {
if (!Array.isArray(input)) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const value of input) {
if (!isValidApiPostPath(value)) continue;
if (seen.has(value)) continue;
seen.add(value);
out.push(value);
}
return out;
}
// 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
+196
View File
@@ -0,0 +1,196 @@
import { readFile, writeFile, rename, stat, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { logger } from '@/lib/logger';
import {
ensureConfigDir,
ensureStateDir,
getConfigPath,
getStatePath,
isConfigReadOnly,
} from './paths';
import type { AdminConfigData, AdminStateData } from './types';
const MIGRATION_MARKER = '.migrated-v2';
interface LegacyAdminData {
passwordHash: string;
createdAt?: string;
lastLogin?: string | null;
passwordChangedAt?: string;
}
/**
* One-shot migration from the v1 layout (everything mixed in `data/admin/`)
* to the v2 layout (config + state split, see lib/admin/paths.ts).
*
* Idempotent: writes a `.migrated-v2` marker into the config dir on success.
*
* Migrations performed:
* 1. admin.json with timestamps → admin.json (passwordHash only) +
* admin-state.json (createdAt, lastLogin, passwordChangedAt)
* 2. audit.log moved from config dir to state dir (by rename if same FS,
* else copy + delete).
*
* Skipped silently when the config dir is read-only - operators who already
* locked their config volume must do the migration manually before mounting
* :ro.
*/
export async function migrateLegacyAdminLayout(): Promise<void> {
if (isConfigReadOnly()) return;
const markerPath = getConfigPath(MIGRATION_MARKER);
if (existsSync(markerPath)) return;
let didWork = false;
try {
didWork = (await migrateAdminJson()) || didWork;
didWork = (await migrateAuditLog()) || didWork;
await ensureConfigDir();
await writeFile(markerPath, new Date().toISOString(), 'utf-8');
if (didWork) {
logger.info('Admin layout migrated to v2 (config/state split)');
}
} catch (error) {
logger.warn('Admin layout migration failed; will retry on next boot', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
/**
* If the existing admin.json carries timestamp fields (legacy mixed layout),
* split them into admin-state.json and rewrite admin.json without them.
* Returns true if a migration was performed.
*/
async function migrateAdminJson(): Promise<boolean> {
const adminJsonPath = getConfigPath('admin.json');
if (!existsSync(adminJsonPath)) return false;
let raw: string;
try {
raw = await readFile(adminJsonPath, 'utf-8');
} catch {
return false;
}
let data: LegacyAdminData;
try {
data = JSON.parse(raw) as LegacyAdminData;
} catch {
logger.warn('admin.json is not valid JSON; skipping migration');
return false;
}
const hasLegacyFields =
'createdAt' in data || 'lastLogin' in data || 'passwordChangedAt' in data;
if (!hasLegacyFields) return false; // already in v2 shape
if (!data.passwordHash || typeof data.passwordHash !== 'string') {
logger.warn('admin.json missing passwordHash; skipping migration');
return false;
}
const now = new Date().toISOString();
const stateData: AdminStateData = {
createdAt: data.createdAt ?? now,
lastLogin: data.lastLogin ?? null,
passwordChangedAt: data.passwordChangedAt ?? now,
};
const configData: AdminConfigData = { passwordHash: data.passwordHash };
await ensureStateDir();
const statePath = getStatePath('admin-state.json');
// If admin-state.json already exists, prefer its values: a previous
// migration may have succeeded and recorded fresh login timestamps that
// we'd otherwise stomp. The legacy admin.json data is older by definition.
if (!existsSync(statePath)) {
const stateTmp = statePath + '.tmp';
await writeFile(stateTmp, JSON.stringify(stateData, null, 2), 'utf-8');
await rename(stateTmp, statePath);
}
const configTmp = adminJsonPath + '.tmp';
await writeFile(configTmp, JSON.stringify(configData, null, 2), 'utf-8');
await rename(configTmp, adminJsonPath);
logger.info('Migrated admin.json: split timestamps into admin-state.json');
return true;
}
/**
* Move audit.log from the config dir to the state dir if present. Returns
* true if a migration was performed. Also moves rotated copies (audit.log.1
* through .3).
*/
async function migrateAuditLog(): Promise<boolean> {
const sources = [
'audit.log',
'audit.log.1',
'audit.log.2',
'audit.log.3',
];
let moved = false;
for (const name of sources) {
const src = getConfigPath(name);
if (!existsSync(src)) continue;
await ensureStateDir();
const dst = getStatePath(name);
try {
// Same-FS rename is atomic. Falls through to copy if cross-device.
await rename(src, dst);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'EXDEV') {
// Cross-device: copy bytes, then delete source.
const data = await readFile(src);
await writeFile(dst, data);
await unlink(src);
} else {
throw error;
}
}
moved = true;
}
if (moved) {
logger.info('Migrated audit.log to state dir');
}
return moved;
}
/**
* Returns approximate size of legacy data still mixed in the config dir
* (for diagnostics / admin UI). Always returns 0 once migration has run.
*/
export async function getLegacyDataInfo(): Promise<{ adminJsonHasTimestamps: boolean; auditLogInConfigDir: boolean }> {
let adminJsonHasTimestamps = false;
const adminJsonPath = getConfigPath('admin.json');
if (existsSync(adminJsonPath)) {
try {
const raw = await readFile(adminJsonPath, 'utf-8');
const parsed = JSON.parse(raw);
adminJsonHasTimestamps =
'createdAt' in parsed ||
'lastLogin' in parsed ||
'passwordChangedAt' in parsed;
} catch {
/* ignore */
}
}
let auditLogInConfigDir = false;
try {
await stat(getConfigPath('audit.log'));
auditLogInConfigDir = true;
} catch {
/* not present - good */
}
return { adminJsonHasTimestamps, auditLogInConfigDir };
}
+125 -84
View File
@@ -1,9 +1,14 @@
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { readFile, writeFile, rename } from 'node:fs/promises';
import { logger } from '@/lib/logger';
import type { AdminData } from './types';
import {
ensureConfigDir,
ensureStateDir,
getConfigPath,
getStatePath,
assertWritable,
} from './paths';
import type { AdminConfigData, AdminStateData } from './types';
const SCRYPT_KEYLEN = 64;
const SCRYPT_COST = 16384; // 2^14
@@ -11,13 +16,8 @@ const SCRYPT_BLOCK_SIZE = 8;
const SCRYPT_PARALLELIZATION = 1;
const SALT_LENGTH = 32;
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
function getAdminJsonPath(): string {
return path.join(getAdminDir(), 'admin.json');
}
const ADMIN_CONFIG_FILE = 'admin.json';
const ADMIN_STATE_FILE = 'admin-state.json';
function hashPassword(password: string): Promise<string> {
return new Promise((resolve, reject) => {
@@ -33,10 +33,8 @@ function hashPassword(password: string): Promise<string> {
function verifyPassword(password: string, stored: string): Promise<boolean> {
return new Promise((resolve, reject) => {
// Support both scrypt format and bcrypt-prefixed values
if (stored.startsWith('$scrypt$')) {
const parts = stored.split('$');
// $scrypt$N=...,r=...,p=...$salt$hash
if (parts.length !== 5) return resolve(false);
const paramStr = parts[2];
const salt = Buffer.from(parts[3], 'base64');
@@ -53,7 +51,6 @@ function verifyPassword(password: string, stored: string): Promise<boolean> {
resolve(timingSafeEqual(derivedKey, storedHash));
});
} else {
// Unknown format
resolve(false);
}
});
@@ -63,50 +60,84 @@ function isHashed(value: string): boolean {
return value.startsWith('$scrypt$') || value.startsWith('$2a$') || value.startsWith('$2b$');
}
async function readAdminData(): Promise<AdminData | null> {
const filePath = getAdminJsonPath();
// ─── Disk I/O ───────────────────────────────────────────────────────────────
async function readJson<T>(filePath: string): Promise<T | null> {
try {
const raw = await readFile(filePath, 'utf-8');
return JSON.parse(raw) as AdminData;
return JSON.parse(raw) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
logger.warn('Failed to read admin.json', { error: error instanceof Error ? error.message : 'Unknown error' });
logger.warn('Failed to read admin file', {
filePath,
error: error instanceof Error ? error.message : 'Unknown error',
});
return null;
}
}
async function writeAdminData(data: AdminData): Promise<void> {
const dir = getAdminDir();
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
const targetPath = getAdminJsonPath();
const tmpPath = targetPath + '.tmp';
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
await rename(tmpPath, targetPath);
async function readConfigData(): Promise<AdminConfigData | null> {
return readJson<AdminConfigData>(getConfigPath(ADMIN_CONFIG_FILE));
}
let cachedAdminData: AdminData | null = null;
async function readStateData(): Promise<AdminStateData | null> {
return readJson<AdminStateData>(getStatePath(ADMIN_STATE_FILE));
}
async function writeConfigData(data: AdminConfigData): Promise<void> {
assertWritable('save admin password');
await ensureConfigDir();
const target = getConfigPath(ADMIN_CONFIG_FILE);
const tmp = target + '.tmp';
await writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8');
await rename(tmp, target);
}
async function writeStateData(data: AdminStateData): Promise<void> {
await ensureStateDir();
const target = getStatePath(ADMIN_STATE_FILE);
const tmp = target + '.tmp';
await writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8');
await rename(tmp, target);
}
// ─── Cache & init ───────────────────────────────────────────────────────────
let cachedConfig: AdminConfigData | null = null;
let cachedState: AdminStateData | null = null;
let initialized = false;
function freshState(): AdminStateData {
const now = new Date().toISOString();
return { createdAt: now, lastLogin: null, passwordChangedAt: now };
}
/**
* Initialize admin password on startup.
* If ADMIN_PASSWORD is cleartext, hash it and write to admin.json.
* Returns true if admin is enabled.
* - If admin.json exists, use it (state file may or may not exist; created on first need).
* - Otherwise, if ADMIN_PASSWORD env var is set, hash and persist it.
* - Otherwise, admin dashboard stays disabled.
*/
export async function initAdminPassword(): Promise<boolean> {
if (initialized) return cachedAdminData !== null;
if (initialized) return cachedConfig !== null;
// Check persistent file first
const existing = await readAdminData();
if (existing) {
cachedAdminData = existing;
const existingConfig = await readConfigData();
if (existingConfig) {
cachedConfig = existingConfig;
cachedState = (await readStateData()) ?? freshState();
if (!(await readStateData())) {
// No state file yet (fresh install or migration); create it.
try {
await writeStateData(cachedState);
} catch {
/* state dir may not be writable yet during early boot probes */
}
}
initialized = true;
logger.info('Admin dashboard enabled (password loaded from admin.json)');
return true;
}
// Check env var
const envPassword = process.env.ADMIN_PASSWORD;
if (!envPassword) {
initialized = true;
@@ -114,33 +145,17 @@ export async function initAdminPassword(): Promise<boolean> {
return false;
}
if (isHashed(envPassword)) {
// Already hashed in env - save to file
const data: AdminData = {
passwordHash: envPassword,
createdAt: new Date().toISOString(),
lastLogin: null,
passwordChangedAt: new Date().toISOString(),
};
await writeAdminData(data);
cachedAdminData = data;
initialized = true;
logger.info('Admin password hash saved to admin.json from environment variable');
return true;
}
// Cleartext - hash it
const hash = await hashPassword(envPassword);
const data: AdminData = {
passwordHash: hash,
createdAt: new Date().toISOString(),
lastLogin: null,
passwordChangedAt: new Date().toISOString(),
};
await writeAdminData(data);
cachedAdminData = data;
const hash = isHashed(envPassword) ? envPassword : await hashPassword(envPassword);
cachedConfig = { passwordHash: hash };
cachedState = freshState();
await writeConfigData(cachedConfig);
await writeStateData(cachedState);
initialized = true;
logger.warn('Admin password hashed and saved to admin.json. You may now remove ADMIN_PASSWORD from .env');
if (isHashed(envPassword)) {
logger.info('Admin password hash saved to admin.json from environment variable');
} else {
logger.warn('Admin password hashed and saved to admin.json. You may now remove ADMIN_PASSWORD from .env');
}
return true;
}
@@ -148,11 +163,9 @@ export async function initAdminPassword(): Promise<boolean> {
* Verify a password against the stored admin hash.
*/
export async function verifyAdminPassword(password: string): Promise<boolean> {
if (!cachedAdminData) {
cachedAdminData = await readAdminData();
}
if (!cachedAdminData) return false;
return verifyPassword(password, cachedAdminData.passwordHash);
if (!cachedConfig) cachedConfig = await readConfigData();
if (!cachedConfig) return false;
return verifyPassword(password, cachedConfig.passwordHash);
}
/**
@@ -163,14 +176,40 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
if (!valid) return false;
const hash = await hashPassword(newPassword);
if (!cachedAdminData) return false;
cachedConfig = { passwordHash: hash };
await writeConfigData(cachedConfig);
cachedAdminData = {
...cachedAdminData,
passwordHash: hash,
cachedState = {
...(cachedState ?? freshState()),
passwordChangedAt: new Date().toISOString(),
};
await writeAdminData(cachedAdminData);
await writeStateData(cachedState);
return true;
}
/**
* Set the admin password without verifying a current one. Used by the setup
* wizard during initial bootstrap.
*
* Refuses to overwrite an existing password unless `allowOverwrite` is true.
* The wizard's finish route passes `allowOverwrite: true` so a half-completed
* setup (admin.json left behind by an ADMIN_PASSWORD env var or an aborted
* earlier wizard run, while setupComplete is still false) can be recovered
* by simply running the wizard again. Safe because the finish route is
* already gated by the one-time setup token.
*/
export async function setInitialAdminPassword(
newPassword: string,
options: { allowOverwrite?: boolean } = {},
): Promise<boolean> {
const existing = await readConfigData();
if (existing && !options.allowOverwrite) return false;
const hash = await hashPassword(newPassword);
cachedConfig = { passwordHash: hash };
cachedState = freshState();
await writeConfigData(cachedConfig);
await writeStateData(cachedState);
initialized = true;
return true;
}
@@ -178,29 +217,31 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
* Update the last login timestamp.
*/
export async function updateLastLogin(): Promise<void> {
if (!cachedAdminData) return;
cachedAdminData = {
...cachedAdminData,
if (!cachedConfig) return;
cachedState = {
...(cachedState ?? freshState()),
lastLogin: new Date().toISOString(),
};
await writeAdminData(cachedAdminData);
try {
await writeStateData(cachedState);
} catch (error) {
logger.warn('Failed to update admin last-login state', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
/**
* Check if admin dashboard is enabled (has a password configured).
*/
export function isAdminEnabled(): boolean {
return cachedAdminData !== null;
return cachedConfig !== null;
}
/**
* Get admin metadata (without the hash).
*/
export function getAdminMeta(): { createdAt: string; lastLogin: string | null; passwordChangedAt: string } | null {
if (!cachedAdminData) return null;
return {
createdAt: cachedAdminData.createdAt,
lastLogin: cachedAdminData.lastLogin,
passwordChangedAt: cachedAdminData.passwordChangedAt,
};
export function getAdminMeta(): AdminStateData | null {
if (!cachedConfig) return null;
return cachedState ?? freshState();
}
+126
View File
@@ -0,0 +1,126 @@
import { existsSync } from 'node:fs';
import { mkdir, writeFile, unlink } from 'node:fs/promises';
import path from 'node:path';
import { logger } from '@/lib/logger';
/**
* Admin data directories.
*
* Two dirs intentionally split (issue #226):
* - CONFIG: holds operator-authored state (config.json, policy.json,
* admin.json passwordHash, plugins, themes, branding uploads). Can be
* mounted read-only after initial setup.
* - STATE: holds runtime mutations (admin-state.json with login timestamps,
* audit.log, .setup-token). Always read-write.
*
* Resolution order:
* getConfigDir()
* 1. ADMIN_CONFIG_DIR
* 2. ADMIN_DATA_DIR (legacy)
* 3. <cwd>/data/admin
*
* getStateDir()
* 1. ADMIN_STATE_DIR
* 2. <ADMIN_CONFIG_DIR>/state - if config dir was set explicitly
* 3. <ADMIN_DATA_DIR>/state - back-compat: stays on the legacy volume
* 4. <cwd>/data/admin-state - fresh-install default; matches the
* sibling mount in docker-compose.yml
*
* The legacy ADMIN_DATA_DIR keeps existing single-volume mounts working
* unchanged: everything ends up under it, with state in a `state/` subdir.
* Fresh installs and the docker-compose default keep state in a separate
* sibling dir so the config dir can be mounted :ro after setup.
*/
export function getConfigDir(): string {
return (
process.env.ADMIN_CONFIG_DIR ||
process.env.ADMIN_DATA_DIR ||
path.join(process.cwd(), 'data', 'admin')
);
}
export function getStateDir(): string {
if (process.env.ADMIN_STATE_DIR) return process.env.ADMIN_STATE_DIR;
if (process.env.ADMIN_CONFIG_DIR) {
return path.join(process.env.ADMIN_CONFIG_DIR, 'state');
}
if (process.env.ADMIN_DATA_DIR) {
return path.join(process.env.ADMIN_DATA_DIR, 'state');
}
return path.join(process.cwd(), 'data', 'admin-state');
}
export function getConfigPath(filename: string): string {
return path.join(getConfigDir(), filename);
}
export function getStatePath(filename: string): string {
return path.join(getStateDir(), filename);
}
export async function ensureConfigDir(): Promise<void> {
const dir = getConfigDir();
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
}
export async function ensureStateDir(): Promise<void> {
const dir = getStateDir();
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
}
// ─── Read-only mode ─────────────────────────────────────────────────────────
let cachedReadOnly: boolean | null = null;
/**
* Whether the config dir is locked. Operators set ADMIN_CONFIG_READONLY=true
* after running the setup wizard and remounting the volume :ro.
*
* When true, all writes to the config dir are refused at the application
* layer (cleaner error than a mid-request EROFS).
*/
export function isConfigReadOnly(): boolean {
if (cachedReadOnly !== null) return cachedReadOnly;
const v = (process.env.ADMIN_CONFIG_READONLY || '').toLowerCase();
cachedReadOnly = v === 'true' || v === '1' || v === 'yes';
return cachedReadOnly;
}
/**
* Probe the config dir by writing a temp file. Used to auto-detect RO mounts
* when ADMIN_CONFIG_READONLY is not set explicitly. Run once at startup;
* cheap on local FS, can be slow on networked FS, hence opt-in.
*/
export async function probeConfigReadOnly(): Promise<boolean> {
if (process.env.ADMIN_CONFIG_READONLY) return isConfigReadOnly();
try {
const probe = path.join(getConfigDir(), '.rw-probe');
await writeFile(probe, '');
await unlink(probe);
cachedReadOnly = false;
return false;
} catch {
cachedReadOnly = true;
logger.info('Config dir is read-only (auto-detected)');
return true;
}
}
export class ConfigReadOnlyError extends Error {
constructor(operation: string) {
super(
`Cannot ${operation}: configuration is read-only. ` +
`Remount the config volume read-write or unset ADMIN_CONFIG_READONLY.`
);
this.name = 'ConfigReadOnlyError';
}
}
export function assertWritable(operation: string): void {
if (isConfigReadOnly()) throw new ConfigReadOnlyError(operation);
}
+175
View File
@@ -0,0 +1,175 @@
// Server-side admin plugin-approval store.
//
// Closes the "C4" audit finding: previously a plugin's `adminApproved` flag
// was client-only, so a malicious user could enable a plugin past the policy
// gate via DevTools. The server now tracks per-(pluginId, bundleHash) status
// and the `enablePlugin` flow consults it before letting a non-managed plugin
// run.
//
// Each entry has one of three states: 'pending' (user installed, waiting for
// admin), 'approved' (admin signed off), 'denied' (admin refused — kept so we
// don't keep asking).
import { readFile, writeFile, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { logger } from '@/lib/logger';
import { ensureConfigDir, getConfigPath, assertWritable } from './paths';
export type ApprovalStatus = 'pending' | 'approved' | 'denied';
export interface ApprovalEntry {
pluginId: string;
bundleHash: string;
status: ApprovalStatus;
/** Snapshot of the manifest at request time. */
manifest: {
name?: string;
version?: string;
author?: string;
description?: string;
permissions?: string[];
httpOrigins?: string[];
apiPostPaths?: string[];
};
requestedBy: string; // JMAP username who triggered the request
requestedAt: string; // ISO 8601
decidedBy?: string; // admin username (set on approve/deny)
decidedAt?: string;
}
interface ApprovalsFile {
entries: ApprovalEntry[];
}
const APPROVALS_FILE = 'plugin-approvals.json';
const MAX_ENTRIES = 500; // hard cap so a misbehaving client can't grow the file unboundedly
let cached: ApprovalsFile | null = null;
let loadPromise: Promise<void> | null = null;
async function loadFromDisk(): Promise<ApprovalsFile> {
await ensureConfigDir();
const path = getConfigPath(APPROVALS_FILE);
if (!existsSync(path)) return { entries: [] };
try {
const raw = await readFile(path, 'utf-8');
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.entries)) return { entries: [] };
return { entries: parsed.entries.filter(isWellFormed) };
} catch (err) {
logger.warn('[plugin-approvals] failed to read file', { error: err instanceof Error ? err.message : String(err) });
return { entries: [] };
}
}
async function ensureLoaded(): Promise<void> {
if (cached !== null) return;
if (!loadPromise) {
loadPromise = (async () => { cached = await loadFromDisk(); })();
}
await loadPromise;
}
async function flushToDisk(): Promise<void> {
if (!cached) return;
await ensureConfigDir();
assertWritable('plugin-approvals.flushToDisk');
const path = getConfigPath(APPROVALS_FILE);
const tmp = `${path}.tmp`;
await writeFile(tmp, JSON.stringify(cached, null, 2), 'utf-8');
await rename(tmp, path);
}
function isWellFormed(value: unknown): value is ApprovalEntry {
if (!value || typeof value !== 'object') return false;
const v = value as Record<string, unknown>;
return (
typeof v.pluginId === 'string' &&
typeof v.bundleHash === 'string' &&
(v.status === 'pending' || v.status === 'approved' || v.status === 'denied') &&
typeof v.requestedBy === 'string' &&
typeof v.requestedAt === 'string' &&
typeof v.manifest === 'object' && v.manifest !== null
);
}
function findEntry(file: ApprovalsFile, pluginId: string, bundleHash: string): ApprovalEntry | undefined {
return file.entries.find(e => e.pluginId === pluginId && e.bundleHash === bundleHash);
}
// ─── Public API ──────────────────────────────────────────────
export async function listApprovals(): Promise<ApprovalEntry[]> {
await ensureLoaded();
return [...cached!.entries];
}
export async function getApprovalStatus(pluginId: string, bundleHash: string): Promise<{ status: ApprovalStatus | 'not-requested'; decidedAt?: string }> {
await ensureLoaded();
const entry = findEntry(cached!, pluginId, bundleHash);
if (!entry) return { status: 'not-requested' };
return { status: entry.status, decidedAt: entry.decidedAt };
}
export async function requestApproval(
pluginId: string,
bundleHash: string,
manifest: ApprovalEntry['manifest'],
requestedBy: string,
): Promise<ApprovalEntry> {
if (!pluginId || !bundleHash) throw new Error('pluginId and bundleHash required');
await ensureLoaded();
const file = cached!;
const existing = findEntry(file, pluginId, bundleHash);
if (existing) return existing;
if (file.entries.length >= MAX_ENTRIES) {
// Drop the oldest pending entry so a new request can land. Approved/denied
// entries are preserved.
const oldestPendingIdx = file.entries.findIndex(e => e.status === 'pending');
if (oldestPendingIdx >= 0) file.entries.splice(oldestPendingIdx, 1);
else throw new Error('plugin-approvals file is full');
}
const entry: ApprovalEntry = {
pluginId,
bundleHash,
status: 'pending',
manifest,
requestedBy,
requestedAt: new Date().toISOString(),
};
file.entries.push(entry);
await flushToDisk();
return entry;
}
export async function decideApproval(
pluginId: string,
bundleHash: string,
decision: 'approved' | 'denied',
decidedBy: string,
): Promise<ApprovalEntry> {
await ensureLoaded();
const file = cached!;
const entry = findEntry(file, pluginId, bundleHash);
if (!entry) throw new Error('approval entry not found');
entry.status = decision;
entry.decidedAt = new Date().toISOString();
entry.decidedBy = decidedBy;
await flushToDisk();
return entry;
}
export async function revokeApproval(pluginId: string, bundleHash: string): Promise<void> {
await ensureLoaded();
const file = cached!;
const idx = file.entries.findIndex(e => e.pluginId === pluginId && e.bundleHash === bundleHash);
if (idx < 0) return;
file.entries.splice(idx, 1);
await flushToDisk();
}
/** Force a re-read on next access. Used in tests / after a manual file edit. */
export function invalidateApprovalsCache(): void {
cached = null;
loadPromise = null;
}
+5 -5
View File
@@ -2,13 +2,10 @@ import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
import { getConfigDir, assertWritable } from './paths';
function getPluginConfigDir(): string {
return path.join(getAdminDir(), 'plugin-config');
return path.join(getConfigDir(), 'plugin-config');
}
function configPath(pluginId: string): string {
@@ -41,6 +38,7 @@ export async function getPluginConfig(pluginId: string): Promise<Record<string,
* Set a single config key for a plugin.
*/
export async function setPluginConfig(pluginId: string, key: string, value: unknown): Promise<void> {
assertWritable('update plugin config');
const dir = getPluginConfigDir();
await ensureDir(dir);
@@ -57,6 +55,7 @@ export async function setPluginConfig(pluginId: string, key: string, value: unkn
* Delete a single config key for a plugin.
*/
export async function deletePluginConfigKey(pluginId: string, key: string): Promise<void> {
assertWritable('delete plugin config key');
const config = await getPluginConfig(pluginId);
delete config[key];
@@ -77,5 +76,6 @@ export async function deletePluginConfigKey(pluginId: string, key: string): Prom
* Delete all config for a plugin (used when uninstalling).
*/
export async function deleteAllPluginConfig(pluginId: string): Promise<void> {
assertWritable('delete plugin config');
try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ }
}
+39 -23
View File
@@ -4,7 +4,7 @@ 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';
import { sanitizeFrameOrigins, sanitizeHttpOrigins, sanitizeApiPostPaths } from './csp-frame-origins';
/**
* Dev-mode plugin loading.
@@ -77,6 +77,32 @@ function resolveBundlePath(pluginDir: string, entrypoint: string): ResolvedBundl
return null;
}
async function bundleEntrypoint(bundlePath: string): Promise<string> {
const esbuild = await import('esbuild');
const result = await esbuild.build({
entryPoints: [bundlePath],
bundle: true,
// CJS format matches the sandbox runtime's evaluator
// (`new Function('module', 'exports', 'require', 'React', ...)`).
format: 'cjs',
platform: 'neutral',
write: false,
logLevel: 'silent',
sourcemap: 'inline',
target: ['es2020'],
// The runtime's `require` shim resolves these at evaluation time:
// react / react-dom / react-dom/client / react/jsx-runtime → host copies
// @plugin-host → the per-plugin `api` object
external: [
'react', 'react-dom', 'react-dom/client', 'react/jsx-runtime',
'@plugin-host',
],
});
const out = result.outputFiles?.[0]?.text;
if (!out) throw new Error('esbuild produced no output');
return out;
}
/**
* 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
@@ -88,22 +114,7 @@ export async function readDevBundle(entry: DevPluginEntry): Promise<string> {
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;
return await bundleEntrypoint(entry.bundlePath);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.warn(`[plugin-dev] esbuild failed for ${entry.plugin.id}`, { error: message });
@@ -142,15 +153,18 @@ async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null>
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).
// Hash from the exact bytes the bundle endpoint will serve so the client's
// verifyBundle check passes. For src/ sources that means running esbuild
// here too — slightly more work per manifest list, but unavoidable since
// the source hash wouldn't match the served bundle.
let bundleHash: string;
try {
const code = await readFile(resolved.bundlePath);
bundleHash = createHash('sha256').update(code).digest('hex').slice(0, 16);
const bytes = resolved.needsBundle
? await bundleEntrypoint(resolved.bundlePath)
: await readFile(resolved.bundlePath);
bundleHash = createHash('sha256').update(bytes).digest('hex');
} catch (err) {
logger.warn(`[plugin-dev] failed to read ${resolved.bundlePath} for ${id}`, {
logger.warn(`[plugin-dev] failed to hash bundle at ${resolved.bundlePath} for ${id}`, {
error: err instanceof Error ? err.message : String(err),
});
return null;
@@ -170,6 +184,7 @@ async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null>
const frameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
const httpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
const apiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
const plugin: ServerPlugin = {
id,
@@ -190,6 +205,7 @@ async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null>
: {}),
...(frameOrigins.length > 0 ? { frameOrigins } : {}),
...(httpOrigins.length > 0 ? { httpOrigins } : {}),
...(apiPostPaths.length > 0 ? { apiPostPaths } : {}),
installedAt,
updatedAt: new Date().toISOString(),
bundleHash,
+61 -11
View File
@@ -3,17 +3,14 @@ import { existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { logger } from '@/lib/logger';
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
import { getConfigDir, assertWritable } from './paths';
function getPluginsDir(): string {
return path.join(getAdminDir(), 'plugins');
return path.join(getConfigDir(), 'plugins');
}
function getThemesDir(): string {
return path.join(getAdminDir(), 'themes');
return path.join(getConfigDir(), 'themes');
}
// ─── Types ───────────────────────────────────────────────────
@@ -59,9 +56,12 @@ export interface ServerPlugin {
installedAt: string;
updatedAt: string;
/**
* SHA-256 hex of the bundle code (first 16 chars). Refreshed every save so
* Full SHA-256 hex of the bundle code (64 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.
* the client. Also doubles as the HTTP ETag for the bundle endpoint and is
* verified by the sandbox loader on every load
* (`lib/plugin-sandbox/bundle-integrity.ts`), so it must match the served
* bytes exactly.
*/
bundleHash?: string;
/**
@@ -74,6 +74,11 @@ export interface ServerPlugin {
* Same syntax as `frameOrigins`. Surfaced to clients via /api/plugins.
*/
httpOrigins?: string[];
/**
* Same-origin `/api/*` path allowlist for `api.http.post()`. See
* `InstalledPlugin.apiPostPaths` in `lib/plugin-types.ts`.
*/
apiPostPaths?: string[];
}
export interface ServerTheme {
@@ -128,8 +133,38 @@ async function writeJsonFile(filePath: string, data: unknown): Promise<void> {
const pluginRegistryPath = () => path.join(getPluginsDir(), 'registry.json');
const FULL_HASH_RE = /^[0-9a-f]{64}$/;
/**
* Older builds wrote a 16-char SHA-256 prefix into `bundleHash`. The current
* client-side verifyBundle requires equal-length hex (and the full digest for
* real integrity), so any registry entry with a truncated or otherwise
* malformed hash needs to be re-hashed from the on-disk bundle. If the bundle
* file is missing the hash is cleared so verifyBundle skips the check rather
* than refusing to load.
*/
async function migrateBundleHashes(registry: PluginRegistry): Promise<boolean> {
let changed = false;
for (const plugin of registry.plugins) {
if (!plugin.bundleHash || FULL_HASH_RE.test(plugin.bundleHash)) continue;
const bundlePath = path.join(getPluginsDir(), `${plugin.id}.js`);
try {
const code = await readFile(bundlePath);
plugin.bundleHash = createHash('sha256').update(code).digest('hex');
} catch {
delete plugin.bundleHash;
}
changed = true;
}
return changed;
}
export async function getPluginRegistry(): Promise<PluginRegistry> {
return readJsonFile<PluginRegistry>(pluginRegistryPath(), { plugins: [] });
const registry = await readJsonFile<PluginRegistry>(pluginRegistryPath(), { plugins: [] });
if (await migrateBundleHashes(registry)) {
try { await writeJsonFile(pluginRegistryPath(), registry); } catch { /* read-only fs ok */ }
}
return registry;
}
export async function getPlugin(id: string): Promise<ServerPlugin | null> {
@@ -137,10 +172,16 @@ export async function getPlugin(id: string): Promise<ServerPlugin | null> {
return registry.plugins.find(p => p.id === id) || null;
}
const SAFE_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
export async function savePlugin(
plugin: ServerPlugin,
code: string,
): Promise<void> {
assertWritable('install plugin');
if (!SAFE_ID_RE.test(plugin.id)) {
throw new Error('Invalid plugin id');
}
const dir = getPluginsDir();
await ensureDir(dir);
@@ -150,8 +191,9 @@ export async function savePlugin(
// 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);
// installedAt across re-uploads. The full SHA-256 is required because the
// client-side verifyBundle compares the entire digest length-checked.
const bundleHash = createHash('sha256').update(code).digest('hex');
const now = new Date().toISOString();
const registry = await getPluginRegistry();
@@ -171,6 +213,7 @@ export async function savePlugin(
}
export async function updatePluginMeta(id: string, updates: Partial<Pick<ServerPlugin, 'enabled' | 'forceEnabled'>>): Promise<ServerPlugin | null> {
assertWritable('update plugin metadata');
const registry = await getPluginRegistry();
const idx = registry.plugins.findIndex(p => p.id === id);
if (idx < 0) return null;
@@ -181,6 +224,7 @@ export async function updatePluginMeta(id: string, updates: Partial<Pick<ServerP
}
export async function deletePlugin(id: string): Promise<boolean> {
assertWritable('delete plugin');
const registry = await getPluginRegistry();
const idx = registry.plugins.findIndex(p => p.id === id);
if (idx < 0) return false;
@@ -221,6 +265,10 @@ export async function saveTheme(
theme: ServerTheme,
css: string,
): Promise<void> {
assertWritable('install theme');
if (!SAFE_ID_RE.test(theme.id)) {
throw new Error('Invalid theme id');
}
const dir = getThemesDir();
await ensureDir(dir);
@@ -240,6 +288,7 @@ export async function saveTheme(
}
export async function updateThemeMeta(id: string, updates: Partial<Pick<ServerTheme, 'enabled' | 'forceEnabled'>>): Promise<ServerTheme | null> {
assertWritable('update theme metadata');
const registry = await getThemeRegistry();
const idx = registry.themes.findIndex(t => t.id === id);
if (idx < 0) return null;
@@ -250,6 +299,7 @@ export async function updateThemeMeta(id: string, updates: Partial<Pick<ServerTh
}
export async function deleteTheme(id: string): Promise<boolean> {
assertWritable('delete theme');
const registry = await getThemeRegistry();
const idx = registry.themes.findIndex(t => t.id === id);
if (idx < 0) return false;
+98
View File
@@ -0,0 +1,98 @@
// Server-side Ed25519 signing for plugin bundles.
//
// Closes the "C2" audit finding: SHA-256 alone catches transport corruption
// but not a compromised server-side bundle store. With signing, even if an
// attacker swaps the bundle bytes in transit or at rest, the client refuses
// to load anything that doesn't verify against the host's public key.
//
// The keypair lives at `data/admin/plugin-signing.key` (PEM-encoded
// PKCS#8 private, mode 0600) and is generated lazily on first use. Operators
// who want to pin the key out-of-band can drop a pre-generated PEM at that
// path before first boot — the loader just reads what's there.
import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as nodeSign, KeyObject } from 'node:crypto';
import { readFile, writeFile, chmod } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { ensureConfigDir, getConfigPath, assertWritable } from './paths';
import { logger } from '@/lib/logger';
const KEY_FILENAME = 'plugin-signing.key';
let cached: { privateKey: KeyObject; publicKey: KeyObject } | null = null;
let initPromise: Promise<void> | null = null;
async function loadOrCreate(): Promise<{ privateKey: KeyObject; publicKey: KeyObject }> {
await ensureConfigDir();
const path = getConfigPath(KEY_FILENAME);
if (existsSync(path)) {
const pem = await readFile(path, 'utf-8');
const privateKey = createPrivateKey({ key: pem, format: 'pem' });
if (privateKey.asymmetricKeyType !== 'ed25519') {
throw new Error(`plugin-signing.key has wrong key type (${privateKey.asymmetricKeyType}); expected ed25519`);
}
const publicKey = createPublicKey(privateKey);
return { privateKey, publicKey };
}
// First boot: generate and persist. Use sync APIs so a half-written file
// never lingers if the process dies between writes.
assertWritable('plugin-signing.generateKeypair');
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
await writeFile(path, pem, { encoding: 'utf-8', mode: 0o600 });
// Ensure 0600 on filesystems that ignored mode on writeFile.
try { await chmod(path, 0o600); } catch { /* best effort */ }
logger.info('[plugin-signing] generated new Ed25519 keypair');
return { privateKey, publicKey };
}
async function ensureLoaded(): Promise<void> {
if (cached) return;
if (!initPromise) {
initPromise = (async () => {
try {
cached = await loadOrCreate();
} catch (err) {
initPromise = null;
logger.error('[plugin-signing] keypair load failed', { error: err instanceof Error ? err.message : String(err) });
throw err;
}
})();
}
await initPromise;
}
// ─── Public API ──────────────────────────────────────────────
/** Returns the public key as a raw 32-byte Uint8Array (Ed25519 standard form). */
export async function getPublicKeyRaw(): Promise<Uint8Array> {
await ensureLoaded();
// Export as SPKI DER and pull the last 32 bytes (the raw key after the
// 12-byte AlgorithmIdentifier prefix). Node has no built-in raw export
// for Ed25519, but the SPKI prefix is fixed for Ed25519 so the slice is
// safe.
const spki = cached!.publicKey.export({ type: 'spki', format: 'der' }) as Buffer;
if (spki.length < 32) throw new Error('SPKI export too short');
return new Uint8Array(spki.subarray(spki.length - 32));
}
/** Base64-encoded raw 32-byte public key (for embedding in HTTP responses). */
export async function getPublicKeyBase64(): Promise<string> {
const raw = await getPublicKeyRaw();
return Buffer.from(raw).toString('base64');
}
/** Sign `bytes` and return a base64-encoded 64-byte Ed25519 signature. */
export async function signBytes(bytes: Uint8Array | string): Promise<string> {
await ensureLoaded();
const data = typeof bytes === 'string' ? Buffer.from(bytes, 'utf-8') : Buffer.from(bytes);
const sig = nodeSign(null, data, cached!.privateKey);
return sig.toString('base64');
}
/** Force a re-read on next access. Used after operator rotates the key. */
export function invalidatePluginSigningCache(): void {
cached = null;
initPromise = null;
}
+47 -4
View File
@@ -1,7 +1,7 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
import { readFileEnv } from '@/lib/read-file-env';
import { getSessionSecret } from '@/lib/auth/session-secret';
import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types';
import type { AdminSessionPayload } from './types';
@@ -12,7 +12,7 @@ const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
function getKey(): Buffer {
const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
const secret = getSessionSecret();
if (!secret) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
@@ -81,9 +81,52 @@ export function verifyAdminSession(token: string): AdminSessionPayload | null {
}
/**
* Validate the admin session from cookies. Returns the payload or a 401 response.
* CSRF gate for cookie-authed admin requests.
*
* The admin session cookie is `SameSite=Lax`, which still allows top-level
* cross-site POST navigations (e.g. a form auto-submitted by an attacker
* page the admin is tricked into visiting). Without a CSRF check, any such
* page can trigger arbitrary state changes carrying the admin cookie.
*
* Strategy: state-changing requests must come from the same origin. Modern
* browsers (since 2020) always send `Sec-Fetch-Site` and that header
* cannot be set by JS, so it is the authoritative signal. Older browsers
* fall back to `Origin`. Non-browser clients (curl, scripts) send neither
* header and cannot ride a victim's cookie cross-origin, so the absence
* of both headers is allowed.
*/
export async function requireAdminAuth(): Promise<{ payload: AdminSessionPayload } | { error: NextResponse }> {
export function isSameOriginRequest(request: Request): boolean {
const method = request.method.toUpperCase();
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return true;
const fetchSite = request.headers.get('sec-fetch-site');
if (fetchSite !== null) {
return fetchSite === 'same-origin';
}
const origin = request.headers.get('origin');
if (!origin) return true;
try {
const originHost = new URL(origin).host;
const requestHost = request.headers.get('x-forwarded-host') ?? request.headers.get('host');
return !!requestHost && originHost === requestHost;
} catch {
return false;
}
}
/**
* Validate the admin session from cookies. Returns the payload or a 401 response.
*
* Also rejects cross-origin state-changing requests with 403 to prevent CSRF
* against cookie-authenticated admin actions.
*/
export async function requireAdminAuth(request: Request): Promise<{ payload: AdminSessionPayload } | { error: NextResponse }> {
if (!isSameOriginRequest(request)) {
return { error: NextResponse.json({ error: 'Cross-origin request rejected' }, { status: 403 }) };
}
const cookieStore = await cookies();
const token = cookieStore.get(ADMIN_SESSION_COOKIE)?.value;
+21 -1
View File
@@ -1,12 +1,30 @@
// Admin dashboard types
export interface AdminData {
/**
* Operator-authored admin record. Lives in admin.json under the config dir
* and can be mounted read-only after setup. Only the password hash itself
* is config; mutable timestamps live in AdminStateData.
*/
export interface AdminConfigData {
passwordHash: string;
}
/**
* Runtime-mutable admin record. Lives in admin-state.json under the state
* dir. Updated on every login and password change, so it must stay writable.
*/
export interface AdminStateData {
createdAt: string;
lastLogin: string | null;
passwordChangedAt: string;
}
/**
* Combined view used by getAdminMeta() and tests. Constructed by merging
* admin.json + admin-state.json at read time.
*/
export interface AdminData extends AdminConfigData, AdminStateData {}
export interface AdminSessionPayload {
role: 'admin';
iat: number;
@@ -128,6 +146,8 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', fileEnvVar: 'OAUTH_CLIENT_SECRET_FILE', type: 'string', defaultValue: '' },
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
oauthScopes: { envVar: 'OAUTH_SCOPES', type: 'string', defaultValue: '' },
oauthExtraScopes: { envVar: 'OAUTH_EXTRA_SCOPES', type: 'string', 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 },
+2 -2
View File
@@ -1,6 +1,6 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
import { getSessionSecret } from '@/lib/auth/session-secret';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
@@ -9,7 +9,7 @@ const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
function getKey(): Buffer {
const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
const secret = getSessionSecret();
if (!secret) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
+31
View File
@@ -0,0 +1,31 @@
import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env';
/**
* Resolve the session secret from any of the supported sources, in priority
* order:
* 1. SESSION_SECRET env var
* 2. SESSION_SECRET_FILE-pointed file
* 3. Admin override in config.json (set by the setup wizard)
*
* Returns an empty string when nothing is configured. Callers must treat
* empty as "feature disabled" rather than crashing.
*
* The configManager fallback exists so the web installer can persist the
* secret without touching .env files. It only takes effect if the env vars
* aren't set, so existing deployments aren't affected.
*/
export function getSessionSecret(): string {
const fromEnv = process.env.SESSION_SECRET;
if (fromEnv) return fromEnv;
const fromFile = readFileEnv(process.env.SESSION_SECRET_FILE);
if (fromFile) return fromFile;
const fromAdmin = configManager.get<string>('sessionSecret', '');
return fromAdmin || '';
}
export function hasSessionSecret(): boolean {
return getSessionSecret().length > 0;
}
+25
View File
@@ -40,6 +40,31 @@ export function validateProxyAuthHeader(authHeader: string): void {
}
}
/**
* For a `Basic` Authorization header, assert that the user portion of the
* credentials matches `claimedUsername`. Prevents callers of routes that
* accept independent `username` + `authHeader` fields from binding a cookie
* to one identity while authenticating as another. No-op for Bearer.
*/
export function assertBasicAuthMatchesUsername(authHeader: string, claimedUsername: string): void {
const match = /^Basic\s+(\S+)$/i.exec(authHeader);
if (!match) return;
let decoded: string;
try {
decoded = Buffer.from(match[1], 'base64').toString('utf8');
} catch {
throw new JmapAuthVerificationError('Invalid Authorization header', 400);
}
const colon = decoded.indexOf(':');
if (colon < 0) {
throw new JmapAuthVerificationError('Invalid Authorization header', 400);
}
const credUser = decoded.slice(0, colon);
if (credUser !== claimedUsername) {
throw new JmapAuthVerificationError('Username does not match credentials', 400);
}
}
export async function verifyJmapAuth(
serverUrl: string,
authHeader: string,
+13 -7
View File
@@ -414,8 +414,8 @@ export class DemoJMAPClient implements IJMAPClient {
receivedAt: new Date().toISOString(),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
to: to.map(e => ({ email: e })),
cc: cc?.map(e => ({ email: e })),
bcc: bcc?.map(e => ({ email: e })),
cc: cc?.length ? cc.map(e => ({ email: e })) : undefined,
bcc: bcc?.length ? bcc.map(e => ({ email: e })) : undefined,
subject,
sentAt: new Date().toISOString(),
preview: body.substring(0, 200),
@@ -455,6 +455,7 @@ export class DemoJMAPClient implements IJMAPClient {
inReplyTo?: string[],
references?: string[],
delayedUntil?: string,
_envelopeMailFrom?: string,
): Promise<SendEmailResult> {
// Remove draft if updating
if (draftId) {
@@ -469,8 +470,8 @@ export class DemoJMAPClient implements IJMAPClient {
receivedAt: new Date().toISOString(),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
to: to.map(e => ({ email: e })),
cc: cc?.map(e => ({ email: e })),
bcc: bcc?.map(e => ({ email: e })),
cc: cc?.length ? cc.map(e => ({ email: e })) : undefined,
bcc: bcc?.length ? bcc.map(e => ({ email: e })) : undefined,
subject,
sentAt: new Date().toISOString(),
preview: body.substring(0, 200),
@@ -551,7 +552,7 @@ export class DemoJMAPClient implements IJMAPClient {
async createIdentity(
name: string, email: string,
replyTo?: EmailAddress[] | null, bcc?: EmailAddress[] | null,
htmlSignature?: string, textSignature?: string,
textSignature?: string | null, htmlSignature?: string | null,
): Promise<Identity> {
const identity: Identity = {
id: generateDemoId('identity'), name, email,
@@ -563,9 +564,14 @@ export class DemoJMAPClient implements IJMAPClient {
return identity;
}
async updateIdentity(identityId: string, updates: { name?: string; replyTo?: EmailAddress[] | null; bcc?: EmailAddress[] | null; htmlSignature?: string; textSignature?: string }): Promise<void> {
async updateIdentity(identityId: string, updates: { name?: string | null; replyTo?: EmailAddress[] | null; bcc?: EmailAddress[] | null; textSignature?: string | null; htmlSignature?: string | null }): Promise<void> {
const identity = this.data.identities.find(i => i.id === identityId);
if (identity) Object.assign(identity, updates);
if (!identity) return;
if (updates.name !== undefined) identity.name = updates.name ?? '';
if (updates.replyTo !== undefined) identity.replyTo = updates.replyTo ?? undefined;
if (updates.bcc !== undefined) identity.bcc = updates.bcc ?? undefined;
if (updates.textSignature !== undefined) identity.textSignature = updates.textSignature ?? '';
if (updates.htmlSignature !== undefined) identity.htmlSignature = updates.htmlSignature ?? '';
}
async deleteIdentity(identityId: string): Promise<void> {
+76
View File
@@ -1,5 +1,16 @@
import type { ContactCard, AddressBook } from '@/lib/jmap/types';
// randomuser.me serves stable portrait URLs at
// https://randomuser.me/api/portraits/{men|women}/{0..99}.jpg
// See https://randomuser.me/documentation#howto - we use these directly
// rather than hitting the JSON API so the demo works offline.
const portrait = (gender: 'men' | 'women', n: number): string =>
`https://randomuser.me/api/portraits/${gender}/${n}.jpg`;
const photo = (gender: 'men' | 'women', n: number) => ({
photo1: { kind: 'photo' as const, uri: portrait(gender, n), mediaType: 'image/jpeg' },
});
export function createDemoAddressBooks(): AddressBook[] {
return [
{
@@ -34,6 +45,7 @@ export function createDemoContacts(): ContactCard[] {
organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Engineering' }] } },
titles: { t1: { name: 'Senior Engineer', kind: 'title' } },
anniversaries: { a1: { kind: 'birth', date: { year: 1990, month: 3, day: 15 } } },
media: photo('women', 44),
},
{
id: 'demo-contact-2',
@@ -50,6 +62,7 @@ export function createDemoContacts(): ContactCard[] {
},
organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Backend Team' }] } },
titles: { t1: { name: 'Staff Engineer', kind: 'title' } },
media: photo('men', 32),
},
{
id: 'demo-contact-3',
@@ -60,6 +73,7 @@ export function createDemoContacts(): ContactCard[] {
phones: { p1: { number: '+1-555-0104', features: { voice: true } } },
organizations: { o1: { name: 'DesignCo' } },
titles: { t1: { name: 'UX Designer', kind: 'title' } },
media: photo('women', 68),
},
{
id: 'demo-contact-4',
@@ -69,6 +83,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'carlos.rivera@example.com', pref: 1 } },
phones: { p1: { number: '+1-555-0105', features: { cell: true } } },
notes: { n1: { note: 'Met at the DevConf 2024 conference' } },
media: photo('men', 15),
},
{
id: 'demo-contact-5',
@@ -89,6 +104,7 @@ export function createDemoContacts(): ContactCard[] {
},
},
anniversaries: { a1: { kind: 'birth', date: { month: 7, day: 22 } } },
media: photo('women', 22),
},
{
id: 'demo-contact-6',
@@ -97,6 +113,7 @@ export function createDemoContacts(): ContactCard[] {
name: { components: [{ kind: 'given', value: 'David' }, { kind: 'surname', value: 'Park' }] },
emails: { e1: { address: 'david.park@example.com', pref: 1 } },
phones: { p1: { number: '+82-10-1234-5678', features: { cell: true } } },
media: photo('men', 67),
},
{
id: 'demo-contact-7',
@@ -123,6 +140,58 @@ export function createDemoContacts(): ContactCard[] {
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Lisa' }, { kind: 'surname', value: 'Tanaka' }] },
emails: { e1: { address: 'lisa.tanaka@example.com', pref: 1 } },
media: photo('women', 85),
},
{
id: 'demo-contact-16',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Sofia' }, { kind: 'surname', value: 'Russo' }] },
emails: { e1: { address: 'sofia.russo@example.com', contexts: { private: true }, pref: 1 } },
phones: { p1: { number: '+39-340-555-0111', features: { cell: true }, contexts: { private: true } } },
notes: { n1: { note: 'Mom' } },
anniversaries: { a1: { kind: 'birth', date: { year: 1962, month: 5, day: 9 } } },
media: photo('women', 3),
},
{
id: 'demo-contact-17',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: 'Kowalski' }] },
emails: { e1: { address: 'anna.kowalski@example.com', contexts: { private: true }, pref: 1 } },
phones: { p1: { number: '+48-602-555-0144', features: { cell: true } } },
notes: { n1: { note: 'Sister - lives in Kraków' } },
anniversaries: { a1: { kind: 'birth', date: { month: 11, day: 4 } } },
media: photo('women', 47),
},
{
id: 'demo-contact-18',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Marcus' }, { kind: 'surname', value: 'Hughes' }] },
emails: { e1: { address: 'marcus.hughes@example.com', pref: 1 } },
notes: { n1: { note: 'College friend - book club organiser' } },
media: photo('men', 96),
},
{
id: 'demo-contact-19',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Bennett' }] },
emails: { e1: { address: 'olivia.bennett@example.com', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Northwind Studio' } },
titles: { t1: { name: 'Product Designer', kind: 'title' } },
media: photo('women', 91),
},
{
id: 'demo-contact-20',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Daniel' }, { kind: 'surname', value: 'Cooper' }] },
emails: { e1: { address: 'daniel.cooper@example.com', pref: 1 } },
organizations: { o1: { name: 'Freelance' } },
titles: { t1: { name: 'Illustrator', kind: 'title' } },
media: photo('men', 76),
},
// ── Work address book ──────────────────────────────────────
@@ -135,6 +204,7 @@ export function createDemoContacts(): ContactCard[] {
phones: { p1: { number: '+1-555-0301', features: { voice: true }, contexts: { work: true } } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Product' }] } },
titles: { t1: { name: 'Product Manager', kind: 'title' } },
media: photo('men', 41),
},
{
id: 'demo-contact-10',
@@ -144,6 +214,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'rachel.green@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Marketing' }] } },
titles: { t1: { name: 'Marketing Lead', kind: 'title' } },
media: photo('women', 12),
},
{
id: 'demo-contact-11',
@@ -153,6 +224,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'james.miller@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Engineering' }] } },
titles: { t1: { name: 'CTO', kind: 'title' } },
media: photo('men', 52),
},
{
id: 'demo-contact-12',
@@ -162,6 +234,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'priya.sharma@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'QA' }] } },
titles: { t1: { name: 'QA Engineer', kind: 'title' } },
media: photo('women', 77),
},
{
id: 'demo-contact-13',
@@ -171,6 +244,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'ahmed.hassan@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'DevOps' }] } },
titles: { t1: { name: 'DevOps Engineer', kind: 'title' } },
media: photo('men', 89),
},
{
id: 'demo-contact-14',
@@ -180,6 +254,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'maria.lopez@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'HR' }] } },
titles: { t1: { name: 'HR Business Partner', kind: 'title' } },
media: photo('women', 55),
},
{
id: 'demo-contact-15',
@@ -189,6 +264,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'wei.zhang@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Data Science' }] } },
titles: { t1: { name: 'Data Scientist', kind: 'title' } },
media: photo('men', 8),
},
];
}
+590 -110
View File
@@ -1,6 +1,35 @@
import type { Email } from '@/lib/jmap/types';
import { demoDate } from '../demo-utils';
const USER = { name: 'Demo User', email: 'demo@example.com' } as const;
// Helper to keep the fixtures short - auto-assigns a partId/blobId per body.
let bodyCounter = 0;
function body(value: string, type: 'text/plain' | 'text/html' = 'text/plain') {
const partId = String(++bodyCounter);
const blobId = `blob-${partId}`;
return {
part: { partId, blobId, size: value.length, type },
values: { [partId]: { value } },
};
}
/** Build text+html parts in one shot. */
function bodies(text: string, html: string) {
const t = body(text, 'text/plain');
const h = body(html, 'text/html');
return {
textBody: [t.part],
htmlBody: [h.part],
bodyValues: { ...t.values, ...h.values },
};
}
function textOnly(text: string) {
const t = body(text, 'text/plain');
return { textBody: [t.part], bodyValues: t.values };
}
export function createDemoEmails(): Email[] {
return [
// ── Inbox ───────────────────────────────────────────────────
@@ -12,19 +41,61 @@ export function createDemoEmails(): Email[] {
size: 4200,
receivedAt: demoDate(0, -2),
from: [{ name: 'Bulwark Team', email: 'welcome@bulwark.email' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
to: [USER],
subject: 'Welcome to Bulwark Mail!',
sentAt: demoDate(0, -2),
preview: 'Thanks for trying out Bulwark Mail. This is a demo environment where you can explore all features...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-1', size: 350, type: 'text/plain' }],
htmlBody: [{ partId: '2', blobId: 'blob-2', size: 800, type: 'text/html' }],
bodyValues: {
'1': { value: 'Thanks for trying out Bulwark Mail!\n\nThis is a demo environment where you can explore all features without connecting to a real server. All data stays on your device.\n\nFeel free to:\n- Read, compose, and organize emails\n- Manage contacts and calendars\n- Configure filters and settings\n- Try keyboard shortcuts (press ? to see them)\n\nEnjoy exploring!' },
'2': { value: '<div><h2>Welcome to Bulwark Mail!</h2><p>Thanks for trying out Bulwark Mail!</p><p>This is a demo environment where you can explore all features without connecting to a real server. <strong>All data stays on your device.</strong></p><p>Feel free to:</p><ul><li>Read, compose, and organize emails</li><li>Manage contacts and calendars</li><li>Configure filters and settings</li><li>Try keyboard shortcuts (press <kbd>?</kbd> to see them)</li></ul><p>Enjoy exploring!</p></div>' },
},
...bodies(
'Thanks for trying out Bulwark Mail!\n\nThis is a demo environment where you can explore all features without connecting to a real server. All data stays on your device.\n\nFeel free to:\n- Read, compose, and organize emails\n- Manage contacts and calendars\n- Configure filters and settings\n- Try keyboard shortcuts (press ? to see them)\n\nEnjoy exploring!',
'<div><h2>Welcome to Bulwark Mail!</h2><p>Thanks for trying out Bulwark Mail!</p><p>This is a demo environment where you can explore all features without connecting to a real server. <strong>All data stays on your device.</strong></p><p>Feel free to:</p><ul><li>Read, compose, and organize emails</li><li>Manage contacts and calendars</li><li>Configure filters and settings</li><li>Try keyboard shortcuts (press <kbd>?</kbd> to see them)</li></ul><p>Enjoy exploring!</p></div>',
),
messageId: '<welcome@demo.bulwark.email>',
},
// Mom - personal message, unread
{
id: 'demo-email-mom',
threadId: 'demo-thread-mom',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 1900,
receivedAt: demoDate(0, -4, -12),
from: [{ name: 'Sofia Russo', email: 'sofia.russo@example.com' }],
to: [USER],
subject: 'when are you coming home?',
sentAt: demoDate(0, -4, -12),
preview: 'Hi sweetie, your father and I were just talking - we miss you. Any chance you can come down for a weekend...',
hasAttachment: false,
...textOnly(
"Hi sweetie,\n\nYour father and I were just talking - we miss you. Any chance you can come down for a weekend before Christmas?\n\nNo pressure if you're swamped with work. Anna said she might be in town the 22nd, would be nice to all be in one place again.\n\nThe lemon tree finally fruited! Twelve lemons. I'll save you some.\n\nLove,\nMom",
),
messageId: '<5a8c-mom@example.com>',
},
// GitHub - PR review request
{
id: 'demo-email-gh-pr',
threadId: 'demo-thread-gh-pr',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 6400,
receivedAt: demoDate(0, -3, -5),
from: [{ name: 'Alice Johnson (via GitHub)', email: 'notifications@github.com' }],
replyTo: [{ name: 'reply', email: 'reply+abc123@reply.github.com' }],
to: [USER],
subject: '[acme/api-gateway] Add token-bucket rate limiter (#1284)',
sentAt: demoDate(0, -3, -5),
preview: '@demo-user requested your review on this pull request. Replaces the fixed-window limiter with a leaky token-bucket...',
hasAttachment: false,
...bodies(
'@demo-user requested your review on this pull request.\n\nReplaces the fixed-window limiter with a leaky token-bucket so we stop punishing clients at the second-boundary edge. Per-endpoint config lives in rate-limit.toml.\n\nThree files changed, +312 47.\n\nView it on GitHub:\nhttps://github.com/acme/api-gateway/pull/1284\n\n-\nReply to this email directly, or view it on GitHub.',
'<table style="font-family:-apple-system,sans-serif"><tr><td><strong>@demo-user</strong> requested your review on this pull request.</td></tr><tr><td style="padding-top:12px">Replaces the fixed-window limiter with a leaky token-bucket so we stop punishing clients at the second-boundary edge. Per-endpoint config lives in <code>rate-limit.toml</code>.</td></tr><tr><td style="padding-top:12px;color:#666">Three files changed, <span style="color:#16a34a">+312</span> <span style="color:#dc2626">47</span></td></tr><tr><td style="padding-top:16px"><a href="https://github.com/acme/api-gateway/pull/1284" style="background:#1f2328;color:#fff;padding:8px 16px;text-decoration:none;border-radius:6px">View on GitHub</a></td></tr></table>',
),
messageId: '<acme/api-gateway/pull/1284@github.com>',
},
// Hacker Newsletter - newsletter, read
{
id: 'demo-email-2',
threadId: 'demo-thread-2',
@@ -33,20 +104,19 @@ export function createDemoEmails(): Email[] {
size: 18500,
receivedAt: demoDate(-1, -5),
from: [{ name: 'TechDigest Weekly', email: 'newsletter@techdigest.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
subject: 'This Week in Tech: AI Developments & Open Source Updates',
to: [USER],
subject: 'Issue #218 - RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla',
sentAt: demoDate(-1, -5),
preview: 'Your weekly roundup of the most important technology news and open source developments...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-3', size: 2400, type: 'text/plain' }],
htmlBody: [{ partId: '2', blobId: 'blob-4', size: 5200, type: 'text/html' }],
bodyValues: {
'1': { value: 'This Week in Tech\n\n1. AI-Powered Code Review Tools\nNew tools are making code reviews faster and more thorough...\n\n2. Open Source Licensing Update\nThe OSI has published new guidelines for AI-generated code...\n\n3. WebAssembly 2.0 Draft\nThe W3C has released the first draft of WebAssembly 2.0...\n\nRead more at techdigest.example' },
'2': { value: '<div style="max-width:600px;margin:0 auto;"><h1>This Week in Tech</h1><h3>1. AI-Powered Code Review Tools</h3><p>New tools are making code reviews faster and more thorough, with several open-source options gaining traction.</p><h3>2. Open Source Licensing Update</h3><p>The OSI has published new guidelines for AI-generated code contributions to open source projects.</p><h3>3. WebAssembly 2.0 Draft</h3><p>The W3C has released the first draft of WebAssembly 2.0, promising improved memory management.</p></div>' },
},
messageId: '<weekly-42@techdigest.example>',
...bodies(
'TechDigest #218\n\n- THE WEEK IN STANDARDS -\n\n1. RFC 9844: Per-message TLS extensions are now official. The implications for SMTP delivery reports are surprisingly large - Mike Crispin has a write-up that runs through what changes for transactional senders.\n\n2. WebAssembly 2.0 (second public draft). Tail calls are in. SIMD is in. Component model is *almost* in but punted to a separate spec, which feels like the right call.\n\n3. Mozilla quietly shipped a privacy-preserving telemetry channel to Firefox 132. No, it doesn\'t replace ad tracking. Yes, it\'s a real cryptographic system. Worth reading the post.\n\n- TOOLS -\n\n- Datasette 1.0 is out. Ten years from the first commit.\n- Fly.io published their object store, Tigris-style, written in Go.\n- Linear added an SSO migration tool that actually handles the IdP-initiated case.\n\n- ESSAYS -\n\n* "Postgres is enough" by E. Tan - a long-form rebuttal to the microservices-by-default pattern.\n* "I rewrote my home network in TypeScript so you don\'t have to" - exactly what it sounds like.\n\n- UNSUBSCRIBE -\n\nManage your subscription at techdigest.example/manage.',
'<div style="max-width:560px;margin:0 auto;font-family:-apple-system,sans-serif;line-height:1.5"><div style="border-bottom:2px solid #111;padding-bottom:16px"><div style="font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:#888">TechDigest · Issue #218</div><h1 style="font-size:22px;margin:4px 0 0">RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla</h1></div><h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#666;margin-top:24px">The week in standards</h2><p><strong>1.</strong> RFC 9844: Per-message TLS extensions are now official. The implications for SMTP delivery reports are surprisingly large - Mike Crispin has a <a href="#" style="color:#db2d54">write-up</a> that runs through what changes for transactional senders.</p><p><strong>2.</strong> WebAssembly 2.0 (second public draft). Tail calls are in. SIMD is in. Component model is <em>almost</em> in but punted to a separate spec, which feels like the right call.</p><p><strong>3.</strong> Mozilla quietly shipped a privacy-preserving telemetry channel to Firefox 132. No, it doesn\'t replace ad tracking. Yes, it\'s a real cryptographic system.</p><h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#666;margin-top:24px">Tools</h2><ul><li>Datasette 1.0 is out. Ten years from the first commit.</li><li>Fly.io published their object store, Tigris-style, written in Go.</li><li>Linear added an SSO migration tool that actually handles the IdP-initiated case.</li></ul><h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#666;margin-top:24px">Essays</h2><p style="margin:0 0 6px">"Postgres is enough" by E. Tan - a long-form rebuttal to the microservices-by-default pattern.</p><p style="margin:0">"I rewrote my home network in TypeScript so you don\'t have to" - exactly what it sounds like.</p><div style="margin-top:28px;padding-top:16px;border-top:1px solid #eee;font-size:12px;color:#888">Manage your subscription at <a href="#" style="color:#888">techdigest.example/manage</a></div></div>',
),
messageId: '<weekly-218@techdigest.example>',
},
// Thread: Project discussion (3 emails in same thread)
// Thread: Q4 Project Timeline - Alice → Bob → Alice (4 messages)
{
id: 'demo-email-3a',
threadId: 'demo-thread-3',
@@ -55,17 +125,15 @@ export function createDemoEmails(): Email[] {
size: 3100,
receivedAt: demoDate(-3, -10),
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }, { name: 'Bob Chen', email: 'bob.chen@example.com' }],
to: [USER, { name: 'Bob Chen', email: 'bob.chen@example.com' }],
subject: 'Q4 Project Timeline',
sentAt: demoDate(-3, -10),
preview: 'Hi team, I wanted to share the updated timeline for our Q4 deliverables...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-5', size: 450, type: 'text/plain' }],
htmlBody: [{ partId: '2', blobId: 'blob-6', size: 650, type: 'text/html' }],
bodyValues: {
'1': { value: 'Hi team,\n\nI wanted to share the updated timeline for our Q4 deliverables:\n\n- Phase 1: Design review - Oct 15\n- Phase 2: Development - Nov 1-30\n- Phase 3: Testing - Dec 1-15\n- Phase 4: Launch - Dec 20\n\nPlease review and let me know if you see any conflicts.\n\nBest,\nAlice' },
'2': { value: '<p>Hi team,</p><p>I wanted to share the updated timeline for our Q4 deliverables:</p><ul><li>Phase 1: Design review - Oct 15</li><li>Phase 2: Development - Nov 1-30</li><li>Phase 3: Testing - Dec 1-15</li><li>Phase 4: Launch - Dec 20</li></ul><p>Please review and let me know if you see any conflicts.</p><p>Best,<br>Alice</p>' },
},
...bodies(
'Hi team,\n\nI wanted to share the updated timeline for our Q4 deliverables:\n\n- Phase 1: Design review - Oct 15\n- Phase 2: Development - Nov 1-30\n- Phase 3: Testing - Dec 1-15\n- Phase 4: Launch - Dec 20\n\nPlease review and let me know if you see any conflicts.\n\nBest,\nAlice',
'<p>Hi team,</p><p>I wanted to share the updated timeline for our Q4 deliverables:</p><ul><li>Phase 1: Design review - Oct 15</li><li>Phase 2: Development - Nov 1-30</li><li>Phase 3: Testing - Dec 1-15</li><li>Phase 4: Launch - Dec 20</li></ul><p>Please review and let me know if you see any conflicts.</p><p>Best,<br>Alice</p>',
),
messageId: '<q4-timeline-1@example.com>',
},
{
@@ -76,15 +144,14 @@ export function createDemoEmails(): Email[] {
size: 3500,
receivedAt: demoDate(-2, -8),
from: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }, { name: 'Demo User', email: 'demo@example.com' }],
to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }, USER],
subject: 'Re: Q4 Project Timeline',
sentAt: demoDate(-2, -8),
preview: 'Looks good to me! One concern: the testing window might be tight given the holidays...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-7', size: 520, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Looks good to me! One concern: the testing window might be tight given the holidays. Could we start testing a few days earlier?\n\nAlso, should we set up a shared doc for tracking blockers?\n\n- Bob' },
},
...textOnly(
"Looks good to me! One concern: the testing window might be tight given the holidays. Could we start testing a few days earlier?\n\nAlso, should we set up a shared doc for tracking blockers?\n\n- Bob",
),
messageId: '<q4-timeline-2@example.com>',
inReplyTo: ['<q4-timeline-1@example.com>'],
references: ['<q4-timeline-1@example.com>'],
@@ -97,20 +164,41 @@ export function createDemoEmails(): Email[] {
size: 3800,
receivedAt: demoDate(-1, -3),
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }, { name: 'Demo User', email: 'demo@example.com' }],
to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }, USER],
subject: 'Re: Q4 Project Timeline',
sentAt: demoDate(-1, -3),
preview: 'Great point Bob. Let\'s move testing to Nov 28. I\'ll create the shared doc today...',
preview: "Great point Bob. Let's move testing to Nov 28. I'll create the shared doc today...",
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-8', size: 400, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Great point Bob. Let\'s move testing to Nov 28. I\'ll create the shared doc today and share the link.\n\nUpdated timeline:\n- Design review: Oct 15\n- Development: Nov 1-27\n- Testing: Nov 28 - Dec 15\n- Launch: Dec 20\n\n- Alice' },
},
...textOnly(
"Great point Bob. Let's move testing to Nov 28. I'll create the shared doc today and share the link.\n\nUpdated timeline:\n- Design review: Oct 15\n- Development: Nov 1-27\n- Testing: Nov 28 - Dec 15\n- Launch: Dec 20\n\n- Alice",
),
messageId: '<q4-timeline-3@example.com>',
inReplyTo: ['<q4-timeline-2@example.com>'],
references: ['<q4-timeline-1@example.com>', '<q4-timeline-2@example.com>'],
},
// Email with attachments
// Stripe receipt
{
id: 'demo-email-stripe',
threadId: 'demo-thread-stripe',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: { $seen: true },
size: 11200,
receivedAt: demoDate(-1, -1, -22),
from: [{ name: 'Stripe', email: 'receipts@stripe.com' }],
to: [USER],
subject: 'Your receipt from Linear Inc. [#2451-9928]',
sentAt: demoDate(-1, -1, -22),
preview: 'Receipt from Linear Inc. for $16.00. Thanks for your business.',
hasAttachment: false,
...bodies(
'Receipt from Linear Inc.\nAmount paid: $16.00\nDate paid: yesterday\nPayment method: Visa •••• 4242\n\nDescription: Linear Standard (monthly)\n\nReceipt #2451-9928\n\nThis charge will appear on your statement as LINEAR INC.\n\nQuestions? Contact support@linear.app.',
'<div style="max-width:560px;margin:0 auto;font-family:-apple-system,sans-serif"><div style="text-align:center;padding:24px 0"><div style="font-size:11px;letter-spacing:0.12em;color:#888;text-transform:uppercase">Receipt</div><div style="font-size:32px;font-weight:700;margin-top:4px">$16.00</div><div style="color:#666;margin-top:4px">Linear Inc.</div></div><table style="width:100%;border-top:1px solid #eee;border-bottom:1px solid #eee"><tr><td style="padding:10px 0;color:#666">Amount</td><td style="padding:10px 0;text-align:right">$16.00</td></tr><tr><td style="padding:10px 0;color:#666;border-top:1px solid #f4f4f4">Payment method</td><td style="padding:10px 0;text-align:right;border-top:1px solid #f4f4f4">Visa •••• 4242</td></tr><tr><td style="padding:10px 0;color:#666;border-top:1px solid #f4f4f4">Receipt number</td><td style="padding:10px 0;text-align:right;border-top:1px solid #f4f4f4;font-family:monospace">2451-9928</td></tr></table><p style="color:#666;font-size:13px;margin-top:24px">Description: Linear Standard (monthly). This charge will appear on your statement as LINEAR INC.</p></div>',
),
messageId: '<receipt-2451-9928@stripe.com>',
},
// Email with attachments - invoice
{
id: 'demo-email-4',
threadId: 'demo-thread-4',
@@ -119,22 +207,22 @@ export function createDemoEmails(): Email[] {
size: 245000,
receivedAt: demoDate(0, -6),
from: [{ name: 'Sarah Kim', email: 'sarah.kim@example.com' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
subject: 'Invoice #2024-089 & Project Screenshot',
to: [USER],
subject: 'Invoice #2024-089 & landing-page prototype v3',
sentAt: demoDate(0, -6),
preview: 'Hi, please find attached the invoice for October and a screenshot of the latest prototype...',
preview: "Hi, please find attached the invoice for October and a screenshot of the latest prototype...",
hasAttachment: true,
textBody: [{ partId: '1', blobId: 'blob-9', size: 280, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Hi,\n\nPlease find attached the invoice for October and a screenshot of the latest prototype.\n\nLet me know if you have any questions.\n\nBest regards,\nSarah' },
},
...textOnly(
"Hi,\n\nPlease find attached the invoice for October and a screenshot of the latest prototype. I went with Option B for the hero (the one with the asymmetric grid) since you mentioned the symmetrical version felt too flat in our last call.\n\nIf the invoice line items look off, ping me - I had to back out the November pre-payment.\n\nBest regards,\nSarah",
),
attachments: [
{ partId: 'att-1', blobId: 'demo-blob-att-1', size: 145000, name: 'Invoice-2024-089.pdf', type: 'application/pdf' },
{ partId: 'att-2', blobId: 'demo-blob-att-2', size: 89000, name: 'prototype-v3.png', type: 'image/png' },
],
messageId: '<invoice-089@example.com>',
},
// Starred email
// Carlos - starred, social
{
id: 'demo-email-5',
threadId: 'demo-thread-5',
@@ -143,18 +231,286 @@ export function createDemoEmails(): Email[] {
size: 2800,
receivedAt: demoDate(-2, -1),
from: [{ name: 'Carlos Rivera', email: 'carlos.rivera@example.com' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
subject: 'Reminder: Team Dinner Friday',
to: [USER],
subject: 'Friday dinner - moved to 7:30 (sorry!)',
sentAt: demoDate(-2, -1),
preview: 'Hey! Just a reminder about our team dinner this Friday at 7 PM at The Garden Bistro...',
preview: 'Quick heads up - had to push the dinner back half an hour. Bistro could only do the late seating...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-10', size: 320, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Hey!\n\nJust a reminder about our team dinner this Friday at 7 PM at The Garden Bistro. I\'ve made a reservation for 8 people.\n\nAddress: 123 Oak Street\n\nLet me know if you can make it!\n\nCheers,\nCarlos' },
},
...textOnly(
"Quick heads up - had to push the dinner back half an hour. Bistro could only do the late seating.\n\nNew time: Friday, 7:30 PM\nThe Garden Bistro, 123 Oak Street\n\nReservation under my name, 8 people. Let me know if that doesn't work for you and I can try to wrangle something.\n\nCheers,\nCarlos",
),
messageId: '<dinner-reminder@example.com>',
},
// Linear - issue assigned
{
id: 'demo-email-linear',
threadId: 'demo-thread-linear',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 5400,
receivedAt: demoDate(0, -7, -15),
from: [{ name: 'Linear', email: 'notifications@linear.app' }],
to: [USER],
subject: 'BUL-2031 was assigned to you - "Compose: drag-and-drop attachments duplicated on slow networks"',
sentAt: demoDate(0, -7, -15),
preview: 'Priya Sharma assigned this issue to you. Repro on a throttled connection (Slow 3G): drop a file twice and...',
hasAttachment: false,
...bodies(
"Priya Sharma assigned BUL-2031 to you.\n\nTitle: Compose: drag-and-drop attachments duplicated on slow networks\nPriority: Medium\n\nRepro on a throttled connection (Slow 3G): drop a file twice in quick succession into the compose drop zone. The first upload doesn't get debounced and both attempts complete, so the attachment shows up twice in the draft.\n\nOpen in Linear: https://linear.app/bulwark/issue/BUL-2031",
'<table style="font-family:-apple-system,sans-serif;max-width:520px"><tr><td><div style="font-size:11px;color:#888;letter-spacing:0.08em;text-transform:uppercase">Linear · BUL-2031</div><div style="font-size:18px;font-weight:600;margin-top:6px">Compose: drag-and-drop attachments duplicated on slow networks</div><div style="margin-top:8px;color:#666"><strong>Priya Sharma</strong> assigned this issue to you · Priority Medium</div></td></tr><tr><td style="padding-top:16px;color:#444">Repro on a throttled connection (Slow 3G): drop a file twice in quick succession into the compose drop zone. The first upload doesn\'t get debounced and both attempts complete, so the attachment shows up twice in the draft.</td></tr><tr><td style="padding-top:16px"><a href="https://linear.app/bulwark/issue/BUL-2031" style="background:#5e6ad2;color:#fff;padding:8px 16px;text-decoration:none;border-radius:6px;font-size:13px">Open in Linear</a></td></tr></table>',
),
messageId: '<BUL-2031-assign@linear.app>',
},
// Anna - sister, photos
{
id: 'demo-email-anna',
threadId: 'demo-thread-anna',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 4800000,
receivedAt: demoDate(-1, -19),
from: [{ name: 'Anna Kowalski', email: 'anna.kowalski@example.com' }],
to: [USER],
subject: 'photos from the wedding',
sentAt: demoDate(-1, -19),
preview: "finally got around to going through these. there are like 600 more on the drive but here's the highlights...",
hasAttachment: true,
...textOnly(
"ok finally got around to going through these. there are like 600 more on the drive but here's the highlights - the ones I'd actually want to print.\n\nmom looked SO happy. dad cried during the speech btw, did you see?\n\nlet me know which ones you want full-res of\n\na",
),
attachments: [
{ partId: 'att-3', blobId: 'demo-blob-att-3', size: 1800000, name: 'wedding-001.jpg', type: 'image/jpeg' },
{ partId: 'att-4', blobId: 'demo-blob-att-4', size: 1600000, name: 'wedding-014-mom-dad.jpg', type: 'image/jpeg' },
{ partId: 'att-5', blobId: 'demo-blob-att-5', size: 1400000, name: 'wedding-038-the-toast.jpg', type: 'image/jpeg' },
],
messageId: '<wedding-photos@example.com>',
},
// AWS billing
{
id: 'demo-email-aws',
threadId: 'demo-thread-aws',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: { $seen: true },
size: 9100,
receivedAt: demoDate(-2, -3, -45),
from: [{ name: 'AWS Billing', email: 'no-reply-aws@amazon.com' }],
to: [USER],
subject: 'Your AWS bill is available - $127.43',
sentAt: demoDate(-2, -3, -45),
preview: 'Your bill for the previous billing period is now available. Total this period: $127.43 (down $4.12)...',
hasAttachment: false,
...textOnly(
"Your bill for the previous billing period is now available.\n\nTotal this period: $127.43 (down $4.12 from last period)\n\nTop services:\n EC2 - $61.20\n S3 - $28.94\n Route 53 - $14.50\n CloudFront - $11.02\n Other - $11.77\n\nView the full invoice in the Billing Console.",
),
messageId: '<aws-bill-2024-11@amazon.com>',
},
// 2FA code - system, unread
{
id: 'demo-email-2fa',
threadId: 'demo-thread-2fa',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 1700,
receivedAt: demoDate(0, -1, -8),
from: [{ name: '1Password', email: 'noreply@1password.com' }],
to: [USER],
subject: 'Your one-time verification code is 814-302',
sentAt: demoDate(0, -1, -8),
preview: "Use this code within 10 minutes to sign in. If you didn't request it, ignore this email.",
hasAttachment: false,
...textOnly(
"Your verification code: 814-302\n\nUse this code within 10 minutes to sign in. If you didn't request it, you can safely ignore this email - your account remains secure.",
),
messageId: '<otp-814302@1password.com>',
},
// LinkedIn - cold-ish
{
id: 'demo-email-linkedin',
threadId: 'demo-thread-linkedin',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: { $seen: true },
size: 8200,
receivedAt: demoDate(-3, -11),
from: [{ name: 'LinkedIn', email: 'jobs-noreply@linkedin.com' }],
to: [USER],
subject: '5 jobs matching "staff engineer · remote · eu" - including one at Datadog',
sentAt: demoDate(-3, -11),
preview: "We thought you'd be interested in these jobs based on your profile and search history.",
hasAttachment: false,
...textOnly(
'Based on your saved search "staff engineer · remote · eu":\n\n1. Staff Software Engineer - Datadog (Remote, EU)\n2. Principal Engineer, Platform - Sentry (Remote, EU)\n3. Staff Backend Engineer - Linear (Remote)\n4. Tech Lead, Infrastructure - Tailscale (Remote, EU)\n5. Staff Engineer, Mobile - Notion (Remote, EU)\n\nManage job alerts at linkedin.com/jobs/preferences.',
),
messageId: '<jobs-1107@linkedin.com>',
},
// Book club - Marcus
{
id: 'demo-email-bookclub',
threadId: 'demo-thread-bookclub',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 2400,
receivedAt: demoDate(-1, -14),
from: [{ name: 'Marcus Hughes', email: 'marcus.hughes@example.com' }],
to: [USER, { name: 'Emma Wilson', email: 'emma.wilson@example.com' }, { name: 'David Park', email: 'david.park@example.com' }],
subject: 'book club thursday - picking the next one',
sentAt: demoDate(-1, -14),
preview: 'Reminder: 7pm at mine. We finish off Le Guin and pick the next read. My vote is the Calvino but I know Emma...',
hasAttachment: false,
...textOnly(
"Reminder: 7pm at mine. We finish off Le Guin and pick the next read.\n\nMy vote is the Calvino but I know Emma's been pushing for the Knausgaard. I'll bring wine, can someone else handle snacks?\n\nm",
),
messageId: '<bookclub-nov@example.com>',
},
// DHL package
{
id: 'demo-email-dhl',
threadId: 'demo-thread-dhl',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 5600,
receivedAt: demoDate(0, -9, -30),
from: [{ name: 'DHL Express', email: 'noreply@dhl.com' }],
to: [USER],
subject: 'Your package is out for delivery - arriving today',
sentAt: demoDate(0, -9, -30),
preview: 'Tracking 1Z 999 AA1 0123 4567 84 · Estimated delivery: today between 14:00 and 18:00.',
hasAttachment: false,
...textOnly(
'Your package is on the truck.\n\nTracking: 1Z 999 AA1 0123 4567 84\nEstimated delivery window: today, 14:0018:00\n\nIf no one is home, the driver will attempt redelivery tomorrow or leave it at the nearest pickup point.\n\nTrack live at dhl.com/track.',
),
messageId: '<delivery-1Z999AA1@dhl.com>',
},
// Notion
{
id: 'demo-email-notion',
threadId: 'demo-thread-notion',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: { $seen: true },
size: 4100,
receivedAt: demoDate(-2, -16),
from: [{ name: 'Olivia Bennett (via Notion)', email: 'team@mail.notion.so' }],
to: [USER],
subject: 'Olivia shared "Q1 2026 - design north star" with you',
sentAt: demoDate(-2, -16),
preview: 'Olivia Bennett shared a page with you in the Northwind workspace. Open in Notion to view.',
hasAttachment: false,
...textOnly(
'Olivia Bennett shared a page with you in the Northwind workspace.\n\n"Q1 2026 - design north star"\n\nOpen in Notion: https://notion.so/northwind/q1-design-north-star',
),
messageId: '<share-northwind-q1@mail.notion.so>',
},
// Spotify wrap
{
id: 'demo-email-spotify',
threadId: 'demo-thread-spotify',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: { $seen: true },
size: 7400,
receivedAt: demoDate(-4, -8),
from: [{ name: 'Spotify', email: 'no-reply@spotify.com' }],
to: [USER],
subject: 'Your year in music is ready',
sentAt: demoDate(-4, -8),
preview: 'You spent 38,420 minutes listening this year. Your top artist was Big Thief, and your top genre was indie folk.',
hasAttachment: false,
...textOnly(
'Your year, in music.\n\n38,420 minutes listened\nTop artist: Big Thief\nTop song: "Vampire Empire"\nTop genre: indie folk\nDiscover Weekly hit rate: 41%\n\nOpen Spotify to see your full Wrapped.',
),
messageId: '<wrapped-2025@spotify.com>',
},
// Booking.com confirmation
{
id: 'demo-email-booking',
threadId: 'demo-thread-booking',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: { $seen: true },
size: 32100,
receivedAt: demoDate(-5, -10),
from: [{ name: 'Booking.com', email: 'no-reply@booking.com' }],
to: [USER],
subject: 'Confirmation 4892-7714-3320 - Hotel Lago, Lake Como (Dec 2225)',
sentAt: demoDate(-5, -10),
preview: 'Your booking is confirmed. Check-in: Dec 22, after 15:00. Check-out: Dec 25, before 11:00.',
hasAttachment: true,
...textOnly(
'Your booking is confirmed.\n\nHotel Lago, Lake Como (Italy)\nCheck-in: Dec 22, after 15:00\nCheck-out: Dec 25, before 11:00\n\nRoom: Lake-view double, breakfast included\nTotal: €612 (paid)\n\nConfirmation number: 4892-7714-3320\n\nYour voucher is attached. Show it at reception.',
),
attachments: [
{ partId: 'att-6', blobId: 'demo-blob-att-6', size: 31000, name: 'booking-voucher-4892-7714-3320.pdf', type: 'application/pdf' },
],
messageId: '<conf-4892-7714-3320@booking.com>',
},
// Substack post
{
id: 'demo-email-substack',
threadId: 'demo-thread-substack',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: { $seen: true },
size: 22400,
receivedAt: demoDate(-1, -12),
from: [{ name: 'Robin Sloan', email: 'robin@substack.com' }],
to: [USER],
subject: 'a small newsletter about a small forge',
sentAt: demoDate(-1, -12),
preview: 'I have been spending the slow weeks of November in the workshop, slowly forging a knife from a piece of...',
hasAttachment: false,
...textOnly(
"Hello, friends.\n\nI have been spending the slow weeks of November in the workshop, slowly forging a knife from a piece of railway track. It is going badly, in the way that is good for one's soul.\n\nWhat I'm reading: Annie Dillard, again. \"The Writing Life\". Specifically the chapter about her cabin, which I read every year around this time and which always makes me want to throw my laptop into the sea.\n\nWhat I'm watching: very little. There is something about December that makes television feel like an admission of defeat.\n\nUntil next month -\nR.",
),
messageId: '<nov-2025@robin.substack.com>',
},
// Recruiter cold outreach
{
id: 'demo-email-recruiter',
threadId: 'demo-thread-recruiter',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 3200,
receivedAt: demoDate(0, -10),
from: [{ name: 'Jennifer Hayes', email: 'jennifer@talent-partners.example' }],
to: [USER],
subject: 'Senior role - Distributed Systems - €180-220k + equity',
sentAt: demoDate(0, -10),
preview: "Hi, I came across your profile and thought you'd be a great fit for a senior position with one of our clients...",
hasAttachment: false,
...textOnly(
"Hi,\n\nI came across your profile and thought you'd be a great fit for a senior position with one of our clients - a well-funded Series B (real-time data infrastructure, 60-person eng team, fully remote within EU).\n\nThe core stack: Rust + Postgres + a non-trivial amount of Go. Hiring level is roughly equivalent to Staff at FAANG.\n\nWould you be open to a 15-minute call this week or next?\n\nBest,\nJennifer Hayes\nTalent Partners",
),
messageId: '<outreach-jh-2025-11@talent-partners.example>',
},
// Dentist reminder
{
id: 'demo-email-dentist',
threadId: 'demo-thread-dentist',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 2200,
receivedAt: demoDate(-1, -2),
from: [{ name: "Dr. Smith's Office", email: 'appointments@drsmith.example' }],
to: [USER],
subject: 'Appointment reminder - Tuesday at 10:00',
sentAt: demoDate(-1, -2),
preview: 'This is a friendly reminder of your upcoming cleaning appointment on Tuesday at 10:00 AM.',
hasAttachment: false,
...textOnly(
"Hello,\n\nThis is a friendly reminder of your upcoming cleaning appointment on Tuesday at 10:00 AM with Dr. Smith.\n\nLocation: 123 Medical Plaza, Suite 4\n\nNeed to reschedule? Reply to this email or call (555) 010-7878.\n\nSee you Tuesday!\nDr. Smith's office",
),
messageId: '<appt-reminder-dr-smith@drsmith.example>',
},
// ── Sent ────────────────────────────────────────────────────
{
id: 'demo-email-6',
@@ -163,16 +519,15 @@ export function createDemoEmails(): Email[] {
keywords: { $seen: true },
size: 2100,
receivedAt: demoDate(-1, -4),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
from: [USER],
to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
subject: 'Updated Requirements Document',
sentAt: demoDate(-1, -4),
preview: 'Hi Alice, I\'ve updated the requirements document with the changes we discussed...',
preview: "Hi Alice, I've updated the requirements document with the changes we discussed...",
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-11', size: 290, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Hi Alice,\n\nI\'ve updated the requirements document with the changes we discussed in yesterday\'s meeting. The main updates are in sections 3 and 5.\n\nLet me know if you have any questions.\n\nBest,\nDemo User' },
},
...textOnly(
"Hi Alice,\n\nI've updated the requirements document with the changes we discussed in yesterday's meeting. The main updates are in sections 3 and 5.\n\nLet me know if you have any questions.\n\nBest,\nDemo User",
),
messageId: '<sent-1@example.com>',
},
{
@@ -182,18 +537,37 @@ export function createDemoEmails(): Email[] {
keywords: { $seen: true },
size: 1800,
receivedAt: demoDate(-4, -2),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
from: [USER],
to: [{ name: 'Sarah Kim', email: 'sarah.kim@example.com' }],
subject: 'Re: Design Feedback',
sentAt: demoDate(-4, -2),
preview: 'Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-12', size: 250, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements for accessibility.\n\nLet\'s go with Option B for the navigation.\n\nBest,\nDemo User' },
},
...textOnly(
"Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements for accessibility.\n\nLet's go with Option B for the navigation.\n\nBest,\nDemo User",
),
messageId: '<sent-2@example.com>',
},
{
id: 'demo-email-sent-mom',
threadId: 'demo-thread-mom',
mailboxIds: { 'demo-mailbox-sent': true },
keywords: { $seen: true },
size: 1400,
receivedAt: demoDate(0, -2, -10),
from: [USER],
to: [{ name: 'Sofia Russo', email: 'sofia.russo@example.com' }],
subject: 'Re: when are you coming home?',
sentAt: demoDate(0, -2, -10),
preview: "Mom - I miss you too. Let me check the calendar tonight and I'll get back to you tomorrow about the weekend...",
hasAttachment: false,
...textOnly(
"Mom - I miss you too. Let me check the calendar tonight and I'll get back to you tomorrow about the weekend. Lemons sound like a bribe and I will not pretend otherwise.\n\nLove you both.",
),
messageId: '<re-mom-1@example.com>',
inReplyTo: ['<5a8c-mom@example.com>'],
references: ['<5a8c-mom@example.com>'],
},
// ── Drafts ──────────────────────────────────────────────────
{
@@ -203,18 +577,35 @@ export function createDemoEmails(): Email[] {
keywords: { $seen: true, $draft: true },
size: 900,
receivedAt: demoDate(0, -1),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
from: [USER],
to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
subject: 'Meeting Notes - Draft',
sentAt: demoDate(0, -1),
preview: 'Here are the notes from today\'s standup...',
preview: "Here are the notes from today's standup...",
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-13', size: 180, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Here are the notes from today\'s standup:\n\n- API integration on track\n- Need to resolve the caching issue\n- ' },
},
...textOnly(
"Here are the notes from today's standup:\n\n- API integration on track\n- Need to resolve the caching issue\n- ",
),
messageId: '<draft-1@example.com>',
},
{
id: 'demo-email-draft-recruiter',
threadId: 'demo-thread-draft-recruiter',
mailboxIds: { 'demo-mailbox-drafts': true },
keywords: { $seen: true, $draft: true },
size: 720,
receivedAt: demoDate(0, -8),
from: [USER],
to: [{ name: 'Jennifer Hayes', email: 'jennifer@talent-partners.example' }],
subject: 'Re: Senior role - Distributed Systems',
sentAt: demoDate(0, -8),
preview: "Hi Jennifer, thanks for reaching out. I'm not actively looking, but the role sounds interesting enough that...",
hasAttachment: false,
...textOnly(
"Hi Jennifer,\n\nThanks for reaching out. I'm not actively looking, but the role sounds interesting enough that I'd be open to a quick call. A few questions before we set something up:\n\n- ",
),
messageId: '<draft-recruiter@example.com>',
},
// ── Trash ───────────────────────────────────────────────────
{
@@ -225,15 +616,14 @@ export function createDemoEmails(): Email[] {
size: 15200,
receivedAt: demoDate(-5, -3),
from: [{ name: 'Promo Store', email: 'deals@promostore.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
to: [USER],
subject: '🎉 Flash Sale: 50% Off Everything!',
sentAt: demoDate(-5, -3),
preview: 'Limited time offer! Get 50% off all items in our store...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-14', size: 400, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Limited time offer! Get 50% off all items in our store. Use code FLASH50 at checkout.' },
},
...textOnly(
'Limited time offer! Get 50% off all items in our store. Use code FLASH50 at checkout.',
),
messageId: '<promo-1@promostore.example>',
},
{
@@ -244,15 +634,14 @@ export function createDemoEmails(): Email[] {
size: 2300,
receivedAt: demoDate(-7, 0),
from: [{ name: 'System Notification', email: 'noreply@service.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
to: [USER],
subject: 'Your password was changed',
sentAt: demoDate(-7, 0),
preview: 'Your account password was successfully changed on...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-15', size: 200, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Your account password was successfully changed. If you did not make this change, please contact support immediately.' },
},
...textOnly(
'Your account password was successfully changed. If you did not make this change, please contact support immediately.',
),
messageId: '<notification-1@service.example>',
},
@@ -265,15 +654,14 @@ export function createDemoEmails(): Email[] {
size: 4500,
receivedAt: demoDate(-2, -7),
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
to: [USER],
subject: '[Project] Sprint Planning Agenda',
sentAt: demoDate(-2, -7),
preview: 'Here\'s the agenda for next week\'s sprint planning session...',
preview: "Here's the agenda for next week's sprint planning session...",
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-16', size: 600, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Hi team,\n\nHere\'s the agenda for next week\'s sprint planning:\n\n1. Review previous sprint velocity\n2. Discuss tech debt items\n3. Prioritize backlog\n4. Assign story points\n5. Capacity planning\n\nPlease come prepared with your updates.\n\nThanks,\nAlice' },
},
...textOnly(
"Hi team,\n\nHere's the agenda for next week's sprint planning:\n\n1. Review previous sprint velocity\n2. Discuss tech debt items\n3. Prioritize backlog\n4. Assign story points\n5. Capacity planning\n\nPlease come prepared with your updates.\n\nThanks,\nAlice",
),
messageId: '<project-1@example.com>',
},
{
@@ -284,17 +672,37 @@ export function createDemoEmails(): Email[] {
size: 3200,
receivedAt: demoDate(0, -8),
from: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
to: [USER],
subject: '[Project] API Rate Limiting Discussion',
sentAt: demoDate(0, -8),
preview: 'I\'ve been thinking about our rate limiting approach and wanted to propose a few changes...',
preview: "I've been thinking about our rate limiting approach and wanted to propose a few changes...",
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-17', size: 480, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Hey,\n\nI\'ve been thinking about our rate limiting approach and wanted to propose:\n\n1. Token bucket algorithm instead of fixed window\n2. Per-endpoint limits rather than global\n3. Graduated response (warn → throttle → block)\n\nThoughts? I can put together a more detailed RFC if we agree on the direction.\n\n- Bob' },
},
...textOnly(
"Hey,\n\nI've been thinking about our rate limiting approach and wanted to propose:\n\n1. Token bucket algorithm instead of fixed window\n2. Per-endpoint limits rather than global\n3. Graduated response (warn → throttle → block)\n\nThoughts? I can put together a more detailed RFC if we agree on the direction.\n\n- Bob",
),
messageId: '<project-2@example.com>',
},
{
id: 'demo-email-roadmap',
threadId: 'demo-thread-roadmap',
mailboxIds: { 'demo-mailbox-projects': true },
keywords: {},
size: 4900,
receivedAt: demoDate(-1, -15),
from: [{ name: 'Michael Torres', email: 'michael.torres@company.example' }],
to: [USER, { name: 'Alice Johnson', email: 'alice.johnson@example.com' }, { name: 'James Miller', email: 'james.miller@company.example' }],
subject: '[Project] Q1 2026 roadmap - first cut',
sentAt: demoDate(-1, -15),
preview: 'Attached is the first cut of the Q1 roadmap. Three themes: reliability, mobile, and the long-promised...',
hasAttachment: true,
...textOnly(
"Team,\n\nAttached is the first cut of the Q1 roadmap. Three themes:\n\n1. Reliability (Alice's team)\n2. Mobile parity (cross-functional)\n3. The long-promised search rework (James, this is mostly on you)\n\nLet's leave comments in the doc rather than do a meeting - I'd rather have the meeting be the *decisions*, not the discussion. Closing comments end-of-week.\n\nM",
),
attachments: [
{ partId: 'att-7', blobId: 'demo-blob-att-7', size: 84000, name: 'Q1-2026-roadmap-v0.pdf', type: 'application/pdf' },
],
messageId: '<roadmap-q1-2026@company.example>',
},
// ── Archive ─────────────────────────────────────────────────
{
@@ -304,18 +712,35 @@ export function createDemoEmails(): Email[] {
keywords: { $seen: true },
size: 2600,
receivedAt: demoDate(-14, -6),
from: [{ name: 'HR Department', email: 'hr@company.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
from: [{ name: 'Maria Lopez', email: 'maria.lopez@company.example' }],
to: [USER],
subject: 'Updated PTO Policy - Effective January 1',
sentAt: demoDate(-14, -6),
preview: 'Please review the updated PTO policy that takes effect January 1st...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-18', size: 380, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Dear team,\n\nPlease review the updated PTO policy effective January 1st. Key changes include:\n\n- Increased annual allowance from 20 to 25 days\n- Flexible half-day options\n- Rollover limit increased to 10 days\n\nPlease acknowledge receipt.\n\nBest,\nHR Department' },
},
...textOnly(
'Dear team,\n\nPlease review the updated PTO policy effective January 1st. Key changes include:\n\n- Increased annual allowance from 20 to 25 days\n- Flexible half-day options\n- Rollover limit increased to 10 days\n\nPlease acknowledge receipt.\n\nBest,\nMaria - People Ops',
),
messageId: '<hr-policy-1@company.example>',
},
{
id: 'demo-email-archive-support',
threadId: 'demo-thread-archive-support',
mailboxIds: { 'demo-mailbox-archive': true },
keywords: { $seen: true },
size: 3400,
receivedAt: demoDate(-21, -4),
from: [{ name: 'Fastmail Support', email: 'support@fastmail.com' }],
to: [USER],
subject: 'Re: Ticket #438201 - DKIM signing fails on cross-account aliases',
sentAt: demoDate(-21, -4),
preview: "Thanks for the additional logs. We were able to reproduce on our side - the issue was indeed the alias resolution...",
hasAttachment: false,
...textOnly(
"Hi,\n\nThanks for the additional logs. We were able to reproduce on our side - the issue was indeed the alias resolution path skipping the DKIM signer step. Fix has been deployed to the AU and SY clusters; EU rolls out tomorrow.\n\nResolved on our end. Please reopen if you see anything related.\n\nBest,\nClaire - Fastmail Support",
),
messageId: '<ticket-438201-resolved@fastmail.com>',
},
// ── Receipts ────────────────────────────────────────────────
{
@@ -325,17 +750,37 @@ export function createDemoEmails(): Email[] {
keywords: { $seen: true },
size: 5200,
receivedAt: demoDate(-3, -12),
from: [{ name: 'Cloud Services', email: 'billing@cloudprovider.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
subject: 'Payment Receipt - Invoice #INV-2024-1042',
from: [{ name: 'Hetzner', email: 'billing@hetzner.com' }],
to: [USER],
subject: 'Invoice #INV-2024-1042 - €49.99 (paid)',
sentAt: demoDate(-3, -12),
preview: 'Your payment of $49.99 has been processed successfully...',
preview: 'Your payment of 49.99 has been processed successfully...',
hasAttachment: true,
...textOnly(
'Payment Confirmation\n\nAmount: €49.99\nDate: 3 days ago\nInvoice: INV-2024-1042\nService: CX22 dedicated (Helsinki, monthly)\n\nThank you for your payment.',
),
attachments: [
{ partId: 'att-8', blobId: 'demo-blob-att-8', size: 28000, name: 'INV-2024-1042.pdf', type: 'application/pdf' },
],
messageId: '<receipt-1@hetzner.com>',
},
{
id: 'demo-email-receipts-domain',
threadId: 'demo-thread-receipts-domain',
mailboxIds: { 'demo-mailbox-receipts': true },
keywords: { $seen: true },
size: 3100,
receivedAt: demoDate(-9, -8),
from: [{ name: 'Porkbun', email: 'support@porkbun.com' }],
to: [USER],
subject: 'Renewal confirmation - example.com (1 year)',
sentAt: demoDate(-9, -8),
preview: 'Your domain example.com has been renewed for 1 year. Next renewal: 11 months from today.',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-19', size: 350, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Payment Confirmation\n\nAmount: $49.99\nDate: Processing date\nInvoice: INV-2024-1042\nService: Cloud Hosting (Standard Plan)\n\nThank you for your payment.' },
},
messageId: '<receipt-1@cloudprovider.example>',
...textOnly(
"Hi,\n\nYour domain example.com has been renewed for 1 year.\n\nAmount: $11.06\nNext renewal: 11 months from today\nAutorenew: on\n\nReply to this email if you need a tax-receipt-style invoice.\n\n- Porkbun",
),
messageId: '<renewal-example.com@porkbun.com>',
},
// ── Spam ────────────────────────────────────────────────────
@@ -347,16 +792,51 @@ export function createDemoEmails(): Email[] {
size: 8900,
receivedAt: demoDate(-1, -9),
from: [{ name: 'Prize Center', email: 'winner@totallylegit.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
to: [USER],
subject: 'Congratulations! You Won $1,000,000!!!',
sentAt: demoDate(-1, -9),
preview: 'Dear lucky winner, you have been selected to receive one million dollars...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-20', size: 500, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Dear lucky winner,\n\nYou have been selected to receive ONE MILLION DOLLARS! Click below to claim your prize immediately.\n\n[This is a demo spam email]' },
},
...textOnly(
'Dear lucky winner,\n\nYou have been selected to receive ONE MILLION DOLLARS! Click below to claim your prize immediately.\n\n[This is a demo spam email]',
),
messageId: '<spam-1@totallylegit.example>',
},
{
id: 'demo-email-spam-phish',
threadId: 'demo-thread-spam-phish',
mailboxIds: { 'demo-mailbox-junk': true },
keywords: {},
size: 4600,
receivedAt: demoDate(-2, -3),
from: [{ name: 'Secure Banking', email: 'security-alert@secur1ty-bank.example' }],
to: [USER],
subject: 'URGENT: Unusual activity on your account - verify within 24 hours',
sentAt: demoDate(-2, -3),
preview: "We've detected suspicious activity. Click below to verify your identity or your account will be suspended...",
hasAttachment: false,
...textOnly(
"We've detected suspicious activity on your account. To prevent suspension, please verify your details within 24 hours by clicking the link below.\n\n[Phishing demo - never click links like this in real life.]",
),
messageId: '<phish-1@secur1ty-bank.example>',
},
{
id: 'demo-email-spam-crypto',
threadId: 'demo-thread-spam-crypto',
mailboxIds: { 'demo-mailbox-junk': true },
keywords: {},
size: 6800,
receivedAt: demoDate(-3, -19),
from: [{ name: 'CryptoGrowth Daily', email: 'invest@cryptogrowth.example' }],
to: [USER],
subject: '🚀 The coin Elon won\'t tell you about - 1000x potential',
sentAt: demoDate(-3, -19),
preview: 'Three early backers turned $500 into $5M in 90 days. Today, you have a chance to get in even earlier...',
hasAttachment: false,
...textOnly(
'Three early backers turned $500 into $5M in 90 days. Today, you have a chance to get in even earlier. Limited spots. No experience needed.\n\n[Demo spam.]',
),
messageId: '<spam-crypto@cryptogrowth.example>',
},
];
}
+8 -7
View File
@@ -3,15 +3,16 @@ import type { Mailbox } from '@/lib/jmap/types';
const RIGHTS_SYSTEM = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: false, mayDelete: false, maySubmit: true };
const RIGHTS_CUSTOM = { ...RIGHTS_SYSTEM, mayRename: true, mayDelete: true };
// Counts must stay in sync with createDemoEmails() in fixtures/emails.ts.
export function createDemoMailboxes(): Mailbox[] {
return [
{ id: 'demo-mailbox-inbox', name: 'Inbox', role: 'inbox', sortOrder: 1, totalEmails: 12, unreadEmails: 5, totalThreads: 10, unreadThreads: 4, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-sent', name: 'Sent', role: 'sent', sortOrder: 2, totalEmails: 8, unreadEmails: 0, totalThreads: 8, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-drafts', name: 'Drafts', role: 'drafts', sortOrder: 3, totalEmails: 1, unreadEmails: 0, totalThreads: 1, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-inbox', name: 'Inbox', role: 'inbox', sortOrder: 1, totalEmails: 22, unreadEmails: 13, totalThreads: 20, unreadThreads: 12, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-sent', name: 'Sent', role: 'sent', sortOrder: 2, totalEmails: 3, unreadEmails: 0, totalThreads: 3, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-drafts', name: 'Drafts', role: 'drafts', sortOrder: 3, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-trash', name: 'Trash', role: 'trash', sortOrder: 5, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-archive', name: 'Archive', role: 'archive', sortOrder: 4, totalEmails: 4, unreadEmails: 0, totalThreads: 4, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-junk', name: 'Spam', role: 'junk', sortOrder: 6, totalEmails: 3, unreadEmails: 1, totalThreads: 3, unreadThreads: 1, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-projects', name: 'Projects', sortOrder: 10, totalEmails: 5, unreadEmails: 2, totalThreads: 5, unreadThreads: 2, myRights: RIGHTS_CUSTOM, isSubscribed: true },
{ id: 'demo-mailbox-receipts', name: 'Receipts', sortOrder: 11, totalEmails: 3, unreadEmails: 0, totalThreads: 3, unreadThreads: 0, myRights: RIGHTS_CUSTOM, isSubscribed: true },
{ id: 'demo-mailbox-archive', name: 'Archive', role: 'archive', sortOrder: 4, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-junk', name: 'Spam', role: 'junk', sortOrder: 6, totalEmails: 3, unreadEmails: 3, totalThreads: 3, unreadThreads: 3, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-projects', name: 'Projects', sortOrder: 10, totalEmails: 3, unreadEmails: 2, totalThreads: 3, unreadThreads: 2, myRights: RIGHTS_CUSTOM, isSubscribed: true },
{ id: 'demo-mailbox-receipts', name: 'Receipts', sortOrder: 11, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_CUSTOM, isSubscribed: true },
];
}
+23
View File
@@ -0,0 +1,23 @@
const HTML_ESCAPE_MAP = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
} as const;
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (char) =>
HTML_ESCAPE_MAP[char as keyof typeof HTML_ESCAPE_MAP]
);
}
export function plainTextToComposerBody(text: string): string {
if (!text) return "";
return text
.replace(/\r\n?/g, "\n")
.split(/\n{2,}/)
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, "<br>")}</p>`)
.join("");
}
+71 -9
View File
@@ -12,9 +12,12 @@ export const EMAIL_SANITIZE_CONFIG = {
ALLOW_DATA_ATTR: false,
FORCE_BODY: true,
// Allow blob: URIs so authenticated inline images (CID) are not stripped.
// data: is restricted to image/* MIME types to prevent SVG script injection.
// data: is restricted to a fixed set of raster image types. SVG (image/svg+xml)
// is excluded because DOMPurify cannot inspect bytes inside a data: URI, so an
// SVG payload can carry <script>/<foreignObject> that the surrounding sanitizer
// never sees. The `(?=[;,])` anchor prevents prefix matches like image/png-evil.
// eslint-disable-next-line no-useless-escape
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob):|data:image\/|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob):|data:image\/(?:png|jpe?g|gif|webp|bmp|avif|x-icon|vnd\.microsoft\.icon)(?=[;,])|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
FORBID_TAGS: [
'script', 'iframe', 'object', 'embed', 'form',
'input', 'button', 'meta', 'link', 'base',
@@ -58,24 +61,83 @@ export function sanitizeEmailHtmlForIframe(html: string): string {
/**
* Sanitize HTML signature with stricter rules
* Only allows basic formatting, no external resources
* Allows basic formatting plus <img> for company logos, plus table-based
* layouts (the de-facto standard for email signatures).
*/
export const SIGNATURE_SANITIZE_CONFIG = {
ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div'],
ALLOWED_ATTR: ['href', 'style', 'class'],
ALLOWED_TAGS: [
'p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div', 'img',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th',
],
ALLOWED_ATTR: [
'href', 'style', 'class', 'src', 'alt', 'width', 'height', 'title',
'cellpadding', 'cellspacing', 'border', 'valign', 'align', 'bgcolor',
'colspan', 'rowspan',
],
ALLOW_DATA_ATTR: false,
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'img', 'video', 'audio'],
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'video', 'audio'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
};
/**
* Sanitize HTML signature for storage and display
* Sanitize HTML signature for storage and display.
* img src is restricted to https: or base64-embedded raster data: URIs
* (png/jpeg/gif/webp). SVG is excluded because DOMPurify cannot inspect
* bytes inside a data: URI. Images with a disallowed src are removed
* entirely so they don't render as broken-image icons.
* @param html - User-provided HTML signature
* @returns Sanitized signature (no scripts, no external resources)
*/
export function sanitizeSignatureHtml(html: string): string {
if (!html?.trim()) return '';
return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG);
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName !== 'IMG') return;
const src = node.getAttribute('src');
if (!src || !/^(?:https:\/\/|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(src)) {
node.remove();
}
});
try {
return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG);
} finally {
DOMPurify.removeAllHooks();
}
}
/**
* Sanitizer for translation strings that contain inline markup (e.g. a
* documentation link). The translation catalog is trusted today, but using
* dangerouslySetInnerHTML on a translation makes that trust permanent and
* implicit; this allowlist limits the blast radius if a translation ever
* becomes attacker-influenced (community PR, crowdsourced service).
*/
const I18N_SANITIZE_CONFIG = {
ALLOWED_TAGS: ['a', 'b', 'strong', 'i', 'em', 'u', 'span', 'br', 'code'],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
ALLOW_DATA_ATTR: false,
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|\/|#)/i,
};
export function sanitizeI18nHtml(html: string): string {
return DOMPurify.sanitize(html, I18N_SANITIZE_CONFIG);
}
/**
* Sanitizer for the non-iframe branch of email rendering (plain-text bodies,
* S/MIME plain-text, TNEF text, no-body fallbacks). The producer
* (`plainTextToSafeHtml`) already escapes text and emits only safe <a> tags,
* so this is defense-in-depth: it ensures the render site is safe even if a
* future code path passes raw HTML in by mistake.
*/
const PLAIN_TEXT_RENDERED_CONFIG = {
ALLOWED_TAGS: ['a', 'br', 'p', 'div', 'span'],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style'],
ALLOW_DATA_ATTR: false,
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|tel:|cid:|#)/i,
};
export function sanitizePlainTextRenderedHtml(html: string): string {
return DOMPurify.sanitize(html, PLAIN_TEXT_RENDERED_CONFIG);
}
/**
@@ -108,7 +170,7 @@ const HTML_ESCAPES: Record<string, string> = {
"'": '&#39;',
};
function escapeHtml(str: string): string {
export function escapeHtml(str: string): string {
return str.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
}
+20
View File
@@ -63,4 +63,24 @@ export function getFilePreviewKind(name?: string, type?: string): FilePreviewKin
export function isFilePreviewable(name?: string, type?: string): boolean {
return getFilePreviewKind(name, type) !== 'unsupported';
}
const INLINE_PREVIEW_SAFE_MIME_PREFIXES = ['image/', 'audio/', 'video/'];
const INLINE_PREVIEW_SAFE_MIME_TYPES = new Set(['application/pdf', 'text/plain']);
const INLINE_PREVIEW_UNSAFE_MIME_TYPES = new Set([
'image/svg+xml',
'image/svg',
]);
// Whether a Blob with this MIME type is safe to open as a top-level navigation
// (e.g. window.open on a blob: URL). Blob URLs inherit the creator's origin, so
// script-bearing types like text/html, application/xhtml+xml, image/svg+xml, and
// XML variants would execute in our origin. Only an explicit allowlist of inert
// types is permitted; everything else must be downloaded.
export function isMimeTypeSafeForInlinePreview(type?: string): boolean {
const mimeType = type?.split(';')[0]?.trim().toLowerCase() || '';
if (!mimeType) return false;
if (INLINE_PREVIEW_UNSAFE_MIME_TYPES.has(mimeType)) return false;
if (INLINE_PREVIEW_SAFE_MIME_TYPES.has(mimeType)) return true;
return INLINE_PREVIEW_SAFE_MIME_PREFIXES.some((prefix) => mimeType.startsWith(prefix));
}
+32 -9
View File
@@ -1,6 +1,17 @@
function normalizeOrigin(value: string): string | null {
if (!value) return null;
try {
const u = new URL(value);
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
return u.origin;
} catch {
return null;
}
}
const PARENT_ORIGIN = typeof window !== 'undefined'
? (document.querySelector('meta[name="parent-origin"]')?.getAttribute('content') || '')
: '';
? normalizeOrigin(document.querySelector('meta[name="parent-origin"]')?.getAttribute('content') || '')
: null;
export function isEmbedded(): boolean {
try {
@@ -12,10 +23,13 @@ export function isEmbedded(): boolean {
export function notifyParent(type: string, payload: Record<string, unknown> = {}) {
if (!isEmbedded()) return;
// Refuse to broadcast when the parent origin is unknown. A wildcard
// targetOrigin would leak the payload (e.g. username) to any frame
// the parent has open.
if (!PARENT_ORIGIN) return;
const targetOrigin = PARENT_ORIGIN || '*';
try {
window.parent.postMessage({ source: 'bulwark', type, ...payload }, targetOrigin);
window.parent.postMessage({ source: 'bulwark', type, ...payload }, PARENT_ORIGIN);
} catch {
// Cross-origin postMessage may fail in restricted contexts
}
@@ -23,13 +37,22 @@ export function notifyParent(type: string, payload: Record<string, unknown> = {}
export function listenFromParent(
handler: (msg: { type: string; [k: string]: unknown }) => void,
allowedOrigin?: string,
allowedOrigin: string,
): () => void {
const listener = (event: MessageEvent) => {
// Validate origin if configured
if (allowedOrigin && event.origin !== allowedOrigin) return;
// Reject installation entirely when the caller cannot pin an origin.
// Without this gate any cross-origin frame could forge
// { source: 'portal', type: 'sso:trigger-logout' } and ride the session.
const normalized = normalizeOrigin(allowedOrigin);
if (!normalized) {
if (typeof console !== 'undefined') {
console.error('[iframe-bridge] listenFromParent requires a valid http(s) allowedOrigin; listener not installed');
}
return () => {};
}
// Only accept messages from the portal
const listener = (event: MessageEvent) => {
if (event.origin !== normalized) return;
if (event.source !== window.parent) return;
if (!event.data || event.data.source !== 'portal') return;
handler(event.data);
+189
View File
@@ -0,0 +1,189 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
export class ImpersonationJwtError extends Error {
status: number;
code: string;
constructor(code: string, message: string, status: number = 401) {
super(message);
this.name = 'ImpersonationJwtError';
this.code = code;
this.status = status;
}
}
export interface ImpersonationClaims {
iss: string;
iat: number;
exp: number;
nbf?: number;
jti: string;
mailbox: string;
tenant_id?: string;
actor_user_id?: string;
}
const MAX_TOKEN_LIFETIME_SEC = 300;
const CLOCK_SKEW_SEC = 60;
const MIN_SECRET_LENGTH = 32;
function base64UrlDecode(input: string): Buffer {
const pad = input.length % 4 === 0 ? 0 : 4 - (input.length % 4);
const b64 = input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(pad);
return Buffer.from(b64, 'base64');
}
function parseSegment(segment: string): unknown {
try {
return JSON.parse(base64UrlDecode(segment).toString('utf8'));
} catch {
throw new ImpersonationJwtError('malformed', 'Malformed JWT segment', 400);
}
}
function assertString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.length === 0) {
throw new ImpersonationJwtError('claims', `Missing or invalid '${field}' claim`);
}
return value;
}
function assertNumber(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new ImpersonationJwtError('claims', `Missing or invalid '${field}' claim`);
}
return value;
}
/**
* Verify an HS256 JWT for master-user impersonation. Returns the validated
* claims on success; throws ImpersonationJwtError otherwise.
*
* Caller must perform replay-protection (jti tracking) on the returned claims.
*/
export function verifyImpersonationJwt(
token: string,
secret: string,
options: { expectedIssuer?: string; now?: number } = {},
): ImpersonationClaims {
if (typeof token !== 'string' || token.length === 0) {
throw new ImpersonationJwtError('malformed', 'Missing token', 400);
}
if (typeof secret !== 'string' || secret.length < MIN_SECRET_LENGTH) {
throw new ImpersonationJwtError(
'config',
`BULWARK_JWT_AUTH_SECRET must be at least ${MIN_SECRET_LENGTH} characters`,
500,
);
}
const parts = token.split('.');
if (parts.length !== 3) {
throw new ImpersonationJwtError('malformed', 'Token must have 3 segments', 400);
}
const [headerB64, payloadB64, sigB64] = parts;
// Header — reject anything but HS256 BEFORE attempting signature verification.
const header = parseSegment(headerB64) as Record<string, unknown>;
if (header.alg !== 'HS256') {
throw new ImpersonationJwtError('alg', `Unsupported alg '${String(header.alg)}'`);
}
if (header.typ !== undefined && header.typ !== 'JWT') {
throw new ImpersonationJwtError('alg', `Unsupported typ '${String(header.typ)}'`);
}
// Signature — constant-time compare.
const expected = createHmac('sha256', secret).update(`${headerB64}.${payloadB64}`).digest();
const provided = base64UrlDecode(sigB64);
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
throw new ImpersonationJwtError('signature', 'Invalid signature');
}
// Claims.
const payload = parseSegment(payloadB64) as Record<string, unknown>;
const iss = assertString(payload.iss, 'iss');
if (options.expectedIssuer && iss !== options.expectedIssuer) {
throw new ImpersonationJwtError('iss', `Unexpected issuer '${iss}'`);
}
const iat = assertNumber(payload.iat, 'iat');
const exp = assertNumber(payload.exp, 'exp');
const jti = assertString(payload.jti, 'jti');
const mailbox = assertString(payload.mailbox, 'mailbox');
// Mailbox MUST NOT contain '%' or ':' — those would inject into the
// master-user auth header.
if (mailbox.includes('%') || mailbox.includes(':')) {
throw new ImpersonationJwtError('mailbox', "mailbox must not contain '%' or ':'");
}
const nowSec = options.now ?? Math.floor(Date.now() / 1000);
if (typeof payload.nbf === 'number' && nowSec + CLOCK_SKEW_SEC < payload.nbf) {
throw new ImpersonationJwtError('nbf', 'Token not yet valid');
}
if (nowSec - CLOCK_SKEW_SEC > exp) {
throw new ImpersonationJwtError('exp', 'Token expired');
}
if (iat - CLOCK_SKEW_SEC > nowSec) {
throw new ImpersonationJwtError('iat', 'Token issued in the future');
}
// Hard ceiling on lifetime — refuse long-lived handoff tokens even if the
// signer asked for one.
if (exp - iat > MAX_TOKEN_LIFETIME_SEC) {
throw new ImpersonationJwtError('lifetime', `Token lifetime exceeds ${MAX_TOKEN_LIFETIME_SEC}s ceiling`);
}
const claims: ImpersonationClaims = { iss, iat, exp, jti, mailbox };
if (typeof payload.nbf === 'number') claims.nbf = payload.nbf;
if (typeof payload.tenant_id === 'string') claims.tenant_id = payload.tenant_id;
if (typeof payload.actor_user_id === 'string') claims.actor_user_id = payload.actor_user_id;
return claims;
}
// ─── Replay protection ──────────────────────────────────────────
// In-memory LRU keyed by jti. Entries expire automatically once their
// underlying JWT could no longer be replayed (exp + skew). On a multi-pod
// deployment each pod has its own cache; that's acceptable because a token
// stolen mid-flight could only be replayed against the pod that already
// consumed it (and that pod will reject it). For stronger guarantees,
// platforms can issue per-pod-routed tokens or front Bulwark with a
// single-leader load balancer for the impersonate route.
const REPLAY_CACHE_MAX = 4096;
class ReplayCache {
private entries = new Map<string, number>(); // jti -> exp epoch seconds
/** Returns true if jti was not previously seen and has been recorded. */
consume(jti: string, exp: number, now: number = Math.floor(Date.now() / 1000)): boolean {
this.prune(now);
if (this.entries.has(jti)) return false;
if (this.entries.size >= REPLAY_CACHE_MAX) {
// Evict the oldest entry — Map preserves insertion order.
const first = this.entries.keys().next().value;
if (first !== undefined) this.entries.delete(first);
}
this.entries.set(jti, exp);
return true;
}
private prune(now: number): void {
for (const [jti, exp] of this.entries) {
if (exp + CLOCK_SKEW_SEC < now) {
this.entries.delete(jti);
} else {
// Insertion order means later entries are no older than this one — but
// exp isn't strictly monotonic with insertion, so we can't break here.
}
}
}
get size(): number {
return this.entries.size;
}
clear(): void {
this.entries.clear();
}
}
export const impersonationReplayCache = new ReplayCache();
+51
View File
@@ -0,0 +1,51 @@
import { configManager } from '@/lib/admin/config-manager';
export interface ImpersonationConfig {
jwtSecret: string;
masterUser: string;
masterPassword: string;
expectedIssuer: string;
}
/**
* Returns null when impersonation is not configured — the route MUST surface
* that as a 404 so an unconfigured deployment doesn't expose the endpoint.
*
* Required env:
* BULWARK_JWT_AUTH_SECRET (>= 32 chars)
* BULWARK_STALWART_MASTER_USER master account address (e.g. master@example.com)
* BULWARK_STALWART_MASTER_PASSWORD
*
* Optional env:
* BULWARK_JWT_AUTH_ISSUER (default: "platform-api/webmail")
*/
export function readImpersonationConfig(): ImpersonationConfig | null {
const jwtSecret = process.env.BULWARK_JWT_AUTH_SECRET ?? '';
const masterUser = process.env.BULWARK_STALWART_MASTER_USER ?? '';
const masterPassword = process.env.BULWARK_STALWART_MASTER_PASSWORD ?? '';
if (!jwtSecret || !masterUser || !masterPassword) return null;
return {
jwtSecret,
masterUser,
masterPassword,
expectedIssuer: process.env.BULWARK_JWT_AUTH_ISSUER ?? 'platform-api/webmail',
};
}
/**
* Resolves the upstream JMAP server URL the same way /api/auth/session does
* for trusted entries: the global `jmapServerUrl` admin setting, then the
* legacy env fallbacks. Returns null if none is configured.
*
* The impersonation flow is server-to-server (no user input), so we never
* accept a custom endpoint — only admin-configured URLs.
*/
export async function resolveImpersonationServerUrl(): Promise<string | null> {
await configManager.ensureLoaded();
const url =
configManager.get<string>('jmapServerUrl', '') ||
process.env.JMAP_SERVER_URL ||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
'';
return url || null;
}
+6 -5
View File
@@ -149,6 +149,7 @@ export interface IJMAPClient {
inReplyTo?: string[],
references?: string[],
delayedUntil?: string,
envelopeMailFrom?: string,
): Promise<SendEmailResult>;
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string): Promise<SendEmailResult>;
@@ -191,17 +192,17 @@ export interface IJMAPClient {
email: string,
replyTo?: EmailAddress[] | null,
bcc?: EmailAddress[] | null,
htmlSignature?: string,
textSignature?: string,
textSignature?: string | null,
htmlSignature?: string | null,
): Promise<Identity>;
updateIdentity(
identityId: string,
updates: {
name?: string;
name?: string | null;
replyTo?: EmailAddress[] | null;
bcc?: EmailAddress[] | null;
htmlSignature?: string;
textSignature?: string;
textSignature?: string | null;
htmlSignature?: string | null;
},
): Promise<void>;
deleteIdentity(identityId: string): Promise<void>;
+103 -36
View File
@@ -101,6 +101,41 @@ const EMAIL_LIST_PROPERTIES = [
"hasAttachment",
] as const;
// Stalwart's default property list for Calendar/get omits shareWith, isVisible,
// includeInAvailability, and the default-alerts properties. Without an explicit
// `properties` list the share indicator and share dialog can't see existing
// shares after a fresh login (only the optimistic in-memory update from the
// share action would carry it). Always request the full set we render.
const CALENDAR_PROPERTIES = [
"id",
"name",
"description",
"color",
"sortOrder",
"isSubscribed",
"isVisible",
"isDefault",
"includeInAvailability",
"defaultAlertsWithTime",
"defaultAlertsWithoutTime",
"timeZone",
"shareWith",
"myRights",
] as const;
// Stalwart's default property list for AddressBook/get omits shareWith, so
// existing shares would be invisible after a fresh login.
const ADDRESS_BOOK_PROPERTIES = [
"id",
"name",
"description",
"sortOrder",
"isDefault",
"isSubscribed",
"shareWith",
"myRights",
] as const;
/**
* Detect whether a calendar object returned by the server is actually a
* task (VTODO) rather than an event (VEVENT). CalDAV clients like
@@ -1822,10 +1857,10 @@ export class JMAPClient implements IJMAPClient {
async createIdentity(
name: string,
email: string,
replyTo?: EmailAddress[],
bcc?: EmailAddress[],
textSignature?: string,
htmlSignature?: string
replyTo?: EmailAddress[] | null,
bcc?: EmailAddress[] | null,
textSignature?: string | null,
htmlSignature?: string | null
): Promise<Identity> {
const response = await this.request([
["Identity/set", {
@@ -1868,11 +1903,11 @@ export class JMAPClient implements IJMAPClient {
async updateIdentity(
identityId: string,
updates: {
name?: string;
replyTo?: EmailAddress[];
bcc?: EmailAddress[];
textSignature?: string;
htmlSignature?: string;
name?: string | null;
replyTo?: EmailAddress[] | null;
bcc?: EmailAddress[] | null;
textSignature?: string | null;
htmlSignature?: string | null;
}
): Promise<void> {
const response = await this.request([
@@ -2023,8 +2058,8 @@ export class JMAPClient implements IJMAPClient {
const emailData: EmailDraft = {
from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }],
to: to.map(email => ({ email })),
cc: cc?.map(email => ({ email })),
bcc: bcc?.map(email => ({ email })),
cc: cc?.length ? cc.map(email => ({ email })) : undefined,
bcc: bcc?.length ? bcc.map(email => ({ email })) : undefined,
subject,
keywords: { "$draft": true },
mailboxIds: { [draftsMailbox.id]: true },
@@ -2099,7 +2134,8 @@ export class JMAPClient implements IJMAPClient {
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[],
delayedUntil?: string
delayedUntil?: string,
envelopeMailFrom?: string
): Promise<SendEmailResult> {
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
const emailId = `send-${Date.now()}`;
@@ -2151,8 +2187,11 @@ export class JMAPClient implements IJMAPClient {
from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }],
replyTo: identityReplyTo?.length ? identityReplyTo : undefined,
to: to.map(email => ({ email })),
cc: cc?.map(email => ({ email })),
bcc: bcc?.map(email => ({ email })),
// RFC 5322 §3.6.3: To/Cc carry an address-list (non-empty). Sending
// cc:[] makes the server emit a literal `Cc:` header with no addresses,
// which is malformed and a spam signal. Omit the field when empty.
cc: cc?.length ? cc.map(email => ({ email })) : undefined,
bcc: bcc?.length ? bcc.map(email => ({ email })) : undefined,
subject,
inReplyTo: normalizedInReplyTo?.length ? normalizedInReplyTo : undefined,
references: normalizedReferences?.length ? normalizedReferences : undefined,
@@ -2196,6 +2235,26 @@ export class JMAPClient implements IJMAPClient {
},
};
// When an explicit envelope MAIL FROM is provided (header From ≠ envelope,
// e.g. sending from a domain-catch-all alias without a dedicated Identity),
// set the EmailSubmission envelope explicitly. JMAP §7.3: when `envelope`
// is omitted the server derives mailFrom from the Identity.
const buildSubmissionCreate = (submissionId: string): Record<string, unknown> => {
const create: Record<string, unknown> = { emailId: `#${emailId}`, identityId: finalIdentityId };
if (holdForSeconds || envelopeMailFrom) {
create.envelope = {
mailFrom: {
email: envelopeMailFrom || fromEmail || this.username,
...(holdForSeconds ? { parameters: { HOLDFOR: String(holdForSeconds) } } : {}),
},
...(envelopeMailFrom
? { rcptTo: [...to, ...(cc || []), ...(bcc || [])].map((email) => ({ email })) }
: {}),
};
}
return { [submissionId]: create };
};
if (draftId) {
// Destroy the old draft and create a new email with the final body
methodCalls.push(["Email/set", {
@@ -2206,14 +2265,9 @@ export class JMAPClient implements IJMAPClient {
accountId: this.accountId,
create: { [emailId]: emailCreate },
}, "1"]);
const submissionCreate = {
emailId: `#${emailId}`,
identityId: finalIdentityId,
...(holdForSeconds ? { envelope: createDelayedSubmissionEnvelope(fromEmail || this.username, holdForSeconds) } : {}),
};
methodCalls.push(["EmailSubmission/set", {
accountId: this.getSubmissionAccountId(),
create: { "1": submissionCreate },
create: buildSubmissionCreate("1"),
onSuccessUpdateEmail,
}, "2"]);
} else {
@@ -2221,14 +2275,9 @@ export class JMAPClient implements IJMAPClient {
accountId: this.accountId,
create: { [emailId]: emailCreate },
}, "0"]);
const submissionCreate = {
emailId: `#${emailId}`,
identityId: finalIdentityId,
...(holdForSeconds ? { envelope: createDelayedSubmissionEnvelope(fromEmail || this.username, holdForSeconds) } : {}),
};
methodCalls.push(["EmailSubmission/set", {
accountId: this.getSubmissionAccountId(),
create: { "1": submissionCreate },
create: buildSubmissionCreate("1"),
onSuccessUpdateEmail,
}, "1"]);
}
@@ -2242,15 +2291,33 @@ export class JMAPClient implements IJMAPClient {
if (response.methodResponses) {
for (const [methodName, result] of response.methodResponses) {
if (methodName.endsWith('/error')) {
console.error('JMAP method error:', result);
console.error('[sendEmail] JMAP method error:', methodName, result);
throw new Error(result.description || `Failed to send email: ${result.type}`);
}
if (result.notCreated) {
const errors = result.notCreated;
const firstError = Object.values(errors)[0] as { description?: string; type?: string };
console.error('Email send error:', firstError);
throw new Error(firstError?.description || firstError?.type || 'Failed to send email');
// Include method name + full error object so it's clear whether the
// failure came from Email/set (draft create) or EmailSubmission/set
// (actual send) and which JMAP error type/properties were returned.
// Without this the user sees a generic "Failed to send" toast and
// the draft sits in Drafts with no indication of why (#303).
const errors = result.notCreated as Record<string, {
type?: string;
description?: string;
properties?: string[];
}>;
const firstError = Object.values(errors)[0];
console.error(
`[sendEmail] ${methodName} notCreated:`,
JSON.stringify(errors, null, 2),
);
const propsHint = firstError?.properties?.length
? ` (properties: ${firstError.properties.join(', ')})`
: '';
const typeHint = firstError?.type ? ` [${firstError.type}]` : '';
throw new Error(
`${firstError?.description || firstError?.type || 'Failed to send email'}${typeHint}${propsHint}`,
);
}
if (methodName === 'Email/set' && result.created?.[emailId]?.id) {
@@ -3286,7 +3353,7 @@ export class JMAPClient implements IJMAPClient {
try {
const accountId = this.getContactsAccountId();
const response = await this.request([
["AddressBook/get", { accountId }, "0"]
["AddressBook/get", { accountId, properties: ADDRESS_BOOK_PROPERTIES }, "0"]
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "AddressBook/get") {
@@ -3311,7 +3378,7 @@ export class JMAPClient implements IJMAPClient {
try {
const response = await this.request([
["AddressBook/get", { accountId }, "0"]
["AddressBook/get", { accountId, properties: ADDRESS_BOOK_PROPERTIES }, "0"]
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "AddressBook/get") {
@@ -3755,7 +3822,7 @@ export class JMAPClient implements IJMAPClient {
try {
const accountId = this.getCalendarsAccountId();
const response = await this.request([
["Calendar/get", { accountId }, "0"]
["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "0"]
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
@@ -3780,7 +3847,7 @@ export class JMAPClient implements IJMAPClient {
try {
const response = await this.request([
["Calendar/get", { accountId }, "0"]
["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "0"]
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
@@ -3832,7 +3899,7 @@ export class JMAPClient implements IJMAPClient {
// Fetch from the target account to find the created calendar
const fetchAccountId = targetAccountId || this.getCalendarsAccountId();
const fetchResponse = await this.request([
["Calendar/get", { accountId: fetchAccountId, ids: [createdId] }, "0"]
["Calendar/get", { accountId: fetchAccountId, ids: [createdId], properties: CALENDAR_PROPERTIES }, "0"]
], this.calendarUsing());
if (fetchResponse.methodResponses?.[0]?.[0] === "Calendar/get") {
const list = fetchResponse.methodResponses[0][1].list || [];
+9 -6
View File
@@ -1,13 +1,16 @@
const COOKIE_SAME_SITE = (process.env.COOKIE_SAME_SITE || 'lax') as 'lax' | 'none' | 'strict';
const COOKIE_SECURE = process.env.COOKIE_SECURE !== undefined
? process.env.COOKIE_SECURE === 'true'
: (COOKIE_SAME_SITE === 'none' || process.env.NODE_ENV === 'production');
import { configManager } from '@/lib/admin/config-manager';
type SameSite = 'lax' | 'none' | 'strict';
export function getCookieOptions() {
const sameSite = configManager.get<SameSite>('cookieSameSite', 'lax');
const secure = process.env.COOKIE_SECURE !== undefined
? process.env.COOKIE_SECURE === 'true'
: (sameSite === 'none' || process.env.NODE_ENV === 'production');
return {
httpOnly: true,
secure: COOKIE_SECURE,
sameSite: COOKIE_SAME_SITE,
secure,
sameSite,
path: '/',
maxAge: 30 * 24 * 60 * 60,
};
+44 -1
View File
@@ -6,6 +6,17 @@ export interface OAuthMetadata {
end_session_endpoint?: string;
}
// Validates that a discovered endpoint URL is safe to follow. Server-side
// callers must pass this to gate against SSRF (typically isPublicHttpUrl from
// @/lib/security/url-guard, which uses node:dns and cannot be bundled for the
// browser). Client callers omit it: the browser handles outbound networking
// and an SSRF check isn't meaningful there.
export type EndpointValidator = (url: string) => Promise<boolean>;
export interface DiscoverOAuthOptions {
validateEndpoint?: EndpointValidator;
}
const CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_MAX_ENTRIES = 64;
const metadataCache = new Map<string, { metadata: OAuthMetadata; expiresAt: number }>();
@@ -22,7 +33,29 @@ function rememberMetadata(serverUrl: string, metadata: OAuthMetadata): void {
metadataCache.set(serverUrl, { metadata, expiresAt: Date.now() + CACHE_TTL_MS });
}
export async function discoverOAuth(serverUrl: string): Promise<OAuthMetadata | null> {
// Endpoints come from an attacker-controllable JSON document when callers pass
// a user-supplied serverUrl (e.g. /api/auth/totp-token-exchange under
// allowCustomJmapEndpoint). Without a validator, a malicious metadata document
// could point token_endpoint at 169.254.169.254 or 127.0.0.1:* and turn the
// downstream fetch() into an SSRF with response-body reflection. Server-side
// callers must pass `validateEndpoint`.
async function endpointsArePublic(
endpoints: Array<string | undefined>,
validate: EndpointValidator | undefined,
): Promise<boolean> {
if (!validate) return true;
for (const endpoint of endpoints) {
if (endpoint === undefined) continue;
if (typeof endpoint !== 'string') return false;
if (!(await validate(endpoint))) return false;
}
return true;
}
export async function discoverOAuth(
serverUrl: string,
options?: DiscoverOAuthOptions,
): Promise<OAuthMetadata | null> {
const cached = metadataCache.get(serverUrl);
if (cached && cached.expiresAt > Date.now()) return cached.metadata;
if (cached) metadataCache.delete(serverUrl);
@@ -44,6 +77,16 @@ export async function discoverOAuth(serverUrl: string): Promise<OAuthMetadata |
const data = await response.json();
if (data.authorization_endpoint && data.token_endpoint) {
const allPublic = await endpointsArePublic([
data.authorization_endpoint,
data.token_endpoint,
data.revocation_endpoint,
data.end_session_endpoint,
], options?.validateEndpoint);
if (!allPublic) {
errors.push(`${url} returned non-public or invalid endpoint URL`);
continue;
}
const metadata: OAuthMetadata = {
issuer: data.issuer,
authorization_endpoint: data.authorization_endpoint,
+3 -2
View File
@@ -1,6 +1,7 @@
import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery';
import type { OAuthMetadata } from '@/lib/oauth/discovery';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, findServerById } from '@/lib/admin/jmap-servers';
@@ -46,7 +47,7 @@ function getClientSecret(serverId?: string | null): string {
export async function getTokenEndpoint(serverId?: string | null): Promise<string> {
const { discoveryUrl } = getRequiredConfig(serverId);
const metadata = await discoverOAuth(discoveryUrl);
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
if (!metadata?.token_endpoint) {
throw new Error('OAuth token endpoint not found');
}
@@ -55,7 +56,7 @@ export async function getTokenEndpoint(serverId?: string | null): Promise<string
export async function getMetadata(serverId?: string | null): Promise<OAuthMetadata | null> {
const { discoveryUrl } = getRequiredConfig(serverId);
return discoverOAuth(discoveryUrl);
return discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
}
export function buildOAuthParams(base: Record<string, string>, serverId?: string | null): URLSearchParams {
+16 -2
View File
@@ -1,6 +1,20 @@
import { configManager } from '@/lib/admin/config-manager';
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);
/**
* Resolve the OAuth scopes to request at authorize time.
*
* Reads admin override / OAUTH_SCOPES / OAUTH_EXTRA_SCOPES at call time so
* runtime env vars (and admin dashboard changes) take effect without a rebuild.
* Server-only: callers in the browser must read `oauthScopes` from /api/config.
*/
export function getOauthScopes(): string {
const explicit = configManager.get<string>('oauthScopes', '');
if (explicit) return explicit;
const extra = configManager.get<string>('oauthExtraScopes', '');
return extra ? `${DEFAULT_SCOPES} ${extra}`.trim() : DEFAULT_SCOPES;
}
export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
export const REFRESH_TOKEN_SERVER_COOKIE = 'jmap_rts';
-930
View File
@@ -1,930 +0,0 @@
// PluginAPI factory — builds the sandboxed API facade for each plugin
import type {
Disposable,
InstalledPlugin,
Permission,
ToolbarAction,
BannerFactory,
SettingsSection,
ComposerAction,
SidebarWidget,
ContextMenuItem,
KeyboardShortcut,
AdminPageSection,
CalendarEventAction,
SlotName,
PluginI18n,
} from './plugin-types';
import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
import {
emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
authHooks, settingsHooks, identityHooks, filterHooks,
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
sidebarAppHooks, avatarHooks, renderHooks, routerHooks,
} from './plugin-hooks';
import { createPluginI18n } from './plugin-i18n';
import { toast as appToast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
import { apiFetch } from '@/lib/browser-navigation';
// --- Permission helpers --------------------------------------
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getPluginExternals(): any {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (globalThis as any).__PLUGIN_EXTERNALS__;
}
function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean {
if ((IMPLICIT as readonly string[]).includes(perm)) return true;
return plugin.permissions.includes(perm);
}
function requirePermission(plugin: InstalledPlugin, perm: Permission): void {
if (!hasPermission(plugin, perm)) {
throw new Error(`Plugin "${plugin.id}" lacks permission "${perm}"`);
}
}
/** Returns a no-op disposable when permission is missing (silent failure) */
function guardedHook<T extends (...args: never[]) => unknown>(
plugin: InstalledPlugin,
perm: Permission,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
bus: { register: (pluginId: string, handler: any, order?: number) => Disposable },
handler: T,
order: number = 100,
): Disposable {
if (!hasPermission(plugin, perm)) {
return { dispose: () => {} };
}
return bus.register(plugin.id, handler, order);
}
// --- Plugin-scoped storage -----------------------------------
function createPluginStorage(pluginId: string) {
const prefix = `plugin:${pluginId}:`;
return {
get: <T>(key: string): T | null => {
if (typeof window === 'undefined') return null;
const raw = localStorage.getItem(prefix + key);
if (raw === null) return null;
try { return JSON.parse(raw) as T; } catch { return null; }
},
set: <T>(key: string, value: T): void => {
if (typeof window === 'undefined') return;
localStorage.setItem(prefix + key, JSON.stringify(value));
},
remove: (key: string): void => {
if (typeof window === 'undefined') return;
localStorage.removeItem(prefix + key);
},
keys: (): string[] => {
if (typeof window === 'undefined') return [];
const keys: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k?.startsWith(prefix)) keys.push(k.slice(prefix.length));
}
return keys;
},
};
}
// --- Plugin-scoped logger ------------------------------------
function createPluginLogger(pluginId: string) {
const tag = `[plugin:${pluginId}]`;
return {
debug: (...args: unknown[]) => console.debug(tag, ...args),
info: (...args: unknown[]) => console.info(tag, ...args),
warn: (...args: unknown[]) => console.warn(tag, ...args),
error: (...args: unknown[]) => console.error(tag, ...args),
};
}
// --- 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 {
plugin: { id: string; version: string; settings: Record<string, unknown> };
/** Localisation API - register translations and call t() to get strings */
i18n: PluginI18n;
ui: {
registerToolbarAction: (action: ToolbarAction) => Disposable;
registerEmailBanner: (factory: BannerFactory) => Disposable;
registerEmailFooter: (component: React.ComponentType) => Disposable;
registerSettingsSection: (section: SettingsSection) => Disposable;
registerComposerAction: (action: ComposerAction) => Disposable;
registerSidebarWidget: (widget: SidebarWidget) => Disposable;
registerComposerSidebar: (widget: SidebarWidget) => Disposable;
registerDetailSidebar: (widget: SidebarWidget) => Disposable;
registerContextMenuItem: (item: ContextMenuItem) => Disposable;
registerNavigationRailItem: (component: React.ComponentType) => Disposable;
registerCalendarEventAction: (action: CalendarEventAction) => Disposable;
registerAdminPage: (page: AdminPageSection) => Disposable;
};
hooks: PluginHooksAPI;
toast: {
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
warning: (message: string) => void;
};
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>;
admin: {
getConfig: (key: string) => Promise<unknown>;
getAllConfig: () => Promise<Record<string, unknown>>;
setConfig: (key: string, value: unknown) => Promise<void>;
deleteConfig: (key: string) => Promise<void>;
};
}
// Simplified hooks API type (all hooks return Disposable)
export interface PluginHooksAPI {
// Email
onEmailOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailClose: (handler: () => void) => Disposable;
onEmailContentRender: (handler: (...args: unknown[]) => unknown) => Disposable;
onThreadExpand: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Intercept - receives ComposeOptions, may mutate fields, return false to cancel */
onBeforeCompose: (handler: (options: import('./plugin-types').ComposeOptions) => boolean | void | Promise<boolean | void>) => Disposable;
onComposerOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
onDraftAutoSave: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Emitted after emails are moved to the Archive mailbox */
onEmailArchive: (handler: (emailIds: string[]) => void) => Disposable;
/** Emitted after emails are moved out of the Archive mailbox */
onEmailUnarchive: (handler: (emailIds: string[]) => void) => Disposable;
onEmailReadStateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailStarToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailSpamToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailKeywordChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onMailboxChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onMailboxesRefresh: (handler: (...args: unknown[]) => unknown) => Disposable;
onMailboxCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onMailboxRename: (handler: (...args: unknown[]) => unknown) => Disposable;
onMailboxDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onMailboxEmpty: (handler: (...args: unknown[]) => unknown) => Disposable;
onSearch: (handler: (...args: unknown[]) => unknown) => Disposable;
onSearchResults: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailSelectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onNewEmailReceived: (handler: (...args: unknown[]) => unknown) => Disposable;
onPushConnectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Intercept - receives MailtoContext, return false to prevent the system mail client */
onMailtoIntercept: (handler: (ctx: import('./plugin-types').MailtoContext) => boolean | void | Promise<boolean | void>) => Disposable;
/** Transform - receives the OutgoingEmail and returns a (possibly modified) copy */
onTransformOutgoingEmail: (handler: (email: import('./plugin-types').OutgoingEmail) => import('./plugin-types').OutgoingEmail | void | Promise<import('./plugin-types').OutgoingEmail | void>) => Disposable;
/** Intercept - receives ReplyContext, return false to cancel */
onBeforeReply: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise<boolean | void>) => Disposable;
onBeforeReplyAll: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise<boolean | void>) => Disposable;
onBeforeForward: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise<boolean | void>) => Disposable;
/** Intercept - receives AttachmentInfo, return false to refuse the upload */
onBeforeAttachmentUpload: (handler: (info: import('./plugin-types').AttachmentInfo) => boolean | void | Promise<boolean | void>) => Disposable;
onAfterAttachmentUpload: (handler: (info: import('./plugin-types').AttachmentInfo) => void) => Disposable;
onAttachmentDownload: (handler: (info: import('./plugin-types').AttachmentInfo) => void) => Disposable;
/** Transform - receives AttachmentPreview, may return a modified preview */
onAttachmentPreview: (handler: (preview: import('./plugin-types').AttachmentPreview, info: import('./plugin-types').AttachmentInfo) => import('./plugin-types').AttachmentPreview | void | Promise<import('./plugin-types').AttachmentPreview | void>) => Disposable;
/** Transform - receives ExternalSearchResult[] and returns an extended array */
onProvideSearchResults: (handler: (results: import('./plugin-types').ExternalSearchResult[], ctx: { query: string; filters: import('./plugin-types').SearchFilters }) => import('./plugin-types').ExternalSearchResult[] | void | Promise<import('./plugin-types').ExternalSearchResult[] | void>) => Disposable;
/** Observer - debounced snapshot of the composer draft */
onDraftChange: (handler: (draft: import('./plugin-types').DraftView) => void) => Disposable;
// Calendar
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEventUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEventUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEventDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEventDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onEventRsvp: (handler: (...args: unknown[]) => unknown) => Disposable;
onEventsImport: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarDateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarViewChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarVisibilityToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarAlertAcknowledge: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Transform - receives ConflictWarning[] and returns an extended array */
onCheckEventConflicts: (handler: (warnings: import('./plugin-types').ConflictWarning[], ctx: { event: import('./plugin-types').CalendarEventFormView }) => import('./plugin-types').ConflictWarning[] | void | Promise<import('./plugin-types').ConflictWarning[] | void>) => Disposable;
// Calendar Form
onCalendarEventFormOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarEventFormSave: (handler: (...args: unknown[]) => unknown) => Disposable;
// Contacts
onContactOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeContactCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterContactCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeContactUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterContactUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeContactDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterContactDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onContactsImport: (handler: (...args: unknown[]) => unknown) => Disposable;
onContactSelectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onContactGroupChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onContactGroupMemberChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onContactMove: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Transform - receives RecipientSuggestion[] and returns an extended array */
onProvideRecipientSuggestions: (handler: (suggestions: import('./plugin-types').RecipientSuggestion[], ctx: { query: string }) => import('./plugin-types').RecipientSuggestion[] | void | Promise<import('./plugin-types').RecipientSuggestion[] | void>) => Disposable;
// Files
onFileNavigate: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeFileUpload: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterFileUpload: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileDownload: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileUploadCancel: (handler: (...args: unknown[]) => unknown) => Disposable;
onDirectoryCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Intercept - receives { file: FileResourceView, newName: string }, return false to cancel */
onBeforeFileRename: (handler: (ctx: { file: import('./plugin-types').FileResourceView; newName: string }) => boolean | void | Promise<boolean | void>) => Disposable;
onFileRename: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileMove: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileCopy: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileDuplicate: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileFavoriteToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileSelectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileUndo: (handler: (...args: unknown[]) => unknown) => Disposable;
// Auth
onLogin: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeLogout: (handler: () => void) => Disposable;
onAfterLogout: (handler: () => void) => Disposable;
onAccountSwitch: (handler: (...args: unknown[]) => unknown) => Disposable;
onAccountAdd: (handler: (...args: unknown[]) => unknown) => Disposable;
onAccountRemove: (handler: (...args: unknown[]) => unknown) => Disposable;
onTokenRefresh: (handler: () => void) => Disposable;
onAuthReady: (handler: (...args: unknown[]) => unknown) => Disposable;
// Settings
onSettingChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onSettingsExport: (handler: () => void) => Disposable;
onSettingsImport: (handler: (...args: unknown[]) => unknown) => Disposable;
onSettingsReset: (handler: () => void) => Disposable;
onSettingsSync: (handler: (...args: unknown[]) => unknown) => Disposable;
onKeywordChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onTrustedSenderChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Identity
onIdentitiesLoaded: (handler: (...args: unknown[]) => unknown) => Disposable;
onIdentityCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onIdentityUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
onIdentityDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onIdentitySelect: (handler: (...args: unknown[]) => unknown) => Disposable;
onSignatureRender: (handler: (...args: unknown[]) => unknown) => Disposable;
// Filters
onFiltersLoaded: (handler: (...args: unknown[]) => unknown) => Disposable;
onFilterRuleChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onFiltersSave: (handler: (...args: unknown[]) => unknown) => Disposable;
onSieveScriptChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Tasks
onTasksLoaded: (handler: (...args: unknown[]) => unknown) => Disposable;
onTaskCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onTaskUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
onTaskDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onTaskToggleComplete: (handler: (...args: unknown[]) => unknown) => Disposable;
onTaskFilterChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Templates
onTemplateCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onTemplateUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
onTemplateDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onTemplateApply: (handler: (...args: unknown[]) => unknown) => Disposable;
onTemplatesImport: (handler: (...args: unknown[]) => unknown) => Disposable;
onTemplateRender: (handler: (...args: unknown[]) => unknown) => Disposable;
// S/MIME
onSmimeKeyImport: (handler: (...args: unknown[]) => unknown) => Disposable;
onSmimeCertImport: (handler: (...args: unknown[]) => unknown) => Disposable;
onSmimeKeyStateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onSmimeDefaultsChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Vacation
onVacationLoaded: (handler: (...args: unknown[]) => unknown) => Disposable;
onVacationUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
// UI
onViewChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onSidebarToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
onSidebarCollapse: (handler: (...args: unknown[]) => unknown) => Disposable;
onDeviceTypeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onColumnResize: (handler: (...args: unknown[]) => unknown) => Disposable;
onMobileBack: (handler: () => void) => Disposable;
onMobileViewSwitch: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Intercept - receives ExternalLinkContext, return false to cancel navigation */
onBeforeExternalLink: (handler: (ctx: import('./plugin-types').ExternalLinkContext) => boolean | void | Promise<boolean | void>) => Disposable;
/** Observer - debounced text-selection change */
onTextSelectionChange: (handler: (ctx: import('./plugin-types').SelectionContext) => void) => Disposable;
// Theme
onThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onCustomThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onLocaleChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Toast
onToastShow: (handler: (...args: unknown[]) => unknown) => Disposable;
onToastDismiss: (handler: (...args: unknown[]) => unknown) => Disposable;
onBrowserNotification: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Observer fired when an OS-level notification is clicked */
onNotificationClick: (handler: (ctx: { tag: string; data?: unknown }) => void) => Disposable;
// Drag & Drop
onDragStart: (handler: (...args: unknown[]) => unknown) => Disposable;
onDragEnd: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailDrop: (handler: (...args: unknown[]) => unknown) => Disposable;
onTagDrop: (handler: (...args: unknown[]) => unknown) => Disposable;
// Keyboard
registerShortcut: (shortcut: KeyboardShortcut) => Disposable;
onBeforeShortcut: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterShortcut: (handler: (...args: unknown[]) => unknown) => Disposable;
// App Lifecycle
onAppReady: (handler: () => void) => Disposable;
onVisibilityChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeUnload: (handler: () => void) => Disposable;
onAppError: (handler: (...args: unknown[]) => unknown) => Disposable;
onInterval: (handler: () => void, intervalMs: number) => Disposable;
/** Observer - browser window focus / blur */
onWindowFocus: (handler: () => void) => Disposable;
onWindowBlur: (handler: () => void) => Disposable;
/** Observer - network connectivity transitions */
onOnline: (handler: () => void) => Disposable;
onOffline: (handler: () => void) => Disposable;
// Account Security
onPasswordChange: (handler: () => void) => Disposable;
onTotpChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onAppPasswordChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onEncryptionChange: (handler: () => void) => Disposable;
onDisplayNameChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Sidebar Apps
onSidebarAppOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onSidebarAppClose: (handler: (...args: unknown[]) => unknown) => Disposable;
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Avatar
onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable;
// Render - transform hook for email list row badges
// Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[]
onEmailListItemRender: (handler: (...args: unknown[]) => unknown) => Disposable;
// Router
/** Observer - fired on every in-app navigation. RouteContext.from holds the previous path. */
onNavigate: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
onRouteEnter: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
onRouteLeave: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
}
// --- Permission mapping for hooks ----------------------------
const HOOK_PERMISSIONS: Record<string, Permission> = {
// Email
onEmailOpen: 'email:read', onEmailClose: 'email:read',
onEmailContentRender: 'email:read', onThreadExpand: 'email:read',
onBeforeCompose: 'email:read', onComposerOpen: 'email:read',
onDraftAutoSave: 'email:read',
onMailboxChange: 'email:read', onMailboxesRefresh: 'email:read',
onSearch: 'email:read', onSearchResults: 'email:read',
onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
onMailtoIntercept: 'email:read', onEmailListItemRender: 'email:read',
onBeforeReply: 'email:read', onBeforeReplyAll: 'email:read',
onBeforeForward: 'email:read', onAttachmentDownload: 'email:read',
onAttachmentPreview: 'email:read', onProvideSearchResults: 'email:read',
onDraftChange: 'email:read',
onBeforeAttachmentUpload: 'email:write', onAfterAttachmentUpload: 'email:write',
onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send',
onTransformOutgoingEmail: 'email:send',
onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
onEmailArchive: 'email:write', onEmailUnarchive: 'email:write',
onEmailReadStateChange: 'email:write', onEmailStarToggle: 'email:write',
onEmailSpamToggle: 'email:write', onEmailKeywordChange: 'email:write',
onMailboxCreate: 'email:write', onMailboxRename: 'email:write',
onMailboxDelete: 'email:write', onMailboxEmpty: 'email:write',
// Calendar
onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read',
onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read',
onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read',
onCheckEventConflicts: 'calendar:read',
onCalendarEventFormOpen: 'calendar:read', onCalendarEventFormSave: 'calendar:write',
onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write',
onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write',
onBeforeEventDelete: 'calendar:write', onAfterEventDelete: 'calendar:write',
onEventRsvp: 'calendar:write', onEventsImport: 'calendar:write',
onCalendarChange: 'calendar:write', onICalSubscriptionChange: 'calendar:write',
// Contacts
onContactOpen: 'contacts:read', onContactSelectionChange: 'contacts:read',
onProvideRecipientSuggestions: 'contacts:read',
onBeforeContactCreate: 'contacts:write', onAfterContactCreate: 'contacts:write',
onBeforeContactUpdate: 'contacts:write', onAfterContactUpdate: 'contacts:write',
onBeforeContactDelete: 'contacts:write', onAfterContactDelete: 'contacts:write',
onContactsImport: 'contacts:write', onContactGroupChange: 'contacts:write',
onContactGroupMemberChange: 'contacts:write', onContactMove: 'contacts:write',
// Files
onFileNavigate: 'files:read', onFileDownload: 'files:read', onFileSelectionChange: 'files:read',
onBeforeFileUpload: 'files:write', onAfterFileUpload: 'files:write',
onFileUploadCancel: 'files:write', onDirectoryCreate: 'files:write',
onBeforeFileDelete: 'files:write', onAfterFileDelete: 'files:write',
onBeforeFileRename: 'files:write',
onFileRename: 'files:write', onFileMove: 'files:write', onFileCopy: 'files:write',
onFileDuplicate: 'files:write', onFileFavoriteToggle: 'files:write', onFileUndo: 'files:write',
// Auth
onLogin: 'auth:observe', onBeforeLogout: 'auth:observe', onAfterLogout: 'auth:observe',
onAccountSwitch: 'auth:observe', onAccountAdd: 'auth:observe', onAccountRemove: 'auth:observe',
onTokenRefresh: 'auth:observe', onAuthReady: 'auth:observe',
// Settings
onSettingChange: 'settings:read', onSettingsExport: 'settings:read',
onSettingsImport: 'settings:read', onSettingsReset: 'settings:read',
onSettingsSync: 'settings:read', onKeywordChange: 'settings:read',
onTrustedSenderChange: 'settings:read',
// Identity
onIdentitiesLoaded: 'identity:read', onIdentitySelect: 'identity:read',
onSignatureRender: 'identity:read',
onIdentityCreate: 'identity:write', onIdentityUpdate: 'identity:write',
onIdentityDelete: 'identity:write',
// Filters
onFiltersLoaded: 'filters:read',
onFilterRuleChange: 'filters:write', onFiltersSave: 'filters:write',
onSieveScriptChange: 'filters:write',
// Tasks
onTasksLoaded: 'tasks:read', onTaskFilterChange: 'tasks:read',
onTaskCreate: 'tasks:write', onTaskUpdate: 'tasks:write',
onTaskDelete: 'tasks:write', onTaskToggleComplete: 'tasks:write',
// Templates
onTemplateApply: 'templates:read', onTemplateRender: 'templates:read',
onTemplateCreate: 'templates:write', onTemplateUpdate: 'templates:write',
onTemplateDelete: 'templates:write', onTemplatesImport: 'templates:write',
// S/MIME
onSmimeKeyImport: 'smime:read', onSmimeCertImport: 'smime:read',
onSmimeKeyStateChange: 'smime:read', onSmimeDefaultsChange: 'smime:read',
// Vacation
onVacationLoaded: 'vacation:read', onVacationUpdate: 'vacation:write',
// UI
onViewChange: 'ui:observe', onSidebarToggle: 'ui:observe',
onSidebarCollapse: 'ui:observe', onDeviceTypeChange: 'ui:observe',
onColumnResize: 'ui:observe', onMobileBack: 'ui:observe',
onMobileViewSwitch: 'ui:observe',
onBeforeExternalLink: 'ui:observe', onTextSelectionChange: 'ui:observe',
// Theme
onThemeChange: 'ui:observe', onCustomThemeChange: 'ui:observe',
onLocaleChange: 'ui:observe',
// Toast
onToastShow: 'ui:observe', onToastDismiss: 'ui:observe',
onBrowserNotification: 'ui:observe', onNotificationClick: 'ui:observe',
// Drag & Drop
onDragStart: 'ui:observe', onDragEnd: 'ui:observe',
onEmailDrop: 'ui:observe', onTagDrop: 'ui:observe',
// Keyboard
registerShortcut: 'ui:keyboard', onBeforeShortcut: 'ui:keyboard',
onAfterShortcut: 'ui:keyboard',
// App Lifecycle
onAppReady: 'app:lifecycle', onVisibilityChange: 'app:lifecycle',
onBeforeUnload: 'app:lifecycle', onAppError: 'app:lifecycle',
onInterval: 'app:lifecycle',
onWindowFocus: 'app:lifecycle', onWindowBlur: 'app:lifecycle',
onOnline: 'app:lifecycle', onOffline: 'app:lifecycle',
// Account Security
onPasswordChange: 'security:read', onTotpChange: 'security:read',
onAppPasswordChange: 'security:read', onEncryptionChange: 'security:read',
onDisplayNameChange: 'security:read',
// Sidebar Apps
onSidebarAppOpen: 'ui:observe', onSidebarAppClose: 'ui:observe',
onSidebarAppChange: 'ui:observe',
// Avatar
onAvatarResolve: 'email:read',
// Router
onNavigate: 'ui:observe', onRouteEnter: 'ui:observe', onRouteLeave: 'ui:observe',
};
// Map hook names → actual HookBus instances
const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...args: unknown[]) => unknown, order?: number) => Disposable }> = {
// Email
...Object.fromEntries(Object.entries(emailHooks)),
// Calendar
...Object.fromEntries(Object.entries(calendarHooks)),
// Calendar Form
...Object.fromEntries(Object.entries(calendarFormHooks)),
// Contacts
...Object.fromEntries(Object.entries(contactHooks)),
// Files
...Object.fromEntries(Object.entries(fileHooks)),
// Auth
...Object.fromEntries(Object.entries(authHooks)),
// Settings
...Object.fromEntries(Object.entries(settingsHooks)),
// Identity
...Object.fromEntries(Object.entries(identityHooks)),
// Filters
...Object.fromEntries(Object.entries(filterHooks)),
// Tasks
...Object.fromEntries(Object.entries(taskHooks)),
// Templates
...Object.fromEntries(Object.entries(templateHooks)),
// S/MIME
...Object.fromEntries(Object.entries(smimeHooks)),
// Vacation
...Object.fromEntries(Object.entries(vacationHooks)),
// UI
...Object.fromEntries(Object.entries(uiHooks)),
// Theme
...Object.fromEntries(Object.entries(themeHooks)),
// Toast
...Object.fromEntries(Object.entries(toastHooks)),
// Drag & Drop
...Object.fromEntries(Object.entries(dragDropHooks)),
// Keyboard
...Object.fromEntries(Object.entries(keyboardHooks)),
// App Lifecycle
...Object.fromEntries(Object.entries(appLifecycleHooks)),
// Account Security
...Object.fromEntries(Object.entries(accountSecurityHooks)),
// Sidebar Apps
...Object.fromEntries(Object.entries(sidebarAppHooks)),
// Avatar
...Object.fromEntries(Object.entries(avatarHooks)),
// Render
...Object.fromEntries(Object.entries(renderHooks)),
// Router
...Object.fromEntries(Object.entries(routerHooks)),
};
// --- Slot registration bridge --------------------------------
// Lazy import to avoid circular dependency — plugin-store imports plugin-api indirectly
let registerSlotFn: ((name: SlotName, reg: { pluginId: string; component: React.ComponentType<Record<string, unknown>>; order: number }) => Disposable) | null = null;
export function setSlotRegistrationBridge(fn: typeof registerSlotFn): void {
registerSlotFn = fn;
}
function registerSlot(
pluginId: string,
slotName: SlotName,
component: React.ComponentType<Record<string, unknown>>,
order: number = 100,
): Disposable {
if (!registerSlotFn) {
console.warn(`[plugin:${pluginId}] Slot registration not available yet`);
return { dispose: () => {} };
}
return registerSlotFn(slotName, { pluginId, component, order });
}
// --- Factory -------------------------------------------------
export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
// Build hooks proxy — each hook method checks permission and registers on the right bus
const hooks: PluginHooksAPI = {} as PluginHooksAPI;
for (const [hookName, bus] of Object.entries(HOOK_BUSES)) {
const perm = HOOK_PERMISSIONS[hookName];
if (!perm) continue;
if (hookName === 'onInterval') {
// Special: onInterval takes (handler, intervalMs)
(hooks as unknown as Record<string, unknown>)[hookName] = (handler: () => void, intervalMs: number) => {
if (!hasPermission(plugin, perm)) return { dispose: () => {} };
const safeMs = Math.max(intervalMs, 60_000); // min 60s
const id = setInterval(handler, safeMs);
return { dispose: () => clearInterval(id) };
};
} else if (hookName === 'registerShortcut') {
// Special: registerShortcut takes a KeyboardShortcut object
(hooks as unknown as Record<string, unknown>)[hookName] = (shortcut: KeyboardShortcut) => {
return guardedHook(plugin, perm, bus, shortcut.handler);
};
} else {
(hooks as unknown as Record<string, unknown>)[hookName] = (handler: (...args: unknown[]) => unknown) => {
return guardedHook(plugin, perm, bus, handler);
};
}
}
return {
plugin: {
id: plugin.id,
version: plugin.version,
settings: { ...plugin.settings },
},
i18n: createPluginI18n(plugin.id),
ui: {
registerToolbarAction: (action: ToolbarAction) => {
requirePermission(plugin, 'ui:toolbar');
const Component = () => {
const externals = getPluginExternals();
const React = externals?.React;
if (!React) return null;
const createElement = (React as { createElement: typeof import('react').createElement }).createElement;
return createElement('button', {
onClick: action.onClick,
className: 'plugin-toolbar-action',
title: action.label,
}, action.label);
};
return registerSlot(plugin.id, 'toolbar-actions', Component as React.ComponentType<Record<string, unknown>>, action.order ?? 100);
},
registerEmailBanner: (factory: BannerFactory) => {
requirePermission(plugin, 'ui:email-banner');
return registerSlot(plugin.id, 'email-banner', factory.render as unknown as React.ComponentType<Record<string, unknown>>, 100);
},
registerEmailFooter: (component: React.ComponentType) => {
requirePermission(plugin, 'ui:email-footer');
return registerSlot(plugin.id, 'email-footer', component as React.ComponentType<Record<string, unknown>>, 100);
},
registerSettingsSection: (section: SettingsSection) => {
requirePermission(plugin, 'ui:settings-section');
return registerSlot(plugin.id, 'settings-section', section.render as React.ComponentType<Record<string, unknown>>, 100);
},
registerComposerAction: (action: ComposerAction) => {
requirePermission(plugin, 'ui:composer-toolbar');
const Component = () => {
const externals = getPluginExternals();
const React = externals?.React;
if (!React) return null;
const createElement = (React as { createElement: typeof import('react').createElement }).createElement;
return createElement('button', {
onClick: action.onClick,
className: 'plugin-composer-action',
title: action.label,
}, action.label);
};
return registerSlot(plugin.id, 'composer-toolbar', Component as React.ComponentType<Record<string, unknown>>, action.order ?? 100);
},
registerSidebarWidget: (widget: SidebarWidget) => {
requirePermission(plugin, 'ui:sidebar-widget');
return registerSlot(plugin.id, 'sidebar-widget', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
},
registerComposerSidebar: (widget: SidebarWidget) => {
requirePermission(plugin, 'ui:composer-sidebar');
const slot = widget.side === 'right' ? 'composer-sidebar-right' : 'composer-sidebar';
return registerSlot(plugin.id, slot, widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
},
registerDetailSidebar: (widget: SidebarWidget) => {
requirePermission(plugin, 'ui:sidebar-widget');
return registerSlot(plugin.id, 'email-detail-sidebar', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
},
registerContextMenuItem: (item: ContextMenuItem) => {
requirePermission(plugin, 'ui:context-menu');
const Component = () => {
const externals = getPluginExternals();
const React = externals?.React;
if (!React) return null;
const createElement = (React as { createElement: typeof import('react').createElement }).createElement;
return createElement('button', {
onClick: () => item.onClick([]),
className: 'plugin-context-menu-item',
}, item.label);
};
return registerSlot(plugin.id, 'context-menu-email', Component as React.ComponentType<Record<string, unknown>>, item.order ?? 100);
},
registerNavigationRailItem: (component: React.ComponentType) => {
requirePermission(plugin, 'ui:navigation-rail');
return registerSlot(plugin.id, 'navigation-rail-bottom', component as React.ComponentType<Record<string, unknown>>, 100);
},
registerCalendarEventAction: (action: CalendarEventAction) => {
requirePermission(plugin, 'ui:calendar-action');
const Component = (props: Record<string, unknown>) => {
const externals = getPluginExternals();
const React = externals?.React;
if (!React) return null;
const createElement = (React as { createElement: typeof import('react').createElement }).createElement;
const iconSpan = createElement('span', {
'aria-hidden': 'true',
style: { display: 'contents' },
dangerouslySetInnerHTML: {
__html: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8z"/><rect x="2" y="8" width="14" height="12" rx="2"/></svg>',
},
});
return createElement('button', {
onClick: () => action.onClick(
props.eventData as import('./plugin-types').CalendarEventFormView,
{ setVirtualLocation: props.setVirtualLocation as (url: string) => void },
),
className: 'inline-flex items-center gap-1.5 h-9 px-3 text-sm font-medium rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background cursor-pointer',
title: action.label,
type: 'button',
}, iconSpan, action.label);
};
return registerSlot(plugin.id, 'calendar-event-actions', Component as React.ComponentType<Record<string, unknown>>, action.order ?? 100);
},
registerAdminPage: (page: AdminPageSection) => {
requirePermission(plugin, 'ui:admin-page');
return registerSlot(plugin.id, 'admin-plugin-page', page.render as React.ComponentType<Record<string, unknown>>, 100);
},
},
hooks,
toast: {
success: (message: string) => appToast.success(message),
error: (message: string) => appToast.error(message),
info: (message: string) => appToast.info(message),
warning: (message: string) => appToast.warning(message),
},
http: {
post: async (path: string, body: Record<string, unknown>) => {
requirePermission(plugin, 'http:post');
if (typeof path !== 'string' || !path.startsWith('/api/')) {
throw new Error('path must start with /api/');
}
const url = new URL(path, globalThis.location.origin);
if (url.origin !== globalThis.location.origin) {
throw new Error('path must resolve to the same origin');
}
const { client } = useAuthStore.getState();
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (client) {
headers['Authorization'] = client.getAuthHeader();
headers['X-JMAP-Username'] = client.getUsername();
}
const res = await fetch(url.pathname + url.search, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
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),
log: createPluginLogger(plugin.id),
admin: {
getConfig: async (key: string) => {
requirePermission(plugin, 'admin:config');
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`);
if (!res.ok) return null;
const data = await res.json();
return data[key] ?? null;
},
getAllConfig: async () => {
requirePermission(plugin, 'admin:config');
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`);
if (!res.ok) return {};
return res.json();
},
setConfig: async (key: string, value: unknown) => {
requirePermission(plugin, 'admin:config');
await apiFetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
});
},
deleteConfig: async (key: string) => {
requirePermission(plugin, 'admin:config');
await apiFetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
},
},
};
}
+6
View File
@@ -222,6 +222,12 @@ export const emailHooks = {
onBeforeReply: new HookBus(),
onBeforeReplyAll: new HookBus(),
onBeforeForward: new HookBus(),
// Transform hook - lets plugins replace the quote header block ("On X,
// Y wrote:" / "---------- Forwarded message ----------") used when opening
// a reply or forward. Initial value: QuoteHeader (host default), second
// argument: QuoteHeaderContext. Handlers return a QuoteHeader (or
// undefined to pass through). Fires once per composer open.
onBuildQuoteHeader: new HookBus(),
// Intercept hook fired before a file is added to the composer as an
// attachment. Handler receives AttachmentInfo (size/type/name only - the
// raw file is not exposed). Return false to refuse the upload.
+51 -157
View File
@@ -1,187 +1,81 @@
// Plugin Loader - loads and activates plugins via blob URL dynamic import
// Plugin loader entrypoint. Delegates to the iframe-based sandbox in
// `lib/plugin-sandbox/`. The legacy blob-URL `import()` path has been
// removed; plugin bundles now run in a null-origin sandbox iframe and
// communicate with the host via postMessage RPC.
import type { InstalledPlugin, Disposable } from './plugin-types';
import { pluginStorage } from './plugin-storage';
import { createPluginAPI, type PluginAPI } from './plugin-api';
import { removeAllPluginHooks, pluginErrorTracker } from './plugin-hooks';
import { setPluginI18nLocale, clearPluginI18nTranslations } from './plugin-i18n';
import React from 'react';
import ReactDOM from 'react-dom';
import * as ReactJSX from 'react/jsx-runtime';
// --- Shared React (window.__PLUGIN_EXTERNALS__) -------------
let localeSyncInitialised = false;
import type { InstalledPlugin } from './plugin-types';
import {
loadSandboxedPlugin,
unloadSandboxedPlugin,
activateAllSandboxed,
deactivateAllSandboxed,
setSandboxStoreAccessor,
setSandboxLocale,
setupSandboxAutoDisable,
} from './plugin-sandbox/loader';
import { all as allActive, get as getActive } from './plugin-sandbox/registry';
/**
* Previously: re-published React/ReactDOM on `globalThis.__PLUGIN_EXTERNALS__`
* so blob-imported plugin code could resolve `react`. With the sandbox model
* plugins receive React injected as a function argument inside their iframe
* runtime — there is nothing to expose on the host window.
*
* Kept as a no-op for callers that still invoke it during app bootstrap.
*/
export function exposePluginExternals(): void {
if (typeof window === 'undefined') return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).__PLUGIN_EXTERNALS__ = {
React,
ReactDOM,
ReactJSX,
};
// Sync plugin i18n with the app locale (runs once per page load)
if (!localeSyncInitialised) {
localeSyncInitialised = true;
// Dynamic import avoids a circular dependency chain at module evaluation time
import('@/stores/locale-store').then(({ useLocaleStore }) => {
setPluginI18nLocale(useLocaleStore.getState().locale);
useLocaleStore.subscribe((state) => setPluginI18nLocale(state.locale));
}).catch(() => {/* locale sync is best-effort */});
}
// Initialise the locale sync once. Importing the store lazily avoids the
// circular module graph we used to fight before the sandbox refactor.
void import('@/stores/locale-store').then(({ useLocaleStore }) => {
setSandboxLocale(useLocaleStore.getState().locale);
useLocaleStore.subscribe((state) => setSandboxLocale(state.locale));
// Mirror on a global so the slot-iframe component can read it at spawn.
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = useLocaleStore.getState().locale;
useLocaleStore.subscribe((state) => {
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = state.locale;
});
}).catch(() => { /* locale sync is best-effort */ });
}
// --- Active plugin tracking ----------------------------------
// ─── Store accessor (status updates) ──────────────────────────
interface ActivePlugin {
id: string;
api: PluginAPI;
disposable?: Disposable;
deactivate?: () => void;
type StoreAccessor = { setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void };
export function setPluginStoreAccessor(accessor: StoreAccessor): void {
setSandboxStoreAccessor(accessor);
}
const activePlugins = new Map<string, ActivePlugin>();
// --- Load a single plugin ------------------------------------
type PluginStoreAccessor = {
setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void;
};
let storeAccessor: PluginStoreAccessor | null = null;
export function setPluginStoreAccessor(accessor: PluginStoreAccessor): void {
storeAccessor = accessor;
}
// ─── Lifecycle (sandbox-backed) ───────────────────────────────
export async function loadPlugin(plugin: InstalledPlugin): Promise<void> {
if (activePlugins.has(plugin.id)) {
console.warn(`[plugin-loader] Plugin "${plugin.id}" is already loaded`);
if (getActive(plugin.id)) {
console.warn(`[plugin-loader] "${plugin.id}" is already loaded`);
return;
}
// Ensure React/ReactDOM are exposed before any plugin module evaluates
exposePluginExternals();
try {
// 1. Read bundle from IndexedDB
const code = await pluginStorage.getCode(plugin.id);
if (!code) {
throw new Error(`No code found in storage for plugin "${plugin.id}"`);
}
// 2. Create scoped module via blob URL
const blob = new Blob([code], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
// 3. Dynamic import (webpackIgnore prevents bundler processing)
let mod: { activate?: (api: PluginAPI) => void | Disposable; deactivate?: () => void };
try {
mod = await import(/* webpackIgnore: true */ url);
} finally {
URL.revokeObjectURL(url);
}
if (typeof mod.activate !== 'function') {
throw new Error(`Plugin "${plugin.id}" has no activate() export`);
}
// 4. Build sandboxed API
const api = createPluginAPI(plugin);
// 4b. Auto-register translations bundled in the manifest (plugin.locales)
// Plugins may still call api.i18n.addTranslations() in activate() to add more.
if (plugin.locales) {
for (const [locale, strings] of Object.entries(plugin.locales)) {
api.i18n.addTranslations(locale, strings);
}
}
// 5. Call activate
const disposable = await mod.activate(api);
// 6. Track active plugin
activePlugins.set(plugin.id, {
id: plugin.id,
api,
disposable: disposable && typeof disposable === 'object' && 'dispose' in disposable
? disposable as Disposable
: undefined,
deactivate: mod.deactivate,
});
// 7. Mark running
storeAccessor?.setPluginStatus(plugin.id, 'running');
console.info(`[plugin-loader] Plugin "${plugin.id}" activated`);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
storeAccessor?.setPluginStatus(plugin.id, 'error', errorMsg);
console.error(`[plugin-loader] Plugin "${plugin.id}" failed to load:`, err);
}
await loadSandboxedPlugin(plugin);
}
// --- Deactivate a single plugin ------------------------------
export function deactivatePlugin(pluginId: string): void {
const active = activePlugins.get(pluginId);
if (!active) return;
try {
// Call deactivate() if provided
active.deactivate?.();
// Dispose the disposable returned from activate()
active.disposable?.dispose();
} catch (err) {
console.error(`[plugin-loader] Error deactivating plugin "${pluginId}":`, err);
}
// Remove all hook subscriptions for this plugin
removeAllPluginHooks(pluginId);
// Clear cached translations (avoids memory leak on repeated enable/disable cycles)
clearPluginI18nTranslations(pluginId);
// Reset error tracker
pluginErrorTracker.reset(pluginId);
activePlugins.delete(pluginId);
storeAccessor?.setPluginStatus(pluginId, 'disabled');
console.info(`[plugin-loader] Plugin "${pluginId}" deactivated`);
unloadSandboxedPlugin(pluginId);
}
// --- Activate all enabled plugins ----------------------------
export async function activateAllPlugins(plugins: InstalledPlugin[]): Promise<void> {
// Ensure externals are exposed
exposePluginExternals();
const enabledPlugins = plugins.filter(p => p.enabled && p.status !== 'error');
for (const plugin of enabledPlugins) {
await loadPlugin(plugin);
}
await activateAllSandboxed(plugins);
}
// --- Deactivate all plugins ---------------------------------
export function deactivateAllPlugins(): void {
for (const pluginId of [...activePlugins.keys()]) {
deactivatePlugin(pluginId);
}
deactivateAllSandboxed();
}
// --- Check if a plugin is active -----------------------------
export function isPluginActive(pluginId: string): boolean {
return activePlugins.has(pluginId);
return getActive(pluginId) !== undefined;
}
// --- Setup auto-disable callback -----------------------------
export function setupAutoDisable(): void {
pluginErrorTracker.setAutoDisableCallback((pluginId) => {
deactivatePlugin(pluginId);
storeAccessor?.setPluginStatus(pluginId, 'error', 'Auto-disabled due to repeated errors');
});
setupSandboxAutoDisable();
}
// Re-export for stores/tests that need the active set.
export { allActive as activePlugins };
+57
View File
@@ -0,0 +1,57 @@
// SHA-256 integrity check for plugin bundles.
//
// The bundle endpoint returns the canonical hash as the ETag. The client
// re-hashes the bytes after fetch and refuses to load on mismatch. This
// closes the gap where a compromised admin route (or transient MITM upstream
// of the CDN/proxy) could swap the bundle silently.
export async function sha256Hex(input: string | Uint8Array): Promise<string> {
// Re-wrap so the buffer is a plain ArrayBuffer (not SharedArrayBuffer) to
// satisfy lib.dom's BufferSource typing.
let buf: ArrayBuffer;
if (typeof input === 'string') {
buf = new TextEncoder().encode(input).buffer as ArrayBuffer;
} else {
const copy = new Uint8Array(input.byteLength);
copy.set(input);
buf = copy.buffer;
}
const digest = await crypto.subtle.digest('SHA-256', buf);
const view = new Uint8Array(digest);
let out = '';
for (let i = 0; i < view.length; i++) {
const h = view[i].toString(16);
out += h.length === 1 ? '0' + h : h;
}
return out;
}
/**
* Compare `actual` and `expected` in constant time. Both must be the same
* length lower-case hex strings. Returns false on any structural mismatch.
*/
export function constantTimeHexEqual(actual: string, expected: string): boolean {
if (typeof actual !== 'string' || typeof expected !== 'string') return false;
if (actual.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < actual.length; i++) {
diff |= actual.charCodeAt(i) ^ expected.charCodeAt(i);
}
return diff === 0;
}
/**
* Verify `code` against `expectedHash`. Returns the (normalised) hash on
* match, throws on mismatch. Pass `null`/`undefined` for `expectedHash` to
* compute-and-return without verification (used for dev-plugin paths).
*/
export async function verifyBundle(code: string, expectedHash: string | null | undefined): Promise<string> {
const actual = await sha256Hex(code);
if (!expectedHash) return actual;
// Server may quote the hash (it's also used as an ETag); strip and compare.
const normalised = expectedHash.replace(/^"|"$/g, '').trim().toLowerCase();
if (!constantTimeHexEqual(actual, normalised)) {
throw new Error(`Bundle integrity mismatch: expected ${normalised}, got ${actual}`);
}
return actual;
}
+90
View File
@@ -0,0 +1,90 @@
// Client-side Ed25519 verification for plugin bundles.
//
// On boot the loader fetches the host's public key from
// `/api/plugin-signing-pubkey`. Each `/api/admin/plugins/[id]/bundle` response
// includes the signature as the `X-Bundle-Signature` header. Before evaluating
// a bundle the loader verifies the signature; mismatch refuses the load.
//
// User-installed plugins (uploaded via the file picker, no server hop) have
// no signature — verification is skipped for those, since the user is
// installing their own code. Verification kicks in for server-managed
// bundles only (the `managed: true` flag on `InstalledPlugin`).
let cachedPubKey: CryptoKey | null = null;
let pubKeyPromise: Promise<CryptoKey | null> | null = null;
async function importEd25519PublicKey(raw: Uint8Array): Promise<CryptoKey | null> {
if (typeof crypto === 'undefined' || !crypto.subtle) return null;
try {
// Browser Web Crypto supports Ed25519 via `name: 'Ed25519'` (no hash).
return await crypto.subtle.importKey('raw', raw.buffer.slice(0) as ArrayBuffer, { name: 'Ed25519' }, false, ['verify']);
} catch (err) {
console.warn('[plugin-signing] Web Crypto Ed25519 import failed', err);
return null;
}
}
async function fetchPublicKey(): Promise<CryptoKey | null> {
try {
const res = await fetch('/api/plugin-signing-pubkey', { credentials: 'same-origin' });
if (!res.ok) return null;
const data = await res.json() as { algorithm?: string; publicKey?: string };
if (data.algorithm !== 'ed25519' || typeof data.publicKey !== 'string') return null;
const raw = base64ToBytes(data.publicKey);
if (raw.length !== 32) return null;
return importEd25519PublicKey(raw);
} catch (err) {
console.warn('[plugin-signing] could not fetch public key', err);
return null;
}
}
export async function getPluginSigningKey(): Promise<CryptoKey | null> {
if (cachedPubKey) return cachedPubKey;
if (!pubKeyPromise) {
pubKeyPromise = fetchPublicKey().then((k) => { cachedPubKey = k; return k; });
}
return pubKeyPromise;
}
/** Force a refresh on next access (e.g. after key rotation). */
export function invalidatePluginSigningKeyCache(): void {
cachedPubKey = null;
pubKeyPromise = null;
}
/**
* Verify a base64 Ed25519 signature against the bundle bytes. Returns false
* on any failure (missing key, invalid encoding, signature mismatch). Never
* throws.
*/
export async function verifySignature(code: string, signatureB64: string): Promise<boolean> {
if (!signatureB64) return false;
const key = await getPluginSigningKey();
if (!key) return false;
let signature: Uint8Array;
try {
signature = base64ToBytes(signatureB64);
} catch {
return false;
}
if (signature.length !== 64) return false;
const data = new TextEncoder().encode(code);
try {
return await crypto.subtle.verify(
{ name: 'Ed25519' },
key,
signature.buffer.slice(0) as ArrayBuffer,
data.buffer.slice(0) as ArrayBuffer,
);
} catch {
return false;
}
}
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
+88
View File
@@ -0,0 +1,88 @@
// Queue for plugin permission-consent requests.
//
// On first enable the plugin store posts a ConsentRequest here; the
// `PluginConsentDialog` component renders the head and resolves the promise
// when the user accepts or rejects. The store persists the granted set in
// `plugin.grantedPermissions` so future enables don't re-prompt.
import type { Permission } from '../plugin-types';
export interface ConsentRequest {
id: string;
pluginId: string;
pluginName: string;
permissions: Permission[];
resolve: (granted: boolean) => void;
}
const queue: ConsentRequest[] = [];
const listeners = new Set<() => void>();
function notify(): void {
for (const l of listeners) {
try { l(); } catch { /* ignore */ }
}
}
function uid(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
export function requestConsent(pluginId: string, pluginName: string, permissions: Permission[]): Promise<boolean> {
return new Promise<boolean>((resolve) => {
queue.push({ id: uid(), pluginId, pluginName, permissions, resolve });
notify();
});
}
export function head(): ConsentRequest | null {
return queue[0] ?? null;
}
export function resolveHead(granted: boolean): void {
const entry = queue.shift();
if (!entry) return;
try { entry.resolve(granted); } catch { /* ignore */ }
notify();
}
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
// ─── Friendly labels for permission strings ───────────────────
const PERMISSION_LABELS: Record<string, { title: string; body: string }> = {
'email:read': { title: 'Read your email', body: 'Access subjects, senders, recipients, body previews, and message bodies of your messages.' },
'email:write': { title: 'Modify your email', body: 'Move, delete, flag, archive, or change keywords on your messages.' },
'email:send': { title: 'Send mail and transform drafts', body: 'Compose and send messages, and modify content right before delivery.' },
'calendar:read': { title: 'Read your calendar', body: 'Access events, calendars, RSVPs, and reminders.' },
'calendar:write': { title: 'Modify your calendar', body: 'Create, edit, or delete events.' },
'contacts:read': { title: 'Read your contacts', body: 'Access your address book entries.' },
'contacts:write': { title: 'Modify your contacts', body: 'Create, edit, or delete contact entries.' },
'files:read': { title: 'Read your files', body: 'Browse files stored in your WebDAV folders.' },
'files:write': { title: 'Modify your files', body: 'Create, edit, rename, move, or delete files.' },
'identity:read': { title: 'Read your identities', body: 'Access the From addresses and signatures you send mail from.' },
'identity:write': { title: 'Modify your identities', body: 'Create, edit, or delete identities.' },
'filters:read': { title: 'Read your filters', body: 'Access your Sieve mail-filter rules.' },
'filters:write': { title: 'Modify your filters', body: 'Create, edit, or delete Sieve filter rules.' },
'tasks:read': { title: 'Read your tasks', body: 'Access your task list.' },
'tasks:write': { title: 'Modify your tasks', body: 'Create, edit, or delete tasks.' },
'templates:read': { title: 'Read your templates', body: 'Access stored mail templates.' },
'templates:write': { title: 'Modify your templates', body: 'Create, edit, or delete mail templates.' },
'smime:read': { title: 'Read your S/MIME state', body: 'Access information about installed S/MIME keys and certificates.' },
'vacation:read': { title: 'Read your vacation auto-reply', body: 'See the configured vacation auto-reply state.' },
'vacation:write': { title: 'Modify your vacation auto-reply',body: 'Create, change, or remove the vacation auto-reply.' },
'settings:read': { title: 'Read your settings', body: 'Access non-secret user preferences.' },
'settings:write': { title: 'Modify your settings', body: 'Change non-secret user preferences.' },
'security:read': { title: 'Read account security state', body: 'See whether TOTP / encryption are enabled (no secrets exposed).' },
'auth:observe': { title: 'Observe login events', body: 'See when you log in, log out, or switch accounts.' },
'http:post': { title: 'Call same-origin APIs', body: 'Make authenticated requests to the webmail backend on your behalf.' },
'http:fetch': { title: 'Talk to external services', body: 'Make uncredentialled requests to the third-party origins listed in the manifest.' },
'admin:config': { title: 'Read/write admin config', body: 'Access this plugin\'s admin-supplied configuration values.' },
};
export function describePermission(perm: string): { title: string; body: string } {
return PERMISSION_LABELS[perm] ?? { title: perm, body: 'No description available.' };
}
+312
View File
@@ -0,0 +1,312 @@
// Host-side implementations of the sandboxed plugin API. Every method gates
// on `plugin.permissions` BEFORE doing the underlying work, and only returns
// structured-cloneable data back to the iframe.
import type { InstalledPlugin, Permission } from '../plugin-types';
import { IMPLICIT_PERMISSIONS } from '../plugin-types';
import { toast as appToast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
import { apiFetch } from '../browser-navigation';
import { awaitDialog } from './host-dialog';
const PERM_PER_METHOD: Record<string, Permission | null> = {
// storage is unscoped by the manifest - implicit.
'storage.get': null,
'storage.set': null,
'storage.remove': null,
'storage.keys': null,
// toast / log don't need a permission (anyone can show a toast).
'toast.success': null,
'toast.error': null,
'toast.info': null,
'toast.warning': null,
// http
'http.post': 'http:post',
'http.fetch': 'http:fetch',
// admin
'admin.getConfig': 'admin:config',
'admin.getAllConfig': 'admin:config',
'admin.setConfig': 'admin:config',
'admin.deleteConfig': 'admin:config',
// ui — any plugin can ask the host to render a modal or open a URL.
'ui.confirm': null,
'ui.alert': null,
'ui.openExternalUrl': null,
};
function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean {
if ((IMPLICIT_PERMISSIONS as readonly string[]).includes(perm)) return true;
if (!plugin.permissions.includes(perm)) return false;
// Defense-in-depth: even if the manifest declares a permission, the host
// refuses the API call unless an admin has marked the plugin as managed,
// or the user has explicitly granted it via the consent dialog.
if (plugin.managed) return true;
return (plugin.grantedPermissions ?? []).includes(perm);
}
// ─── Cross-origin allow-list (mirrors lib/plugin-api.ts) ──────
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();
const host = url.hostname.toLowerCase();
if (host.endsWith(suffix)) {
const prefix = host.slice(0, host.length - suffix.length);
if (prefix.length > 0 && !prefix.includes('.')) return true;
}
} else if (url.hostname.toLowerCase() === parsed.hostname.toLowerCase()) {
return true;
}
}
return false;
}
// ─── Per-plugin storage namespace ─────────────────────────────
const STORAGE_PREFIX = (pluginId: string) => `plugin:${pluginId}:`;
function storageGet(pluginId: string, key: string): unknown {
if (typeof window === 'undefined') return null;
const raw = window.localStorage.getItem(STORAGE_PREFIX(pluginId) + key);
if (raw === null) return null;
try { return JSON.parse(raw); } catch { return null; }
}
function storageSet(pluginId: string, key: string, value: unknown): void {
if (typeof window === 'undefined') return;
window.localStorage.setItem(STORAGE_PREFIX(pluginId) + key, JSON.stringify(value));
}
function storageRemove(pluginId: string, key: string): void {
if (typeof window === 'undefined') return;
window.localStorage.removeItem(STORAGE_PREFIX(pluginId) + key);
}
function storageKeys(pluginId: string): string[] {
if (typeof window === 'undefined') return [];
const prefix = STORAGE_PREFIX(pluginId);
const out: string[] = [];
for (let i = 0; i < window.localStorage.length; i++) {
const k = window.localStorage.key(i);
if (k?.startsWith(prefix)) out.push(k.slice(prefix.length));
}
return out;
}
// ─── http.post (same-origin /api/*) ───────────────────────────
/**
* Returns true iff `path` is permitted by the plugin's `apiPostPaths`
* allowlist. Entries are either exact paths (must equal `path`) or prefixes
* that end with `/` (`path` must start with the entry).
*/
function isApiPostPathAllowed(path: string, allowlist: readonly string[]): boolean {
for (const entry of allowlist) {
if (typeof entry !== 'string' || !entry.startsWith('/api/')) continue;
if (entry.endsWith('/')) {
if (path === entry || path.startsWith(entry)) return true;
} else if (path === entry) {
return true;
}
}
return false;
}
async function doHttpPost(plugin: InstalledPlugin, path: string, body: unknown): Promise<{ ok: boolean; status: number; data: unknown }> {
if (typeof path !== 'string' || !path.startsWith('/api/')) {
throw new Error('path must start with /api/');
}
const url = new URL(path, window.location.origin);
if (url.origin !== window.location.origin) {
throw new Error('path must resolve to the same origin');
}
// Per-plugin path allow-list. Comparison is on the pathname only (query
// strings don't widen the surface, so we ignore them here).
const allow = plugin.apiPostPaths ?? [];
if (allow.length === 0) {
throw new Error(`Plugin "${plugin.id}" has no apiPostPaths declared`);
}
if (!isApiPostPathAllowed(url.pathname, allow)) {
throw new Error(`Path ${url.pathname} not in plugin apiPostPaths allowlist`);
}
const { client } = useAuthStore.getState();
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (client) {
headers['Authorization'] = client.getAuthHeader();
headers['X-JMAP-Username'] = client.getUsername();
}
const res = await fetch(url.pathname + url.search, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
const data = await res.json().catch(() => null);
return { ok: res.ok, status: res.status, data };
}
// ─── http.fetch (cross-origin, manifest-allowlisted) ──────────
interface PluginFetchInit {
method?: string;
headers?: Record<string, string>;
body?: string | ArrayBuffer | ArrayBufferView | null;
}
async function doHttpFetch(plugin: InstalledPlugin, rawUrl: string, init?: PluginFetchInit) {
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 absolute https://'); }
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`);
}
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,
body: (init?.body ?? undefined) as BodyInit | undefined,
credentials: 'omit',
mode: 'cors',
redirect: 'follow',
});
// Sandboxed plugin can't hold a Response object across the boundary, so
// we read the body once and return it as text + arrayBuffer (base64).
const headers: Record<string, string> = {};
res.headers.forEach((val, key) => { headers[key.toLowerCase()] = val; });
const buf = await res.arrayBuffer();
let text: string | null = null;
try { text = new TextDecoder('utf-8', { fatal: false }).decode(buf); } catch { text = null; }
return {
ok: res.ok,
status: res.status,
statusText: res.statusText,
headers,
bodyText: text,
bodyBytes: new Uint8Array(buf),
};
}
// ─── admin config (same as before) ────────────────────────────
async function adminGetAll(pluginId: string): Promise<Record<string, unknown>> {
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`);
if (!res.ok) return {};
return res.json();
}
async function adminGet(pluginId: string, key: string): Promise<unknown> {
const all = await adminGetAll(pluginId);
return all[key] ?? null;
}
async function adminSet(pluginId: string, key: string, value: unknown): Promise<void> {
await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
});
}
async function adminDelete(pluginId: string, key: string): Promise<void> {
await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
}
// ─── Dispatcher ──────────────────────────────────────────────
/** Resolves an api-request method against the per-plugin permissions. */
export async function dispatchApiCall(
plugin: InstalledPlugin,
method: string,
args: unknown[],
): Promise<unknown> {
// Permission gate
const requiredPerm = PERM_PER_METHOD[method];
if (requiredPerm !== undefined && requiredPerm !== null) {
if (!hasPermission(plugin, requiredPerm)) {
throw new Error(`Plugin "${plugin.id}" lacks permission "${requiredPerm}"`);
}
} else if (!(method in PERM_PER_METHOD)) {
throw new Error(`Unknown API method "${method}"`);
}
switch (method) {
case 'storage.get': return storageGet(plugin.id, args[0] as string);
case 'storage.set': storageSet(plugin.id, args[0] as string, args[1]); return undefined;
case 'storage.remove': storageRemove(plugin.id, args[0] as string); return undefined;
case 'storage.keys': return storageKeys(plugin.id);
case 'toast.success': appToast.success(String(args[0] ?? '')); return undefined;
case 'toast.error': appToast.error(String(args[0] ?? '')); return undefined;
case 'toast.info': appToast.info(String(args[0] ?? '')); return undefined;
case 'toast.warning': appToast.warning(String(args[0] ?? '')); return undefined;
case 'http.post': return doHttpPost(plugin, args[0] as string, args[1]);
case 'http.fetch': return doHttpFetch(plugin, args[0] as string, args[1] as PluginFetchInit | undefined);
case 'admin.getConfig': return adminGet(plugin.id, args[0] as string);
case 'admin.getAllConfig': return adminGetAll(plugin.id);
case 'admin.setConfig': await adminSet(plugin.id, args[0] as string, args[1]); return undefined;
case 'admin.deleteConfig': await adminDelete(plugin.id, args[0] as string); return undefined;
case 'ui.confirm': {
const opts = (args[0] ?? {}) as { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean };
return awaitDialog({
pluginId: plugin.id,
kind: 'confirm',
title: String(opts.title ?? plugin.name ?? 'Confirm'),
message: String(opts.message ?? ''),
confirmLabel: typeof opts.confirmLabel === 'string' ? opts.confirmLabel : undefined,
cancelLabel: typeof opts.cancelLabel === 'string' ? opts.cancelLabel : undefined,
danger: !!opts.danger,
});
}
case 'ui.alert': {
const opts = (args[0] ?? {}) as { title?: string; message?: string; confirmLabel?: string };
await awaitDialog({
pluginId: plugin.id,
kind: 'alert',
title: String(opts.title ?? plugin.name ?? 'Notice'),
message: String(opts.message ?? ''),
confirmLabel: typeof opts.confirmLabel === 'string' ? opts.confirmLabel : undefined,
});
return undefined;
}
case 'ui.openExternalUrl': {
const url = String(args[0] ?? '');
// Only http(s) — the sandbox should not be able to navigate the host
// anywhere internal, nor open javascript:/data:/file: schemes.
let parsed: URL;
try { parsed = new URL(url); } catch { throw new Error('ui.openExternalUrl: invalid URL'); }
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`ui.openExternalUrl: ${parsed.protocol} not allowed`);
}
// Always open in a new tab; plugins must not be able to navigate the
// host window (_self/_top/_parent) to an attacker-controlled origin.
window.open(parsed.toString(), '_blank', 'noopener,noreferrer');
return undefined;
}
default:
throw new Error(`Unhandled method "${method}"`);
}
}
// ─── Cleanup hook for unloading plugins ───────────────────────
export { cancelForPlugin as cancelPluginDialogs } from './host-dialog';
+335
View File
@@ -0,0 +1,335 @@
// Host-side wrapper around a single sandbox iframe (one per plugin/background,
// plus one per slot mount). Owns the iframe lifecycle and the postMessage RPC.
//
// Origin model: the iframe is `sandbox="allow-scripts"` with no
// `allow-same-origin`, so its origin is opaque ("null"). We can't pin on
// `event.origin`; instead, every inbound message is gated on
// `event.source === iframe.contentWindow`. The iframe's runtime pins the
// parent on the first inbound message.
import type { InstalledPlugin, SlotName } from '../plugin-types';
import { dispatchApiCall } from './host-api';
import { SANDBOX_PATH } from './protocol';
import type {
SandboxToHost, HostToSandbox, InitMsg, InitPayload,
} from './protocol';
// ─── Callback marshalling ────────────────────────────────────
/**
* Walks an object graph and replaces any function values with
* `{ __pluginCallback: id }` markers, registering each function in `table` so
* the iframe can call back later via 'callback-invoke'. Non-plain values
* (functions on prototype, DOM nodes, etc.) are dropped.
*/
function encodeCallbacks(
value: unknown,
table: Map<string, (...args: unknown[]) => unknown>,
depth = 0,
): unknown {
if (depth > 6) return null; // hard cap to avoid pathological graphs
if (value === null || value === undefined) return value;
const t = typeof value;
if (t === 'function') {
const id = Math.random().toString(36).slice(2) + Date.now().toString(36);
table.set(id, value as (...args: unknown[]) => unknown);
return { __pluginCallback: id };
}
if (t !== 'object') return value;
if (Array.isArray(value)) {
return value.map((v) => encodeCallbacks(v, table, depth + 1));
}
// Plain object — copy own enumerable keys.
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = encodeCallbacks(v, table, depth + 1);
}
return out;
}
// ─── Public option types ─────────────────────────────────────
export interface BackgroundOptions {
plugin: InstalledPlugin;
code: string;
locale: string;
/** Where the hidden iframe should attach. Defaults to document.body. */
hostContainer?: HTMLElement;
}
export interface SlotOptions {
plugin: InstalledPlugin;
slot: SlotName;
code: string;
locale: string;
extraProps: Record<string, unknown>;
/** Container element the visible slot iframe is mounted into. */
hostContainer: HTMLElement;
/** Called whenever the sandbox reports a new content height. */
onResize: (height: number) => void;
}
export interface InitDoneInfo {
hooks: string[];
slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number }>;
shortcuts: Array<{ id: string; keys: string; label: string; category?: string }>;
}
// ─── Sandbox instance ────────────────────────────────────────
export class SandboxInstance {
readonly iframe: HTMLIFrameElement;
readonly pluginId: string;
readonly mode: 'background' | 'slot';
readyPromise: Promise<void>;
initPromise: Promise<InitDoneInfo>;
private resolveReady!: () => void;
private resolveInit!: (info: InitDoneInfo) => void;
private rejectInit!: (err: Error) => void;
private listener: (ev: MessageEvent) => void;
private destroyed = false;
private pendingHookInvokes = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
private pendingShouldShow = new Map<string, (show: boolean) => void>();
/** Host-side function references the sandbox can call back via 'callback-invoke'. */
private callbackTable = new Map<string, (...args: unknown[]) => unknown>();
constructor(
private plugin: InstalledPlugin,
initPayload: InitPayload,
hostContainer: HTMLElement,
private slotResizeCb: ((height: number) => void) | null,
) {
this.pluginId = plugin.id;
this.mode = initPayload.mode;
// Slot iframes get `extraProps`; encode any function values now so the
// structured-clone send doesn't drop them.
if (initPayload.mode === 'slot') {
initPayload.extraProps = encodeCallbacks(initPayload.extraProps, this.callbackTable) as Record<string, unknown>;
}
this.readyPromise = new Promise<void>((res) => { this.resolveReady = res; });
this.initPromise = new Promise<InitDoneInfo>((res, rej) => {
this.resolveInit = res;
this.rejectInit = rej;
});
this.iframe = document.createElement('iframe');
this.iframe.setAttribute('sandbox', 'allow-scripts');
this.iframe.setAttribute('referrerpolicy', 'no-referrer');
this.iframe.title = `plugin-${plugin.id}-${initPayload.mode}`;
this.iframe.style.border = 'none';
this.iframe.style.display = 'block';
if (initPayload.mode === 'background') {
this.iframe.style.position = 'absolute';
this.iframe.style.width = '1px';
this.iframe.style.height = '1px';
this.iframe.style.opacity = '0';
this.iframe.style.pointerEvents = 'none';
this.iframe.style.left = '-9999px';
this.iframe.setAttribute('aria-hidden', 'true');
} else {
this.iframe.style.width = '100%';
this.iframe.style.height = '0px';
}
this.iframe.src = SANDBOX_PATH;
this.listener = (ev) => this.onMessage(ev);
window.addEventListener('message', this.listener);
hostContainer.appendChild(this.iframe);
// Send init after the iframe runtime signals it's ready.
this.readyPromise.then(() => {
if (this.destroyed) return;
const msg: InitMsg = { type: 'init', payload: initPayload };
this.send(msg);
});
}
// ─── Internal ───────────────────────────────────────────────
private send(msg: HostToSandbox): void {
// targetOrigin '*' is required because the iframe is opaque-origin. The
// payload contains no host secrets — bundle code and manifest fields the
// plugin already owns.
this.iframe.contentWindow?.postMessage(msg, '*');
}
private onMessage(ev: MessageEvent): void {
if (this.destroyed) return;
if (ev.source !== this.iframe.contentWindow) return;
const msg = ev.data as SandboxToHost;
if (!msg || typeof (msg as { type?: unknown }).type !== 'string') return;
switch (msg.type) {
case 'sandbox-ready':
this.resolveReady();
return;
case 'init-done':
this.resolveInit({ hooks: msg.hooks, slots: msg.slots, shortcuts: msg.shortcuts ?? [] });
return;
case 'init-error':
this.rejectInit(new Error(msg.error));
return;
case 'api-request': {
const { id, method, args } = msg;
void (async () => {
try {
const result = await dispatchApiCall(this.plugin, method, args ?? []);
this.send({ type: 'api-response', id, ok: true, result });
} catch (err) {
this.send({ type: 'api-response', id, ok: false, error: (err as Error).message ?? String(err) });
}
})();
return;
}
case 'callback-invoke': {
const { id, callbackId, args } = msg;
const fn = this.callbackTable.get(callbackId);
if (!fn) {
this.send({ type: 'callback-response', id, ok: false, error: `unknown callback ${callbackId}` });
return;
}
void (async () => {
try {
const result = await Promise.resolve(fn(...(args ?? [])));
// Only send back primitives / plain objects; functions inside
// results would round-trip but we don't support that yet.
this.send({ type: 'callback-response', id, ok: true, result });
} catch (err) {
this.send({ type: 'callback-response', id, ok: false, error: (err as Error).message ?? String(err) });
}
})();
return;
}
case 'hook-result': {
const entry = this.pendingHookInvokes.get(msg.id);
if (!entry) return;
this.pendingHookInvokes.delete(msg.id);
if (msg.ok) entry.resolve(msg.result);
else entry.reject(new Error(msg.error ?? 'hook error'));
return;
}
case 'slot-should-show-result': {
const cb = this.pendingShouldShow.get(msg.id);
if (!cb) return;
this.pendingShouldShow.delete(msg.id);
cb(msg.show);
return;
}
case 'slot-resize':
this.slotResizeCb?.(msg.height);
return;
}
}
// ─── Public ─────────────────────────────────────────────────
/** Dispatch a hook handler inside the sandbox; resolves with its return value. */
invokeHook(hookName: string, args: unknown[]): Promise<unknown> {
if (this.destroyed) return Promise.reject(new Error('sandbox destroyed'));
const id = uid();
const p = new Promise<unknown>((resolve, reject) => {
this.pendingHookInvokes.set(id, { resolve, reject });
});
this.send({ type: 'hook-invoke', id, hookName, args });
return p;
}
/** Ask the background instance whether a slot should mount for this context. */
evaluateShouldShow(slot: SlotName, context: unknown): Promise<boolean> {
if (this.destroyed) return Promise.resolve(false);
const id = uid();
const p = new Promise<boolean>((resolve) => {
this.pendingShouldShow.set(id, resolve);
});
this.send({ type: 'slot-should-show', id, slot, context });
return p;
}
setLocale(locale: string): void {
if (this.destroyed) return;
this.send({ type: 'locale-change', locale });
}
updateProps(props: Record<string, unknown>): void {
if (this.destroyed) return;
// Stale references would leak if we kept growing the table without
// bound; for now we let it grow until destroy(). A future refinement
// could diff old vs new props and drop entries no longer referenced.
const encoded = encodeCallbacks(props, this.callbackTable) as Record<string, unknown>;
this.send({ type: 'props-update', props: encoded });
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
window.removeEventListener('message', this.listener);
this.iframe.remove();
for (const { reject } of this.pendingHookInvokes.values()) {
reject(new Error('sandbox destroyed'));
}
this.pendingHookInvokes.clear();
this.pendingShouldShow.clear();
this.callbackTable.clear();
}
}
function uid(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
// ─── Factory helpers ─────────────────────────────────────────
export function createBackgroundInstance(opts: BackgroundOptions): SandboxInstance {
const payload: InitPayload = {
mode: 'background',
pluginId: opts.plugin.id,
manifest: {
id: opts.plugin.id,
version: opts.plugin.version,
permissions: opts.plugin.permissions,
settings: { ...opts.plugin.settings },
locales: opts.plugin.locales,
httpOrigins: opts.plugin.httpOrigins,
},
code: opts.code,
locale: opts.locale,
};
return new SandboxInstance(
opts.plugin,
payload,
opts.hostContainer ?? document.body,
null,
);
}
export function createSlotInstance(opts: SlotOptions): SandboxInstance {
const payload: InitPayload = {
mode: 'slot',
pluginId: opts.plugin.id,
slot: opts.slot,
code: opts.code,
manifest: {
id: opts.plugin.id,
version: opts.plugin.version,
permissions: opts.plugin.permissions,
settings: { ...opts.plugin.settings },
locales: opts.plugin.locales,
httpOrigins: opts.plugin.httpOrigins,
},
extraProps: opts.extraProps,
locale: opts.locale,
};
return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize);
}
+80
View File
@@ -0,0 +1,80 @@
// Process-wide queue for plugin-requested host dialogs (confirm / alert).
// The sandboxed plugin posts a `ui.confirm` API request; the host enqueues a
// dialog here and resolves the awaited Promise after the user clicks. The
// `PluginDialogHost` component subscribes and renders one dialog at a time.
export type DialogKind = 'confirm' | 'alert';
export interface DialogRequest {
id: string;
pluginId: string;
kind: DialogKind;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
/** When true, confirm button uses destructive styling. */
danger?: boolean;
/** Called when the dialog closes. `ok` is true only for confirm-accept. */
resolve: (ok: boolean) => void;
}
const queue: DialogRequest[] = [];
const listeners = new Set<() => void>();
function notify(): void {
for (const l of listeners) {
try { l(); } catch { /* ignore */ }
}
}
function uid(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
export function enqueueDialog(req: Omit<DialogRequest, 'id'>): { id: string } {
const entry: DialogRequest = { ...req, id: uid() };
queue.push(entry);
notify();
return { id: entry.id };
}
export function head(): DialogRequest | null {
return queue[0] ?? null;
}
export function resolveHead(ok: boolean): void {
const entry = queue.shift();
if (!entry) return;
try { entry.resolve(ok); } catch { /* ignore */ }
notify();
}
/** Cancel every pending dialog for a plugin (called on unload). */
export function cancelForPlugin(pluginId: string): void {
let changed = false;
for (let i = queue.length - 1; i >= 0; i--) {
if (queue[i].pluginId === pluginId) {
const entry = queue[i];
queue.splice(i, 1);
try { entry.resolve(false); } catch { /* ignore */ }
changed = true;
}
}
if (changed) notify();
}
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
/**
* Internal helper used by host-api to convert an `enqueueDialog` call into a
* Promise the plugin-side `await` can land on.
*/
export function awaitDialog(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise<boolean> {
return new Promise<boolean>((resolve) => {
enqueueDialog({ ...req, resolve });
});
}
+167
View File
@@ -0,0 +1,167 @@
// Iframe-based plugin loader. Replaces the blob-URL `import()` flow in
// `lib/plugin-loader.ts` with a postMessage-isolated sandbox.
import type { Disposable, InstalledPlugin } from '../plugin-types';
import { pluginStorage } from '../plugin-storage';
import {
emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
authHooks, settingsHooks, identityHooks, filterHooks,
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
sidebarAppHooks, avatarHooks, renderHooks, routerHooks,
removeAllPluginHooks, pluginErrorTracker,
} from '../plugin-hooks';
import { verifyBundle } from './bundle-integrity';
import { createBackgroundInstance } from './host-bridge';
import { register as registerActive, deregister as deregisterActive } from './registry';
import { cancelPluginDialogs } from './host-api';
import { registerShortcuts } from './shortcuts';
// ─── Hook-bus lookup (one flat map for name → bus) ────────────
type AnyBus = { register: (pluginId: string, handler: (...args: unknown[]) => unknown, order?: number) => Disposable };
const HOOK_BUSES: Record<string, AnyBus> = Object.assign({},
emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
authHooks, settingsHooks, identityHooks, filterHooks,
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
sidebarAppHooks, avatarHooks, renderHooks, routerHooks,
) as Record<string, AnyBus>;
// ─── Store accessor (status updates flow through the existing store) ──
type StoreAccessor = { setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void };
let storeAccessor: StoreAccessor | null = null;
export function setSandboxStoreAccessor(a: StoreAccessor): void { storeAccessor = a; }
// ─── Locale (kept in step with the app locale) ────────────────
let currentLocale = 'en';
export function setSandboxLocale(locale: string): void {
currentLocale = locale;
// Push to all active background instances.
// Slot iframes inherit locale at spawn time; they're short-lived.
// (We don't import the registry here to avoid a circular import; the
// PluginIframeSlot subscribes to locale changes on its own.)
}
// ─── Bundle fetch ─────────────────────────────────────────────
async function getBundleCode(plugin: InstalledPlugin): Promise<string> {
// Dev plugins are written into IndexedDB by the same install flow; the
// bundle endpoint is the source of truth for managed plugins. For Phase 1
// we read from IndexedDB to match the existing flow; the store-side install
// path already populates this from /api/admin/plugins/[id]/bundle.
const code = await pluginStorage.getCode(plugin.id);
if (!code) {
throw new Error(`No bundle in storage for plugin "${plugin.id}". Reinstall to populate.`);
}
await verifyBundle(code, plugin.bundleHash);
return code;
}
// ─── Load ─────────────────────────────────────────────────────
export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
if (typeof window === 'undefined') return;
try {
const code = await getBundleCode(plugin);
const background = createBackgroundInstance({
plugin,
code,
locale: currentLocale,
});
// Wait for the background runtime to evaluate the bundle, register hooks,
// and enumerate slots.
const info = await background.initPromise;
// Wire hook proxies: every hookName the plugin registered gets a HookBus
// entry whose handler dispatches into the sandbox. `shortcut:<id>` hooks
// are dispatched by the keyboard module separately and don't have a bus.
const hookDisposables: Disposable[] = [];
for (const hookName of info.hooks) {
if (hookName.startsWith('shortcut:')) continue;
const bus = HOOK_BUSES[hookName];
if (!bus) {
console.warn(`[plugin-sandbox] Plugin "${plugin.id}" registered unknown hook "${hookName}"`);
continue;
}
const proxy = async (...args: unknown[]) => {
try {
return await background.invokeHook(hookName, args);
} catch (err) {
pluginErrorTracker.record(plugin.id, err);
throw err;
}
};
hookDisposables.push(bus.register(plugin.id, proxy as (...a: unknown[]) => unknown));
}
// Install plugin-declared keyboard shortcuts.
const shortcutDispose = registerShortcuts(background, info.shortcuts ?? []);
hookDisposables.push({ dispose: shortcutDispose });
registerActive({
plugin,
code,
background,
slotOffers: info.slots,
hookDisposables,
});
storeAccessor?.setPluginStatus(plugin.id, 'running');
console.info(`[plugin-sandbox] "${plugin.id}" activated (hooks=${info.hooks.length}, slots=${info.slots.length})`);
} catch (err) {
const msg = (err as Error).message ?? String(err);
storeAccessor?.setPluginStatus(plugin.id, 'error', msg);
console.error(`[plugin-sandbox] Failed to load "${plugin.id}":`, err);
}
}
// ─── Unload ───────────────────────────────────────────────────
export function unloadSandboxedPlugin(pluginId: string): void {
const entry = deregisterActive(pluginId);
if (!entry) return;
for (const d of entry.hookDisposables) {
try { d.dispose(); } catch { /* ignore */ }
}
removeAllPluginHooks(pluginId);
try { entry.background.destroy(); } catch { /* ignore */ }
cancelPluginDialogs(pluginId);
pluginErrorTracker.reset(pluginId);
storeAccessor?.setPluginStatus(pluginId, 'disabled');
console.info(`[plugin-sandbox] "${pluginId}" deactivated`);
}
// ─── Bulk ─────────────────────────────────────────────────────
export async function activateAllSandboxed(plugins: InstalledPlugin[]): Promise<void> {
const enabled = plugins.filter(p => p.enabled && p.status !== 'error');
for (const p of enabled) await loadSandboxedPlugin(p);
}
export function deactivateAllSandboxed(): void {
// import lazily to avoid a circular dep when registry mutates while we iterate.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { all } = require('./registry') as typeof import('./registry');
for (const e of all()) unloadSandboxedPlugin(e.plugin.id);
}
// ─── Auto-disable ─────────────────────────────────────────────
export function setupSandboxAutoDisable(): void {
pluginErrorTracker.setAutoDisableCallback((pluginId) => {
unloadSandboxedPlugin(pluginId);
storeAccessor?.setPluginStatus(pluginId, 'error', 'Auto-disabled due to repeated errors');
});
}
// ─── Re-export for compat with the existing loader name ───────
export { SandboxInstance } from './host-bridge';
+218
View File
@@ -0,0 +1,218 @@
// Shared message-protocol types for host ↔ sandbox postMessage RPC.
//
// The sandbox iframe is null-origin (`sandbox="allow-scripts"`), so postMessage
// events arrive with `event.origin === "null"`. The host pins messages by the
// iframe's `contentWindow` reference instead. All values crossing the boundary
// must be structured-cloneable: no functions, no DOM nodes, no class instances.
import type { SlotName } from '../plugin-types';
// ─── Sandbox mode ────────────────────────────────────────────
export type SandboxMode = 'background' | 'slot';
/** Initialisation payload for a background-instance iframe (one per plugin). */
export interface BackgroundInit {
mode: 'background';
pluginId: string;
/** Trimmed manifest visible to the plugin. No host secrets. */
manifest: {
id: string;
version: string;
permissions: string[];
settings: Record<string, unknown>;
locales?: Record<string, Record<string, string>>;
httpOrigins?: string[];
};
/** UTF-8 plugin bundle source (CommonJS). */
code: string;
/** Initial app locale; host pushes updates via 'locale-change'. */
locale: string;
}
/** Initialisation payload for a slot-instance iframe (one per slot mount). */
export interface SlotInit {
mode: 'slot';
pluginId: string;
/** Slot name the iframe should render a component for. */
slot: SlotName;
/** Same bundle code as the background instance. */
code: string;
/**
* Trimmed manifest (mirrors `BackgroundInit.manifest`). Slot iframes get the
* same fields so `api.plugin.settings` and `httpOrigins` work identically
* to the background context.
*/
manifest: {
id: string;
version: string;
permissions: string[];
settings: Record<string, unknown>;
locales?: Record<string, Record<string, string>>;
httpOrigins?: string[];
};
/**
* Initial props the host passes through from `PluginSlot` `extraProps`.
* Function values are pre-encoded by the host as
* `{ __pluginCallback: '<id>' }` markers and rehydrated to stub functions
* by the runtime; the stubs round-trip to the host via 'callback-invoke'.
*/
extraProps: Record<string, unknown>;
locale: string;
}
export type InitPayload = BackgroundInit | SlotInit;
// ─── Sandbox → Host messages ─────────────────────────────────
export interface ReadyMsg { type: 'sandbox-ready'; }
export interface InitDoneMsg {
type: 'init-done';
/** Hook names the plugin registered. The host installs proxy handlers. */
hooks: string[];
/** Slots the plugin claims. Used by the host to know when a slot is offered. */
slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number }>;
/**
* Keyboard shortcuts the plugin declares. The host installs a global
* keydown listener that dispatches to the `shortcut:<id>` hook when the
* combo matches. `keys` is a `+`-separated string like "Ctrl+Shift+L".
*/
shortcuts: Array<{ id: string; keys: string; label: string; category?: string }>;
}
export interface InitErrorMsg { type: 'init-error'; error: string; }
export interface ApiRequestMsg {
type: 'api-request';
id: string;
/** Dotted method path, e.g. "http.post", "storage.get", "admin.getConfig". */
method: string;
args: unknown[];
}
/** Sandbox → host: invoke a function the host passed in via `extraProps`. */
export interface CallbackInvokeMsg {
type: 'callback-invoke';
/** Round-trip id so the host can return a value if the caller awaits. */
id: string;
/** The callback marker id (matches `__pluginCallback`). */
callbackId: string;
args: unknown[];
}
/** Host → sandbox: response to a callback-invoke. */
export interface CallbackResponseMsg {
type: 'callback-response';
id: string;
ok: boolean;
result?: unknown;
error?: string;
}
export interface HookResultMsg {
type: 'hook-result';
id: string;
ok: boolean;
result?: unknown;
error?: string;
}
export interface SlotResizeMsg {
type: 'slot-resize';
height: number;
}
export interface SlotShouldShowResultMsg {
type: 'slot-should-show-result';
id: string;
show: boolean;
}
export type SandboxToHost =
| ReadyMsg
| InitDoneMsg
| InitErrorMsg
| ApiRequestMsg
| CallbackInvokeMsg
| HookResultMsg
| SlotResizeMsg
| SlotShouldShowResultMsg;
// ─── Host → Sandbox messages ─────────────────────────────────
export interface InitMsg { type: 'init'; payload: InitPayload; }
export interface ApiResponseMsg {
type: 'api-response';
id: string;
ok: boolean;
result?: unknown;
error?: string;
}
export interface HookInvokeMsg {
type: 'hook-invoke';
id: string;
hookName: string;
args: unknown[];
}
export interface LocaleChangeMsg { type: 'locale-change'; locale: string; }
export interface PropsUpdateMsg { type: 'props-update'; props: Record<string, unknown>; }
export interface SlotShouldShowMsg {
type: 'slot-should-show';
id: string;
slot: SlotName;
context: unknown;
}
export type HostToSandbox =
| InitMsg
| ApiResponseMsg
| CallbackResponseMsg
| HookInvokeMsg
| LocaleChangeMsg
| PropsUpdateMsg
| SlotShouldShowMsg;
/** Marker used in extraProps for function values that the host owns. */
export interface PluginCallbackMarker {
__pluginCallback: string;
}
export function isCallbackMarker(value: unknown): value is PluginCallbackMarker {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as { __pluginCallback?: unknown }).__pluginCallback === 'string'
);
}
// ─── Type guards ─────────────────────────────────────────────
export function isSandboxMessage(value: unknown): value is SandboxToHost {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as { type?: unknown }).type === 'string'
);
}
// ─── Constants ───────────────────────────────────────────────
/** Path used for the sandbox iframe `src`. Matched in `proxy.ts` for CSP. */
export const SANDBOX_PATH = '/plugin-sandbox';
/** Methods callable by a plugin via api-request. Host enforces permissions. */
export const API_METHODS = [
'storage.get', 'storage.set', 'storage.remove', 'storage.keys',
'http.post', 'http.fetch',
'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig',
'toast.success', 'toast.error', 'toast.info', 'toast.warning',
'ui.confirm', 'ui.alert', 'ui.openExternalUrl',
] as const;
export type ApiMethod = (typeof API_METHODS)[number];
+93
View File
@@ -0,0 +1,93 @@
// Process-wide registry of active sandboxed plugins. The loader populates it
// after a successful boot; PluginIframeSlot reads it to spawn slot iframes
// and to call evaluateShouldShow on the background instance.
//
// `offersForSlot` results are memoised per slot name so that
// `useSyncExternalStore` sees a stable reference between unrelated renders.
// The cache is invalidated whenever the set of active plugins changes.
import type { Disposable, InstalledPlugin, SlotName } from '../plugin-types';
import type { SandboxInstance } from './host-bridge';
export interface SlotOffer {
name: SlotName;
order: number;
hasShouldShow: boolean;
}
export interface ActivePlugin {
plugin: InstalledPlugin;
/** Verified bundle source. Reused when spinning up slot iframes. */
code: string;
background: SandboxInstance;
slotOffers: SlotOffer[];
hookDisposables: Disposable[];
}
export interface ResolvedSlotOffer {
pluginId: string;
order: number;
hasShouldShow: boolean;
}
const active = new Map<string, ActivePlugin>();
const listeners = new Set<() => void>();
// Per-slot snapshot cache. Cleared on any registry mutation.
const offersCache = new Map<SlotName, readonly ResolvedSlotOffer[]>();
const EMPTY: readonly ResolvedSlotOffer[] = Object.freeze([]);
function invalidate(): void {
offersCache.clear();
for (const l of listeners) {
try { l(); } catch { /* ignore */ }
}
}
export function register(entry: ActivePlugin): void {
active.set(entry.plugin.id, entry);
invalidate();
}
export function deregister(pluginId: string): ActivePlugin | undefined {
const e = active.get(pluginId);
if (!e) return undefined;
active.delete(pluginId);
invalidate();
return e;
}
export function get(pluginId: string): ActivePlugin | undefined {
return active.get(pluginId);
}
export function all(): ActivePlugin[] {
return [...active.values()];
}
/**
* Returns the cached, frozen list of plugins offering this slot, sorted by
* `order`. The returned array is referentially stable until the active-plugin
* set changes, so it is safe to pass to `useSyncExternalStore`.
*/
export function offersForSlot(slot: SlotName): readonly ResolvedSlotOffer[] {
const cached = offersCache.get(slot);
if (cached) return cached;
const out: ResolvedSlotOffer[] = [];
for (const entry of active.values()) {
for (const offer of entry.slotOffers) {
if (offer.name === slot) {
out.push({ pluginId: entry.plugin.id, order: offer.order, hasShouldShow: offer.hasShouldShow });
}
}
}
out.sort((a, b) => a.order - b.order);
const snapshot = out.length === 0 ? EMPTY : Object.freeze(out);
offersCache.set(slot, snapshot);
return snapshot;
}
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
+457
View File
@@ -0,0 +1,457 @@
'use client';
// Runtime that boots inside the null-origin plugin sandbox iframe.
//
// Lifecycle:
// 1. Iframe loads → posts 'sandbox-ready' to parent (targetOrigin '*' is OK;
// the message carries no secrets, and the parent's first inbound message
// gives us the origin to pin for everything that follows).
// 2. Parent posts 'init' with the bundle code + manifest + mode/slot.
// 3. We evaluate the bundle in a `new Function` scope with React/ReactDOM
// injected as globals; the bundle is CommonJS-style (`module.exports = {
// slots, hooks, activate }`). ES-module syntax inside the bundle is a
// build-time concern handled by the plugin's bundler.
// 4. In background mode: register hook handlers and call `activate(api)`.
// The host installs HookBus stubs and dispatches via 'hook-invoke'.
// 5. In slot mode: look up `slots[slot].component`, render it into the
// iframe body, push height back via ResizeObserver.
import { useEffect, useRef } from 'react';
import * as React from 'react';
import * as ReactDOM from 'react-dom/client';
import * as ReactJSXRuntime from 'react/jsx-runtime';
import type {
HostToSandbox,
SandboxToHost,
InitPayload,
BackgroundInit,
SlotInit,
} from './protocol';
import type { SlotName } from '../plugin-types';
// ─── Module-scope state ──────────────────────────────────────
interface PluginExports {
slots?: Record<string, { component: React.ComponentType<Record<string, unknown>>; shouldShow?: (ctx: unknown) => boolean; order?: number }>;
hooks?: Record<string, (...args: unknown[]) => unknown>;
/**
* Keyboard shortcut bindings. Each entry's `handler` is registered as a
* hook named `shortcut:<id>` so the host's keydown dispatcher can fire it.
*/
shortcuts?: Record<string, {
keys: string;
label: string;
category?: string;
handler: () => void | Promise<void>;
}>;
activate?: (api: unknown) => void | Promise<void> | { dispose: () => void };
default?: unknown;
}
let parentWindow: Window | null = null;
let parentOrigin: string | null = null;
let pluginExports: PluginExports | null = null;
let mode: 'background' | 'slot' | null = null;
let slotName: SlotName | null = null;
let bootDone = false;
const pendingApi = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
const pendingCallbacks = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
const hookHandlers: Record<string, (...args: unknown[]) => unknown> = {};
function sendToHost(msg: SandboxToHost): void {
if (!parentWindow || !parentOrigin) return;
parentWindow.postMessage(msg, parentOrigin);
}
function uid(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
// ─── Sandboxed API facade (calls flow to host via postMessage) ─
function callApi(method: string, args: unknown[]): Promise<unknown> {
const id = uid();
return new Promise((resolve, reject) => {
pendingApi.set(id, { resolve, reject });
sendToHost({ type: 'api-request', id, method, args });
// Reject after 30s to prevent unbounded promise leaks if the host hangs.
setTimeout(() => {
const entry = pendingApi.get(id);
if (!entry) return;
pendingApi.delete(id);
entry.reject(new Error(`API call ${method} timed out after 30s`));
}, 30_000);
});
}
function invokeHostCallback(callbackId: string, args: unknown[]): Promise<unknown> {
const id = uid();
return new Promise((resolve, reject) => {
pendingCallbacks.set(id, { resolve, reject });
sendToHost({ type: 'callback-invoke', id, callbackId, args });
setTimeout(() => {
const entry = pendingCallbacks.get(id);
if (!entry) return;
pendingCallbacks.delete(id);
entry.reject(new Error('host callback timed out after 30s'));
}, 30_000);
});
}
/**
* Walks an object graph received from the host and rehydrates
* `{ __pluginCallback: id }` markers into stub functions that round-trip via
* the 'callback-invoke' RPC. Mirrors `encodeCallbacks` in host-bridge.ts.
*/
function decodeCallbacks(value: unknown, depth = 0): unknown {
if (depth > 6) return null;
if (value === null || value === undefined) return value;
const t = typeof value;
if (t !== 'object') return value;
if (Array.isArray(value)) return value.map((v) => decodeCallbacks(v, depth + 1));
const obj = value as Record<string, unknown>;
if (typeof obj.__pluginCallback === 'string') {
const cbId = obj.__pluginCallback;
return (...args: unknown[]) => invokeHostCallback(cbId, args);
}
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = decodeCallbacks(v, depth + 1);
}
return out;
}
type PluginManifest = BackgroundInit['manifest'];
function buildPluginApi(manifest: PluginManifest) {
return {
plugin: {
id: manifest.id,
version: manifest.version,
settings: { ...manifest.settings },
},
storage: {
get: (key: string) => callApi('storage.get', [key]),
set: (key: string, value: unknown) => callApi('storage.set', [key, value]),
remove: (key: string) => callApi('storage.remove', [key]),
keys: () => callApi('storage.keys', []),
},
http: {
post: (path: string, body: Record<string, unknown>) => callApi('http.post', [path, body]),
fetch: (url: string, init?: unknown) => callApi('http.fetch', [url, init]),
},
toast: {
success: (m: string) => { void callApi('toast.success', [m]); },
error: (m: string) => { void callApi('toast.error', [m]); },
info: (m: string) => { void callApi('toast.info', [m]); },
warning: (m: string) => { void callApi('toast.warning', [m]); },
},
ui: {
/** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise. */
confirm: (opts: { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }) =>
callApi('ui.confirm', [opts]) as Promise<boolean>,
/** Opens a host-rendered alert (one button). Resolves once dismissed. */
alert: (opts: { title?: string; message?: string; confirmLabel?: string }) =>
callApi('ui.alert', [opts]) as Promise<void>,
/** Opens an http/https URL in a new tab via host `window.open`. */
openExternalUrl: (url: string, target?: string) =>
callApi('ui.openExternalUrl', [url, target]) as Promise<void>,
},
admin: {
getConfig: (key: string) => callApi('admin.getConfig', [key]),
getAllConfig: () => callApi('admin.getAllConfig', []),
setConfig: (key: string, v: unknown) => callApi('admin.setConfig', [key, v]),
deleteConfig: (key: string) => callApi('admin.deleteConfig', [key]),
},
log: {
debug: (...a: unknown[]) => console.debug(`[plugin:${manifest.id}]`, ...a),
info: (...a: unknown[]) => console.info(`[plugin:${manifest.id}]`, ...a),
warn: (...a: unknown[]) => console.warn(`[plugin:${manifest.id}]`, ...a),
error: (...a: unknown[]) => console.error(`[plugin:${manifest.id}]`, ...a),
},
};
}
// ─── Bundle evaluation ───────────────────────────────────────
/**
* Resolve a bundler-emitted `require(name)` call inside the sandbox. Plugin
* bundlers should be configured to externalise React; the runtime provides
* those modules here. Anything else is refused — the sandbox has no Node-
* compatible module resolution and we don't want plugins probing globals.
*
* The host injects the per-plugin API as `@plugin-host`, so plugin code can
* `const api = require('@plugin-host')` in both background and slot modes.
*/
function makePluginRequire(api: ReturnType<typeof buildPluginApi> | null): (name: string) => unknown {
const known: Record<string, unknown> = {
'react': React,
'react-dom': ReactDOM,
'react-dom/client': ReactDOM,
'react/jsx-runtime': ReactJSXRuntime,
'react/jsx-dev-runtime': ReactJSXRuntime,
};
if (api) known['@plugin-host'] = api;
return (name: string) => {
if (Object.prototype.hasOwnProperty.call(known, name)) return known[name];
throw new Error(`Plugin sandbox: module "${name}" is not available. Externalise it in your bundler or ship it bundled.`);
};
}
function evaluateBundle(code: string, api: ReturnType<typeof buildPluginApi> | null): PluginExports {
const mod: { exports: PluginExports } = { exports: {} };
const requireShim = makePluginRequire(api);
let fn: (...args: unknown[]) => void;
try {
fn = new Function(
'module', 'exports', 'require', 'React', 'ReactDOM', 'JsxRuntime', 'console',
code,
) as (...args: unknown[]) => void;
} catch (err) {
throw new Error(`Bundle parse error: ${(err as Error).message}`);
}
try {
fn(mod, mod.exports, requireShim, React, ReactDOM, ReactJSXRuntime, console);
} catch (err) {
throw new Error(`Bundle evaluation threw: ${(err as Error).message}`);
}
const exports = (mod.exports?.default ?? mod.exports) as PluginExports;
if (!exports || typeof exports !== 'object') {
throw new Error('Bundle did not produce module.exports object');
}
return exports;
}
// ─── Init flow ───────────────────────────────────────────────
async function bootBackground(payload: BackgroundInit): Promise<void> {
const api = buildPluginApi(payload.manifest);
const exports = evaluateBundle(payload.code, api);
pluginExports = exports;
// Register hooks (each value must be a function).
const hookNames: string[] = [];
const hooks = exports.hooks ?? {};
for (const [name, handler] of Object.entries(hooks)) {
if (typeof handler === 'function') {
hookHandlers[name] = handler;
hookNames.push(name);
}
}
// Enumerate slot offers.
const slotInfo: Array<{ name: SlotName; hasShouldShow: boolean; order: number }> = [];
const slots = exports.slots ?? {};
for (const [name, def] of Object.entries(slots)) {
if (def && typeof def.component === 'function') {
slotInfo.push({
name: name as SlotName,
hasShouldShow: typeof def.shouldShow === 'function',
order: typeof def.order === 'number' ? def.order : 100,
});
}
}
// Shortcuts: register each handler as a 'shortcut:<id>' hook so the host's
// global keydown dispatcher can invoke it.
const shortcutInfo: Array<{ id: string; keys: string; label: string; category?: string }> = [];
const shortcuts = exports.shortcuts ?? {};
for (const [id, def] of Object.entries(shortcuts)) {
if (!def || typeof def.handler !== 'function' || typeof def.keys !== 'string') continue;
hookHandlers[`shortcut:${id}`] = def.handler as (...args: unknown[]) => unknown;
hookNames.push(`shortcut:${id}`);
shortcutInfo.push({
id,
keys: def.keys,
label: typeof def.label === 'string' ? def.label : id,
category: typeof def.category === 'string' ? def.category : undefined,
});
}
// Side effects.
if (typeof exports.activate === 'function') {
await Promise.resolve(exports.activate(api));
}
sendToHost({ type: 'init-done', hooks: hookNames, slots: slotInfo, shortcuts: shortcutInfo });
}
function bootSlot(payload: SlotInit): void {
const api = buildPluginApi(payload.manifest);
const exports = evaluateBundle(payload.code, api);
pluginExports = exports;
slotName = payload.slot;
const slotDef = exports.slots?.[payload.slot];
if (!slotDef || typeof slotDef.component !== 'function') {
throw new Error(`Plugin "${payload.pluginId}" does not export slots["${payload.slot}"].component`);
}
const rootEl = document.getElementById('plugin-sandbox-root');
if (!rootEl) throw new Error('Sandbox root element missing');
let currentProps = decodeCallbacks(payload.extraProps) as Record<string, unknown>;
const Component = slotDef.component;
// A trivial pub/sub so host-pushed `props-update` messages re-render the
// slot tree without tearing down the iframe.
const propsListeners = new Set<(p: Record<string, unknown>) => void>();
slotPropsUpdater = (next) => {
currentProps = decodeCallbacks(next) as Record<string, unknown>;
for (const l of propsListeners) {
try { l(currentProps); } catch { /* ignore */ }
}
};
const SlotShell = () => {
const wrapRef = React.useRef<HTMLDivElement>(null);
const [props, setProps] = React.useState(currentProps);
React.useEffect(() => {
propsListeners.add(setProps);
return () => { propsListeners.delete(setProps); };
}, []);
React.useEffect(() => {
if (!wrapRef.current) return;
let lastHeight = -1;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const h = Math.ceil(entry.contentRect.height);
if (h !== lastHeight) {
lastHeight = h;
sendToHost({ type: 'slot-resize', height: h });
}
}
});
ro.observe(wrapRef.current);
return () => ro.disconnect();
}, []);
return React.createElement('div', { ref: wrapRef }, React.createElement(Component, props));
};
const reactRoot = ReactDOM.createRoot(rootEl);
reactRoot.render(React.createElement(SlotShell));
sendToHost({ type: 'init-done', hooks: [], slots: [], shortcuts: [] });
}
// Populated by bootSlot — receives `props-update` messages.
let slotPropsUpdater: ((next: Record<string, unknown>) => void) | null = null;
async function handleInit(payload: InitPayload): Promise<void> {
if (bootDone) return;
bootDone = true;
mode = payload.mode;
try {
if (payload.mode === 'background') {
await bootBackground(payload);
} else {
bootSlot(payload);
}
} catch (err) {
sendToHost({ type: 'init-error', error: (err as Error).message ?? String(err) });
}
}
// ─── Host message handler ────────────────────────────────────
function handleHostMessage(ev: MessageEvent): void {
// First inbound message pins source + origin. Reject everything else.
if (!parentWindow) {
if (!ev.source || ev.source === window) return;
parentWindow = ev.source as Window;
parentOrigin = ev.origin || null;
}
if (ev.source !== parentWindow) return;
if (parentOrigin && ev.origin !== parentOrigin) return;
const msg = ev.data as HostToSandbox;
if (!msg || typeof (msg as { type?: unknown }).type !== 'string') return;
switch (msg.type) {
case 'init':
void handleInit(msg.payload);
break;
case 'api-response': {
const pending = pendingApi.get(msg.id);
if (!pending) return;
pendingApi.delete(msg.id);
if (msg.ok) pending.resolve(msg.result);
else pending.reject(new Error(msg.error ?? 'api error'));
break;
}
case 'callback-response': {
const pending = pendingCallbacks.get(msg.id);
if (!pending) return;
pendingCallbacks.delete(msg.id);
if (msg.ok) pending.resolve(msg.result);
else pending.reject(new Error(msg.error ?? 'callback error'));
break;
}
case 'hook-invoke': {
const handler = hookHandlers[msg.hookName];
if (!handler) {
sendToHost({ type: 'hook-result', id: msg.id, ok: false, error: `no handler for ${msg.hookName}` });
return;
}
try {
const result = handler(...(msg.args ?? []));
Promise.resolve(result).then(
(v) => sendToHost({ type: 'hook-result', id: msg.id, ok: true, result: v }),
(e) => sendToHost({ type: 'hook-result', id: msg.id, ok: false, error: (e as Error).message ?? String(e) }),
);
} catch (err) {
sendToHost({ type: 'hook-result', id: msg.id, ok: false, error: (err as Error).message });
}
break;
}
case 'slot-should-show': {
// Resolved by the background instance for any slot it offers.
const slotDef = pluginExports?.slots?.[msg.slot];
let show = true;
try {
if (slotDef && typeof slotDef.shouldShow === 'function') {
show = !!slotDef.shouldShow(msg.context);
}
} catch {
show = false;
}
sendToHost({ type: 'slot-should-show-result', id: msg.id, show });
break;
}
case 'locale-change':
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = msg.locale;
break;
case 'props-update':
slotPropsUpdater?.(msg.props ?? {});
break;
}
}
// ─── React entry ─────────────────────────────────────────────
export function SandboxRuntime(): React.JSX.Element {
const inited = useRef(false);
useEffect(() => {
if (inited.current) return;
inited.current = true;
window.addEventListener('message', handleHostMessage);
// Initial ping. We don't know parent origin yet, so '*' is required.
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'sandbox-ready' } satisfies SandboxToHost, '*');
}
return () => {
window.removeEventListener('message', handleHostMessage);
};
}, []);
return <div id="plugin-sandbox-root" />;
}
// Suppress unused-variable warning when `mode` is only read for debugging.
void mode;
void slotName;
+132
View File
@@ -0,0 +1,132 @@
// Plugin shortcut dispatcher.
//
// Each enabled plugin declares zero-or-more keyboard shortcuts via its
// `shortcuts` export. On init the host registers each binding here. A single
// window keydown listener matches keys against the active bindings and
// dispatches via `instance.invokeHook('shortcut:<id>', [])`.
//
// The listener ignores events when an editable element has focus, matching
// the convention in `use-keyboard-shortcuts.ts`.
import type { SandboxInstance } from './host-bridge';
interface Binding {
pluginId: string;
shortcutId: string;
keys: string; // "Ctrl+Shift+L"
label: string;
category?: string;
invoke: () => Promise<void>;
}
interface NormalisedCombo {
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
key: string;
}
const bindings = new Map<string, Binding>(); // key: `${pluginId}:${shortcutId}`
let listenerInstalled = false;
function normaliseCombo(combo: string): NormalisedCombo | null {
if (typeof combo !== 'string') return null;
const parts = combo.split('+').map(p => p.trim()).filter(Boolean);
if (parts.length === 0) return null;
let ctrl = false, shift = false, alt = false, meta = false;
let key = '';
for (const p of parts) {
const lower = p.toLowerCase();
if (lower === 'ctrl' || lower === 'control') ctrl = true;
else if (lower === 'shift') shift = true;
else if (lower === 'alt' || lower === 'option') alt = true;
else if (lower === 'meta' || lower === 'cmd' || lower === 'command') meta = true;
else key = lower;
}
if (!key) return null;
return { ctrl, shift, alt, meta, key };
}
function eventMatches(ev: KeyboardEvent, combo: NormalisedCombo): boolean {
if (combo.ctrl !== (ev.ctrlKey || ev.metaKey ? ev.ctrlKey : false)) {
// Treat Ctrl and Cmd as equivalent: a binding declaring Ctrl matches a
// Cmd press on macOS.
if (combo.ctrl) {
if (!(ev.ctrlKey || ev.metaKey)) return false;
} else if (ev.ctrlKey) return false;
}
if (combo.shift !== ev.shiftKey) return false;
if (combo.alt !== ev.altKey) return false;
if (!combo.ctrl && combo.meta !== ev.metaKey) return false;
return ev.key.toLowerCase() === combo.key;
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
if (target.isContentEditable) return true;
return false;
}
function onKeyDown(ev: KeyboardEvent): void {
if (isEditableTarget(ev.target)) return;
if (bindings.size === 0) return;
for (const binding of bindings.values()) {
const combo = normaliseCombo(binding.keys);
if (!combo) continue;
if (eventMatches(ev, combo)) {
ev.preventDefault();
ev.stopPropagation();
void binding.invoke();
return;
}
}
}
function ensureListener(): void {
if (listenerInstalled || typeof window === 'undefined') return;
listenerInstalled = true;
window.addEventListener('keydown', onKeyDown, true);
}
export function registerShortcuts(
instance: SandboxInstance,
shortcuts: Array<{ id: string; keys: string; label: string; category?: string }>,
): () => void {
ensureListener();
const keys: string[] = [];
for (const sc of shortcuts) {
const key = `${instance.pluginId}:${sc.id}`;
bindings.set(key, {
pluginId: instance.pluginId,
shortcutId: sc.id,
keys: sc.keys,
label: sc.label,
category: sc.category,
invoke: async () => {
try {
await instance.invokeHook(`shortcut:${sc.id}`, []);
} catch {
/* hook tracker already logs */
}
},
});
keys.push(key);
}
return () => {
for (const k of keys) bindings.delete(k);
};
}
/** Snapshot of currently active shortcuts. Used by the help modal. */
export function listShortcuts(): Array<{ pluginId: string; id: string; keys: string; label: string; category?: string }> {
return [...bindings.values()].map(b => ({
pluginId: b.pluginId,
id: b.shortcutId,
keys: b.keys,
label: b.label,
category: b.category,
}));
}
+70 -1
View File
@@ -127,6 +127,15 @@ export interface PluginManifest {
* The remote host must serve CORS headers permitting the webmail origin.
*/
httpOrigins?: string[];
/**
* Same-origin `/api/*` paths this plugin may target via `api.http.post()`.
* Each entry is a path prefix; a call to `api.http.post('/api/X', ...)` is
* accepted iff `'/api/X'` exactly equals an entry OR an entry ends in
* `/` and `'/api/X'` starts with it. With no entry (or an empty array),
* the plugin may not call `api.http.post` even with the `http:post`
* permission. Validated at install time.
*/
apiPostPaths?: string[];
// ─── Marketplace media (NOT shipped in the runtime zip) ──────
/**
@@ -224,12 +233,26 @@ export interface InstalledPlugin {
* `api.http.fetch()`. Carried over from the manifest at install time.
*/
httpOrigins?: string[];
/**
* Validated allowlist of same-origin `/api/*` paths this plugin may target
* via `api.http.post()`. Carried over from the manifest at install time.
*/
apiPostPaths?: string[];
/**
* Permissions the user has explicitly granted. Populated by the in-app
* consent dialog the first time the plugin is enabled. The host API gate
* checks this set in addition to `permissions`, so an unapproved permission
* cannot be exercised even if it appears in the manifest. Managed plugins
* skip the consent prompt (admin pre-approval).
*/
grantedPermissions?: string[];
}
// ─── UI Slots ────────────────────────────────────────────────
export type SlotName =
| 'toolbar-actions'
| 'app-top-banner'
| 'email-banner'
| 'email-footer'
| 'composer-toolbar'
@@ -622,6 +645,52 @@ export interface ReplyContext {
mode: 'reply' | 'reply-all' | 'forward';
}
/**
* Second argument to onBuildQuoteHeader transform handlers. Describes the
* original message and how the host plans to render the quote header so
* plugins can produce a replacement block (e.g. an Outlook-style
* From/Sent/To/Cc/Subject section).
*/
export interface QuoteHeaderContext {
mode: 'reply' | 'replyAll' | 'forward';
/** Recipients of the new outgoing message (already resolved by the host). */
newTo: string[];
newCc: string[];
/** Original message metadata. */
from: { name?: string; email: string } | null;
to: { name?: string; email: string }[];
cc: { name?: string; email: string }[];
subject: string;
/** Pre-formatted date string the host already produced (locale-aware). */
date: string;
/** Raw ISO datetime, in case the plugin wants to reformat. */
receivedAt?: string;
/** Active UI locale (BCP-47), useful for Intl.DateTimeFormat in plugins. */
locale: string;
}
/**
* Initial value for the onBuildQuoteHeader transform hook. Plugins return a
* replacement; returning undefined falls through to the next handler or the
* default. The composer splices `html` into HTML drafts and `text` into
* plain-text drafts.
*
* For HTML, returning a header that includes its own surrounding wrapper
* (`<div>...</div>`) is fine; the composer does not add extra wrappers.
* For text, the host appends the quoted body after the header.
*/
export interface QuoteHeader {
html: string;
text: string;
/**
* When false, the composer skips its default blockquote wrapping around the
* quoted body (HTML mode only). Use this for the Outlook style where the
* quoted message is intended to follow the header without indentation.
* Defaults to true (preserve the existing blockquote wrapping).
*/
wrapInBlockquote?: boolean;
}
/**
* Describes an attachment crossing an attachment hook (upload, download, preview).
*/
@@ -778,7 +847,7 @@ export const ALL_PERMISSIONS = [
'security:read',
'auth:observe',
'http:post', 'http:fetch',
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer',
'ui:composer-toolbar', 'ui:composer-sidebar',
'ui:sidebar-widget', 'ui:settings-section',
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
+117
View File
@@ -0,0 +1,117 @@
export interface ParsedMailto {
to: string[];
cc: string[];
bcc: string[];
subject: string;
body: string;
}
const MAX_RECIPIENTS = 200;
const MAX_SUBJECT_LENGTH = 998;
const MAX_BODY_LENGTH = 64 * 1024;
// eslint-disable-next-line no-control-regex
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
// eslint-disable-next-line no-control-regex
const CONTROL_CHARS_EXCEPT_LINE_BREAKS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
function stripControlChars(value: string): string {
return value.replace(CONTROL_CHARS, "");
}
function stripBodyControlChars(value: string): string {
return value
.replace(/\r\n?/g, "\n")
.replace(CONTROL_CHARS_EXCEPT_LINE_BREAKS, "");
}
function splitRecipients(value: string): string[] {
return stripControlChars(value)
.split(",")
.map((recipient) => recipient.trim())
.filter(Boolean);
}
type QueryParam = {
key: string;
value: string;
};
function getQueryValue(searchParams: QueryParam[], key: string): string {
const values: string[] = [];
const lowerKey = key.toLowerCase();
for (const { key: paramKey, value } of searchParams) {
if (paramKey.toLowerCase() === lowerKey) {
values.push(value);
}
}
return values.join(",");
}
function decodePathname(pathname: string): string | null {
try {
return decodeURIComponent(pathname || "");
} catch {
return null;
}
}
function decodeQueryPart(value: string): string | null {
try {
// RFC 6068 uses percent-encoding for mailto query fields; unlike form
// encoding, a literal '+' is part of the value and must not become space.
return decodeURIComponent(value);
} catch {
return null;
}
}
function parseQuery(query: string): QueryParam[] | null {
if (!query) return [];
const params: QueryParam[] = [];
for (const part of query.split("&")) {
if (!part) continue;
const separatorIndex = part.indexOf("=");
const rawKey = separatorIndex >= 0 ? part.slice(0, separatorIndex) : part;
const rawValue = separatorIndex >= 0 ? part.slice(separatorIndex + 1) : "";
const key = decodeQueryPart(rawKey);
const value = decodeQueryPart(rawValue);
if (key === null || value === null) return null;
params.push({ key, value });
}
return params;
}
export function parseMailto(raw: string): ParsedMailto | null {
if (!raw.toLowerCase().startsWith("mailto:")) return null;
const addressAndQuery = raw.slice("mailto:".length);
const queryIndex = addressAndQuery.indexOf("?");
const rawPathname = queryIndex >= 0 ? addressAndQuery.slice(0, queryIndex) : addressAndQuery;
const rawQuery = queryIndex >= 0 ? addressAndQuery.slice(queryIndex + 1) : "";
const decodedPathname = decodePathname(rawPathname);
if (decodedPathname === null) return null;
const searchParams = parseQuery(rawQuery);
if (searchParams === null) return null;
const to = [
...splitRecipients(decodedPathname),
...splitRecipients(getQueryValue(searchParams, "to")),
].slice(0, MAX_RECIPIENTS);
const remainingAfterTo = Math.max(0, MAX_RECIPIENTS - to.length);
const cc = splitRecipients(getQueryValue(searchParams, "cc")).slice(0, remainingAfterTo);
const remainingAfterCc = Math.max(0, MAX_RECIPIENTS - to.length - cc.length);
const bcc = splitRecipients(getQueryValue(searchParams, "bcc")).slice(0, remainingAfterCc);
return {
to,
cc,
bcc,
subject: stripControlChars(getQueryValue(searchParams, "subject")).slice(0, MAX_SUBJECT_LENGTH),
body: stripBodyControlChars(getQueryValue(searchParams, "body")).slice(0, MAX_BODY_LENGTH),
};
}
+357
View File
@@ -0,0 +1,357 @@
import type { ParsedMailto } from "./mailto";
import type { ParsedWebcal } from "./webcal";
const MAILTO_KEY = "bulwark:pending-mailto";
const WEBCAL_KEY = "bulwark:pending-webcal";
const PROTOCOL_CHANNEL = "bulwark:protocol-handlers";
const PENDING_TTL_MS = 5 * 60 * 1000;
const MAILTO_REQUEST = "mailto-request";
const MAILTO_CANDIDATE = "mailto-candidate";
const MAILTO_ACK = "mailto-ack";
const OPEN_MAILTO_IN_CLIENT = "open-mailto-in-client";
const MAILTO_CLIENT_READY = "mailto-client-ready";
const MAILTO_CLIENT_GONE = "mailto-client-gone";
const PENDING_MAILTO_EVENT = "bulwark:pending-mailto";
const PENDING_WEBCAL_EVENT = "bulwark:pending-webcal";
type PendingValue<T> = T & { createdAt: number };
type PendingMailtoRequest = { type: typeof MAILTO_REQUEST; id: string; value: ParsedMailto; clientId?: string };
type PendingMailtoCandidate = { type: typeof MAILTO_CANDIDATE; id: string; clientId: string; priority: number };
type PendingMailtoAck = { type: typeof MAILTO_ACK; id: string };
type OpenMailtoInClientRequest = {
type: typeof OPEN_MAILTO_IN_CLIENT;
id: string;
value: ParsedMailto;
clientId?: string;
};
type ProtocolClientInfo = {
path: string;
standalone: boolean;
clientId?: string;
focusNotificationTitle?: string;
focusNotificationBody?: string;
};
function savePending<T>(key: string, value: T) {
try {
sessionStorage.setItem(key, JSON.stringify({ ...value, createdAt: Date.now() }));
} catch {
// Storage can be unavailable in hardened/private browser modes.
}
}
function consumePending<T>(key: string, validate: (value: unknown) => value is T): T | null {
try {
const raw = sessionStorage.getItem(key);
sessionStorage.removeItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw) as PendingValue<unknown>;
if (typeof parsed.createdAt !== "number" || Date.now() - parsed.createdAt > PENDING_TTL_MS) {
return null;
}
return validate(parsed) ? parsed : null;
} catch {
return null;
}
}
function hasPending<T>(key: string, validate: (value: unknown) => value is T): boolean {
try {
const raw = sessionStorage.getItem(key);
if (!raw) return false;
const parsed = JSON.parse(raw) as PendingValue<unknown>;
if (typeof parsed.createdAt !== "number" || Date.now() - parsed.createdAt > PENDING_TTL_MS) {
sessionStorage.removeItem(key);
return false;
}
return validate(parsed);
} catch {
return false;
}
}
function isParsedMailto(value: unknown): value is ParsedMailto {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<ParsedMailto>;
return Array.isArray(candidate.to)
&& Array.isArray(candidate.cc)
&& Array.isArray(candidate.bcc)
&& typeof candidate.subject === "string"
&& typeof candidate.body === "string";
}
function isPendingMailtoRequest(value: unknown): value is PendingMailtoRequest {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<PendingMailtoRequest>;
return candidate.type === MAILTO_REQUEST
&& typeof candidate.id === "string"
&& isParsedMailto(candidate.value)
&& (candidate.clientId === undefined || typeof candidate.clientId === "string");
}
function isPendingMailtoAck(value: unknown, id: string): value is PendingMailtoAck {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<PendingMailtoAck>;
return candidate.type === MAILTO_ACK && candidate.id === id;
}
function isPendingMailtoCandidate(value: unknown, id: string): value is PendingMailtoCandidate {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<PendingMailtoCandidate>;
return candidate.type === MAILTO_CANDIDATE
&& candidate.id === id
&& typeof candidate.clientId === "string"
&& typeof candidate.priority === "number";
}
function isOpenMailtoInClientRequest(value: unknown): value is OpenMailtoInClientRequest {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<OpenMailtoInClientRequest>;
return candidate.type === OPEN_MAILTO_IN_CLIENT
&& typeof candidate.id === "string"
&& isParsedMailto(candidate.value)
&& (candidate.clientId === undefined || typeof candidate.clientId === "string");
}
function createRequestId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
const BROWSER_CLIENT_ID = createRequestId();
function getMailtoClientPriority(info: ProtocolClientInfo): number {
const isMailSection = info.path === "/" || info.path === "";
if (info.standalone && isMailSection) return 0;
if (isMailSection) return 1;
if (info.standalone) return 2;
return 3;
}
function getDefaultProtocolClientInfo(): ProtocolClientInfo {
const nav = navigator as Navigator & { standalone?: boolean };
const standalone = window.matchMedia?.("(display-mode: standalone)").matches || nav.standalone === true;
return { path: window.location.pathname, standalone, clientId: BROWSER_CLIENT_ID };
}
async function requestMailtoViaServiceWorker(value: ParsedMailto, timeoutMs: number): Promise<boolean> {
if (typeof navigator === "undefined"
|| !("serviceWorker" in navigator)
|| typeof MessageChannel === "undefined") {
return false;
}
try {
const registration = await Promise.race([
navigator.serviceWorker.ready,
new Promise<null>((resolve) => globalThis.setTimeout(() => resolve(null), timeoutMs)),
]);
if (!registration) return false;
const worker = navigator.serviceWorker.controller ?? registration.active;
if (!worker) return false;
return await new Promise((resolve) => {
const channel = new MessageChannel();
const timeout = globalThis.setTimeout(() => {
channel.port1.close();
resolve(false);
}, timeoutMs);
channel.port1.onmessage = (event) => {
globalThis.clearTimeout(timeout);
channel.port1.close();
resolve(event.data?.delivered === true);
};
worker.postMessage({
type: OPEN_MAILTO_IN_CLIENT,
id: createRequestId(),
value,
} satisfies OpenMailtoInClientRequest, [channel.port2]);
});
} catch {
return false;
}
}
function notifyServiceWorker(
type: typeof MAILTO_CLIENT_READY | typeof MAILTO_CLIENT_GONE,
info?: ProtocolClientInfo,
) {
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
navigator.serviceWorker.ready
.then((registration) => {
const worker = navigator.serviceWorker.controller ?? registration.active;
worker?.postMessage({ type, ...info });
})
.catch(() => {
// Service worker registration is optional for local/dev environments.
});
}
function isParsedWebcal(value: unknown): value is ParsedWebcal {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<ParsedWebcal>;
return typeof candidate.originalUrl === "string"
&& typeof candidate.subscriptionUrl === "string"
&& typeof candidate.suggestedName === "string";
}
export function savePendingMailto(value: ParsedMailto) {
savePending(MAILTO_KEY, value);
}
export function consumePendingMailto(): ParsedMailto | null {
return consumePending(MAILTO_KEY, isParsedMailto);
}
export function notifyPendingMailto() {
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(PENDING_MAILTO_EVENT));
}
}
export function subscribeToPendingMailto(callback: () => void): () => void {
if (typeof window === "undefined") return () => {};
window.addEventListener(PENDING_MAILTO_EVENT, callback);
return () => window.removeEventListener(PENDING_MAILTO_EVENT, callback);
}
async function requestMailtoViaBroadcastChannel(value: ParsedMailto, timeoutMs: number): Promise<boolean> {
if (typeof BroadcastChannel === "undefined") {
return false;
}
return new Promise((resolve) => {
const id = createRequestId();
const channel = new BroadcastChannel(PROTOCOL_CHANNEL);
const candidates: PendingMailtoCandidate[] = [];
let selected = false;
let selectionTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
const candidateWindowMs = Math.min(75, Math.max(25, Math.floor(timeoutMs / 3)));
const timeout = globalThis.setTimeout(() => {
if (selectionTimer) globalThis.clearTimeout(selectionTimer);
channel.close();
resolve(false);
}, timeoutMs);
const selectCandidate = () => {
if (selected) return;
selected = true;
const best = candidates.sort((a, b) => a.priority - b.priority)[0];
if (!best) {
globalThis.clearTimeout(timeout);
channel.close();
resolve(false);
return;
}
channel.postMessage({
type: OPEN_MAILTO_IN_CLIENT,
id,
clientId: best.clientId,
value,
} satisfies OpenMailtoInClientRequest);
};
channel.onmessage = (event) => {
if (isPendingMailtoCandidate(event.data, id)) {
candidates.push(event.data);
selectionTimer ??= globalThis.setTimeout(selectCandidate, candidateWindowMs);
return;
}
if (isPendingMailtoAck(event.data, id)) {
if (selectionTimer) globalThis.clearTimeout(selectionTimer);
globalThis.clearTimeout(timeout);
channel.close();
resolve(true);
}
};
channel.postMessage({ type: MAILTO_REQUEST, id, value } satisfies PendingMailtoRequest);
});
}
export async function requestOpenMailtoInExistingClient(value: ParsedMailto, timeoutMs = 300): Promise<boolean> {
if (await requestMailtoViaServiceWorker(value, timeoutMs)) return true;
return requestMailtoViaBroadcastChannel(value, timeoutMs);
}
export function listenForMailtoRequests(
onMailto: (value: ParsedMailto) => void,
getClientInfo: () => ProtocolClientInfo = getDefaultProtocolClientInfo,
): () => void {
const cleanup: Array<() => void> = [];
const clientInfo = getClientInfo();
if (typeof navigator !== "undefined" && "serviceWorker" in navigator) {
const handleServiceWorkerMessage = (event: MessageEvent) => {
if (isPendingMailtoRequest(event.data)) {
if (event.data.clientId !== undefined && event.data.clientId !== BROWSER_CLIENT_ID) return;
if (typeof window !== "undefined") window.focus();
onMailto(event.data.value);
}
};
navigator.serviceWorker.addEventListener("message", handleServiceWorkerMessage);
notifyServiceWorker(MAILTO_CLIENT_READY, { ...clientInfo, clientId: BROWSER_CLIENT_ID });
cleanup.push(() => {
notifyServiceWorker(MAILTO_CLIENT_GONE, { ...clientInfo, clientId: BROWSER_CLIENT_ID });
navigator.serviceWorker.removeEventListener("message", handleServiceWorkerMessage);
});
}
if (typeof BroadcastChannel !== "undefined") {
const channel = new BroadcastChannel(PROTOCOL_CHANNEL);
channel.onmessage = (event) => {
if (isPendingMailtoRequest(event.data)) {
channel.postMessage({
type: MAILTO_CANDIDATE,
id: event.data.id,
clientId: BROWSER_CLIENT_ID,
priority: getMailtoClientPriority(getClientInfo()),
} satisfies PendingMailtoCandidate);
return;
}
if (!isOpenMailtoInClientRequest(event.data) || event.data.clientId !== BROWSER_CLIENT_ID) return;
if (typeof window !== "undefined") window.focus();
onMailto(event.data.value);
channel.postMessage({ type: MAILTO_ACK, id: event.data.id } satisfies PendingMailtoAck);
};
cleanup.push(() => channel.close());
}
return () => cleanup.forEach((dispose) => dispose());
}
export function savePendingWebcal(value: ParsedWebcal) {
savePending(WEBCAL_KEY, value);
}
export function consumePendingWebcal(): ParsedWebcal | null {
return consumePending(WEBCAL_KEY, isParsedWebcal);
}
export function notifyPendingWebcal() {
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(PENDING_WEBCAL_EVENT));
}
}
export function subscribeToPendingWebcal(callback: () => void): () => void {
if (typeof window === "undefined") return () => {};
window.addEventListener(PENDING_WEBCAL_EVENT, callback);
return () => window.removeEventListener(PENDING_WEBCAL_EVENT, callback);
}
export function hasPendingWebcal(): boolean {
return hasPending(WEBCAL_KEY, isParsedWebcal);
}
+49
View File
@@ -0,0 +1,49 @@
export interface ParsedWebcal {
originalUrl: string;
subscriptionUrl: string;
suggestedName: string;
}
function stripControlChars(value: string): string {
// eslint-disable-next-line no-control-regex
return value.replace(/[\u0000-\u001F\u007F]/g, "").trim();
}
function extensionlessName(value: string): string {
return value.replace(/\.(ics|ical)$/i, "");
}
function decodePathSegment(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
export function parseWebcal(raw: string): ParsedWebcal | null {
let url: URL;
try {
url = new URL(raw);
} catch {
return null;
}
if (url.protocol === "webcal:" || url.protocol === "webcals:") {
url = new URL(raw.replace(/^webcals?:/i, "https:"));
} else if (url.protocol !== "http:" && url.protocol !== "https:") {
return null;
}
const subscriptionUrl = url.toString();
const queryName = stripControlChars(url.searchParams.get("name") || "");
const pathSegment = stripControlChars(decodePathSegment(url.pathname.split("/").filter(Boolean).pop() || ""));
const suggestedName = queryName || extensionlessName(pathSegment) || url.hostname;
return {
originalUrl: raw,
subscriptionUrl,
suggestedName,
};
}
+82
View File
@@ -0,0 +1,82 @@
// Builds the default reply/forward quote header and runs it through the
// emailHooks.onBuildQuoteHeader transform so plugins can replace it (e.g.
// with an Outlook-style From/Sent/To/Cc/Subject block).
//
// This module is the single source of truth for the default header strings -
// the composer keeps the same defaults inline as a fallback, but production
// flow goes through here.
import { formatDateTime } from "@/lib/utils";
import { emailHooks } from "@/lib/plugin-hooks";
import type { QuoteHeader, QuoteHeaderContext } from "@/lib/plugin-types";
interface BuildArgs {
mode: "reply" | "replyAll" | "forward";
email: {
from?: { email?: string; name?: string }[];
to?: { email?: string; name?: string }[];
cc?: { email?: string; name?: string }[];
subject?: string;
receivedAt?: string;
};
newTo: string[];
newCc: string[];
locale: string;
timeFormat: "12h" | "24h";
unknownLabel: string;
}
function defaultHeader(args: BuildArgs): QuoteHeader {
const { mode, email, timeFormat, unknownLabel } = args;
const date = email.receivedAt
? formatDateTime(email.receivedAt, timeFormat, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
})
: "";
const from = email.from?.[0];
const fromStr = from ? `${from.name || from.email}` : unknownLabel;
const subject = email.subject || "";
if (mode === "forward") {
const text = `---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${subject}\n`;
const html = `<div>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${subject}<br><br></div>`;
return { html, text, wrapInBlockquote: false };
}
const text = `On ${date}, ${fromStr} wrote:\n`;
const html = `<div>On ${date}, ${fromStr} wrote:<br></div>`;
return { html, text, wrapInBlockquote: true };
}
export async function buildQuoteHeader(args: BuildArgs): Promise<QuoteHeader> {
const def = defaultHeader(args);
const ctx: QuoteHeaderContext = {
mode: args.mode,
newTo: args.newTo,
newCc: args.newCc,
from: args.email.from?.[0]?.email
? { name: args.email.from[0].name, email: args.email.from[0].email }
: null,
to: (args.email.to ?? [])
.filter((r): r is { email: string; name?: string } => !!r.email)
.map((r) => ({ name: r.name, email: r.email })),
cc: (args.email.cc ?? [])
.filter((r): r is { email: string; name?: string } => !!r.email)
.map((r) => ({ name: r.name, email: r.email })),
subject: args.email.subject ?? "",
date: args.email.receivedAt
? formatDateTime(args.email.receivedAt, args.timeFormat, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
})
: "",
receivedAt: args.email.receivedAt,
locale: args.locale,
};
return emailHooks.onBuildQuoteHeader.transform<QuoteHeader>(def, ctx);
}
+95
View File
@@ -2,6 +2,7 @@ import type { Identity } from '@/lib/jmap/types';
interface ReplyRecipient {
email?: string | null;
name?: string | null;
}
interface ReplyRecipients {
@@ -29,6 +30,11 @@ function normalizeBaseEmailAddress(email: string): string {
return `${plusIndex >= 0 ? localPart.slice(0, plusIndex) : localPart}@${domain}`;
}
function domainOf(email: string): string {
const at = email.indexOf('@');
return at > 0 ? email.slice(at + 1).toLowerCase() : '';
}
export function findReplyIdentityId(
identities: Identity[],
recipients?: ReplyRecipients,
@@ -59,4 +65,93 @@ export function findReplyIdentityId(
const baseIdentity = identities.find((identity) => baseMatches.has(normalizeBaseEmailAddress(identity.email)));
return baseIdentity?.id ?? null;
}
export interface ReplyFromResolution {
/** Identity to use for JMAP `identityId` and the SMTP envelope MAIL FROM. */
identityId: string;
/**
* Override for the outgoing `From:` header. Populated when the incoming
* message was delivered to an address on a domain the user owns (by
* identity) but that isn't itself a configured identity - typical
* domain-catch-all deployments. When set, the composer should put this
* address (and `overrideName`) in the message's From header while sending
* through the chosen identity.
*/
overrideEmail?: string;
overrideName?: string;
}
/**
* Pick the identity + optional header-From override for replying to a message.
*
* Decision order:
* 1. If a recipient address exactly matches an identity, reply as that
* identity with no override.
* 2. Else if a recipient matches an identity after stripping `+tag`
* sub-addressing, reply as that identity with no override.
* 3. Else if a recipient address is on a domain that one of the identities
* uses, treat that recipient as a catch-all alias: return the matching
* identity + the recipient as a header-From override.
* 4. Else return `null` (caller falls back to primary identity).
*/
export function resolveReplyFrom(
identities: Identity[],
recipients?: ReplyRecipients,
): ReplyFromResolution | null {
if (identities.length === 0 || !recipients) {
return null;
}
const received: { email: string; name: string | undefined }[] = [
...(recipients.to || []),
...(recipients.cc || []),
...(recipients.bcc || []),
].flatMap((r) => {
const email = r.email?.trim();
if (!email) return [];
return [{ email, name: r.name?.trim() || undefined }];
});
if (received.length === 0) {
return null;
}
const identityEmails = new Set(identities.map((i) => normalizeEmailAddress(i.email)));
const identityBaseEmails = new Set(identities.map((i) => normalizeBaseEmailAddress(i.email)));
const exactIdentity = identities.find((i) =>
received.some((r) => normalizeEmailAddress(r.email) === normalizeEmailAddress(i.email)),
);
if (exactIdentity) {
return { identityId: exactIdentity.id };
}
const baseIdentity = identities.find((i) =>
received.some((r) => normalizeBaseEmailAddress(r.email) === normalizeBaseEmailAddress(i.email)),
);
if (baseIdentity) {
return { identityId: baseIdentity.id };
}
const ownedDomains = new Set(identities.map((i) => domainOf(i.email)).filter(Boolean));
const catchAll = received.find((r) => {
const email = normalizeEmailAddress(r.email);
if (identityEmails.has(email) || identityBaseEmails.has(normalizeBaseEmailAddress(email))) {
return false;
}
return ownedDomains.has(domainOf(email));
});
if (catchAll) {
const anchor = identities.find((i) => domainOf(i.email) === domainOf(catchAll.email)) || identities[0];
return {
identityId: anchor.id,
overrideEmail: catchAll.email,
overrideName: catchAll.name,
};
}
return null;
}
+2 -2
View File
@@ -3,14 +3,14 @@ import { readFile, writeFile, unlink, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
import { getSessionSecret } from '@/lib/auth/session-secret';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
function getKey(): Buffer {
const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
const secret = getSessionSecret();
if (!secret) throw new Error('SESSION_SECRET not configured');
return createHash('sha256').update(secret).digest();
}
+33
View File
@@ -0,0 +1,33 @@
import { cookies } from 'next/headers';
import { verifySetupToken } from './token';
export const SETUP_COOKIE = 'bulwark_setup_token';
const COOKIE_MAX_AGE = 60 * 60; // 1 hour, matches token TTL
/**
* The wizard "session" is just the setup token itself, set as an HttpOnly
* cookie after the operator pastes it into step 1. Subsequent step calls
* re-verify the cookie value against the .setup-token file. When the wizard
* finishes, the token file is deleted and any cookies become useless.
*
* No JWT, no separate signing key, no rotating session id. The lifecycle of
* the wizard maps 1:1 to the lifecycle of the token file.
*/
export async function authenticateWizardRequest(): Promise<boolean> {
const jar = await cookies();
const token = jar.get(SETUP_COOKIE)?.value;
if (!token) return false;
return verifySetupToken(token);
}
export function buildSessionCookieAttributes() {
return {
name: SETUP_COOKIE,
httpOnly: true,
sameSite: 'lax' as const,
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: COOKIE_MAX_AGE,
};
}
+53
View File
@@ -0,0 +1,53 @@
import { existsSync } from 'node:fs';
import { configManager } from '@/lib/admin/config-manager';
import { getConfigPath, isConfigReadOnly } from '@/lib/admin/paths';
/**
* The three lifecycle states for the running container.
*
* bootstrap - no config persisted yet and no JMAP_SERVER_URL env. The
* setup wizard is served at /setup; everything else 302s
* there.
* configured - setup wizard finished (admin override config.json carries
* setupComplete=true). Normal app; /setup returns 404.
* env-managed - JMAP_SERVER_URL is set in the environment, so the
* operator is configuring via .env (legacy / CI path). The
* wizard stays disabled.
*/
export type SetupState = 'bootstrap' | 'configured' | 'env-managed';
/**
* Cheap to call on every request. configManager keeps `setupComplete` in
* memory after the initial load, so this is just env reads + an in-memory
* boolean check.
*/
export function detectSetupState(): SetupState {
if (configManager.isSetupComplete()) return 'configured';
if (process.env.JMAP_SERVER_URL && process.env.JMAP_SERVER_URL.trim() !== '') {
return 'env-managed';
}
// Read-only config dir + no setupComplete flag means the volume was
// mounted :ro before the wizard ran. Fall through to bootstrap so the
// failure (write attempt during wizard) surfaces with a clear error
// rather than silently 404'ing /setup.
if (isConfigReadOnly()) return 'bootstrap';
return 'bootstrap';
}
/**
* Whether the wizard's UI and APIs should be reachable.
*/
export function isSetupActive(): boolean {
return detectSetupState() === 'bootstrap';
}
/**
* The persisted `.config-locked` marker the wizard drops when the operator
* checks "lock configuration after setup" on the review screen. Purely
* advisory - the actual locking is the operator's `:ro` mount or the
* ADMIN_CONFIG_READONLY env var. This file is what the admin UI uses to
* remind the operator that they intended to lock.
*/
export function lockMarkerExists(): boolean {
return existsSync(getConfigPath('.config-locked'));
}
+111
View File
@@ -0,0 +1,111 @@
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { readFile, writeFile, unlink, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { logger } from '@/lib/logger';
import { ensureStateDir, getStatePath } from '@/lib/admin/paths';
const TOKEN_FILE = '.setup-token';
const TOKEN_BYTES = 32;
const DEFAULT_TTL_SECONDS = 60 * 60; // 1 hour
interface TokenPayload {
token: string;
issuedAt: number;
ttlSeconds: number;
}
/**
* Read the current token if one exists and hasn't expired. Stale tokens
* are deleted lazily - first stale read removes the file.
*/
async function readToken(): Promise<TokenPayload | null> {
const path = getStatePath(TOKEN_FILE);
if (!existsSync(path)) return null;
try {
const raw = await readFile(path, 'utf-8');
const payload = JSON.parse(raw) as TokenPayload;
if (Date.now() / 1000 - payload.issuedAt > payload.ttlSeconds) {
try { await unlink(path); } catch { /* ok */ }
return null;
}
return payload;
} catch (error) {
logger.warn('Failed to read setup token', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return null;
}
}
/**
* Generate (or refresh) the setup token. Called at startup when the app
* detects bootstrap state. Idempotent: returns the existing token if it's
* still valid, otherwise issues a fresh one.
*
* The token lands in a file in ADMIN_STATE_DIR (always writable, never
* read-only) and is also printed to the container logs so the operator
* can copy it without execing into the container.
*/
export async function ensureSetupToken(ttlSeconds: number = DEFAULT_TTL_SECONDS): Promise<string> {
const existing = await readToken();
if (existing) return existing.token;
await ensureStateDir();
const token = randomBytes(TOKEN_BYTES).toString('hex');
const payload: TokenPayload = {
token,
issuedAt: Math.floor(Date.now() / 1000),
ttlSeconds,
};
const path = getStatePath(TOKEN_FILE);
await writeFile(path, JSON.stringify(payload, null, 2), 'utf-8');
return token;
}
/**
* Verify a token submitted by the wizard. Constant-time comparison; never
* leak the stored token via timing.
*/
export async function verifySetupToken(submitted: string): Promise<boolean> {
if (!submitted || typeof submitted !== 'string') return false;
const stored = await readToken();
if (!stored) return false;
const a = Buffer.from(submitted);
const b = Buffer.from(stored.token);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
/**
* Delete the token file. Called by the wizard's finish endpoint after
* setupComplete=true is persisted.
*/
export async function clearSetupToken(): Promise<void> {
const path = getStatePath(TOKEN_FILE);
try {
await unlink(path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
logger.warn('Failed to clear setup token', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
/**
* For diagnostics / startup logging.
*/
export async function getTokenInfo(): Promise<{ exists: boolean; expiresInSeconds: number | null }> {
const path = getStatePath(TOKEN_FILE);
if (!existsSync(path)) return { exists: false, expiresInSeconds: null };
try {
await stat(path);
const payload = await readToken();
if (!payload) return { exists: false, expiresInSeconds: null };
const elapsed = Date.now() / 1000 - payload.issuedAt;
return { exists: true, expiresInSeconds: Math.max(0, Math.floor(payload.ttlSeconds - elapsed)) };
} catch {
return { exists: false, expiresInSeconds: null };
}
}
@@ -111,6 +111,30 @@ describe('external rule preservation (issue #201)', () => {
expect(result.rules).toHaveLength(1);
expect(result.rules[0].origin).toBeUndefined();
});
it('does not duplicate a Bulwark rule whose values contain literal braces', () => {
// Regression for an issue where a value like "Report Domain: {x}" caused
// parseIfBlockToRule's naive indexOf('{') to point inside the value
// instead of at the body, so the if-block fell back to an opaque rule
// that escaped the bulwark-name dedup filter.
const bulwark = [
makeBulwarkRule({
name: 'DMARC reports',
matchType: 'any',
conditions: [
{ field: 'subject', comparator: 'contains', value: 'Report Domain: mydomain.com' },
{ field: 'subject', comparator: 'contains', value: 'Report Domain: {mydomain.com}' },
],
actions: [{ type: 'mark_read' }, { type: 'move', value: 'DMARC' }],
}),
];
const script = generateScript(bulwark);
const result = parseScript(script);
expect(result.rules).toHaveLength(1);
expect(result.rules[0].origin).toBeUndefined();
expect(result.rules[0].name).toBe('DMARC reports');
});
});
describe('generator - external splice', () => {
+28 -7
View File
@@ -434,12 +434,30 @@ function parseAction(raw: string): FilterAction | null {
return null;
}
function findBodyOpenBrace(s: string): number {
// Locate the first `{` that introduces the if-block body, skipping over
// string literals and comments. A naive indexOf('{') would otherwise pick
// up braces inside condition values (e.g. `:contains "{foo}"`).
let i = 0;
while (i < s.length) {
const c = s[i];
if (c === '"') { i = skipStringLit(s, i); continue; }
if (c === '#') { i = skipHashComment(s, i); continue; }
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
if (c === '{') return i;
i++;
}
return -1;
}
function parseIfBlockToRule(block: TopBlock, idPrefix: string, index: number): FilterRule | null {
const stmt = block.statement;
const afterIf = stmt.replace(/^if\s+/, '');
const braceIdx = afterIf.indexOf('{');
const lastBraceIdx = afterIf.lastIndexOf('}');
if (braceIdx === -1 || lastBraceIdx === -1 || lastBraceIdx < braceIdx) return null;
const braceIdx = findBodyOpenBrace(afterIf);
// scanTopLevel's skipIfStatement uses balanced-brace scanning, so the
// statement always terminates at the matching `}`.
const lastBraceIdx = afterIf.length - 1;
if (braceIdx === -1 || afterIf[lastBraceIdx] !== '}' || lastBraceIdx < braceIdx) return null;
const condStr = afterIf.slice(0, braceIdx).trim();
const bodyStr = afterIf.slice(braceIdx + 1, lastBraceIdx).trim();
@@ -681,12 +699,15 @@ export function parseScript(content: string): ParseResult {
// Drop any external "rules" that are really the bulwark-managed if-blocks or vacation.
// Recognizable by the leading comment "# Rule: <name>" or "# Vacation auto-reply".
// This applies regardless of whether the block parsed as a structured rule or
// fell back to opaque - a Bulwark-emitted block may fail to round-trip cleanly
// (e.g. a value with literal braces) but the `# Rule: <name>` marker still
// identifies it as ours.
const filteredExternal = external.rules.filter(r => {
const raw = r.rawBlock || '';
if (/#\s*Rule:\s*/.test(raw) && r.origin === 'external') {
// If the name matches a bulwark rule name exactly, treat as bulwark-emitted
const match = raw.match(/#\s*Rule:\s*(.+?)\s*$/m);
const name = match ? match[1].trim() : '';
const match = raw.match(/#\s*Rule:\s*(.+?)\s*$/m);
if (match) {
const name = match[1].trim();
if (bulwarkRules.some(b => b.name === name)) return false;
}
if (/#\s*Vacation auto-reply/i.test(raw)) return false;
+7 -2
View File
@@ -110,13 +110,18 @@ export function getPlainTextSignature(signature?: SignatureSource | null): strin
return '';
}
export function appendPlainTextSignature(body: string, signature?: SignatureSource | null): string {
export function appendPlainTextSignature(
body: string,
signature?: SignatureSource | null,
options: { separator?: boolean } = {},
): string {
const plainTextSignature = getPlainTextSignature(signature);
if (!plainTextSignature) {
return body;
}
return `${body}\n\n-- \n${plainTextSignature}`;
const sep = options.separator === false ? '\n\n' : '\n\n-- \n';
return `${body}${sep}${plainTextSignature}`;
}
export function hasMeaningfulHtmlBody(html: string): boolean {
+11 -47
View File
@@ -56,7 +56,6 @@ beforeEach(() => {
identityKeyBindings: {},
defaultSignIdentity: {},
defaultEncrypt: false,
rememberUnlockedKeys: false,
autoImportSignerCerts: true,
accountPreferences: {},
currentAccountId: null,
@@ -82,37 +81,16 @@ describe('smime-store', () => {
expect(state.isLoading).toBe(false);
});
it('re-unlocks remembered keys during load', async () => {
it('does not auto-unlock keys on load (security: no persisted passphrases)', async () => {
const records = [mockKeyRecord];
const mockSigningKey = {} as CryptoKey;
const mockDecryptionKey = {} as CryptoKey;
// Simulate a stale legacy entry written by an older build.
sessionStorage.setItem('smime-unlocked-session', JSON.stringify({ 'key-1': 'passphrase' }));
useSmimeStore.setState({ rememberUnlockedKeys: true });
vi.mocked(listKeyRecords).mockResolvedValue(records);
vi.mocked(listPublicCerts).mockResolvedValue([]);
vi.mocked(unlockPrivateKey).mockResolvedValue({
signingKey: mockSigningKey,
decryptionKey: mockDecryptionKey,
});
await useSmimeStore.getState().load();
expect(unlockPrivateKey).toHaveBeenCalledWith(mockKeyRecord, 'passphrase');
expect(useSmimeStore.getState().getUnlockedKey('key-1')).toBe(mockSigningKey);
expect(useSmimeStore.getState().unlockedDecryptionKeys.get('key-1')).toBe(mockDecryptionKey);
});
it('removes stale remembered keys when re-unlock fails', async () => {
const records = [mockKeyRecord];
sessionStorage.setItem('smime-unlocked-session', JSON.stringify({ 'key-1': 'bad-pass' }));
useSmimeStore.setState({ rememberUnlockedKeys: true });
vi.mocked(listKeyRecords).mockResolvedValue(records);
vi.mocked(listPublicCerts).mockResolvedValue([]);
vi.mocked(unlockPrivateKey).mockRejectedValue(new Error('Incorrect passphrase'));
await useSmimeStore.getState().load();
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
expect(unlockPrivateKey).not.toHaveBeenCalled();
expect(useSmimeStore.getState().isKeyUnlocked('key-1')).toBe(false);
});
@@ -210,16 +188,14 @@ describe('smime-store', () => {
expect(useSmimeStore.getState().unlockedDecryptionKeys.get('key-1')).toBe(mockDecryptionKey);
});
it('stores the passphrase for session rehydration when remember is enabled', async () => {
it('never persists the passphrase to sessionStorage', async () => {
const mockSigningKey = {} as CryptoKey;
vi.mocked(unlockPrivateKey).mockResolvedValue({ signingKey: mockSigningKey });
useSmimeStore.setState({ keyRecords: [mockKeyRecord], rememberUnlockedKeys: true });
useSmimeStore.setState({ keyRecords: [mockKeyRecord] });
await useSmimeStore.getState().unlockKey('key-1', 'passphrase');
expect(sessionStorage.getItem('smime-unlocked-session')).toBe(
JSON.stringify({ 'key-1': 'passphrase' }),
);
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
});
it('stores only the signing key when no decryption key is available', async () => {
@@ -240,7 +216,6 @@ describe('smime-store', () => {
});
it('locks a key', () => {
sessionStorage.setItem('smime-unlocked-session', JSON.stringify({ 'key-1': 'passphrase' }));
useSmimeStore.setState({
unlockedKeys: new Map([['key-1', {} as CryptoKey]]),
unlockedDecryptionKeys: new Map([['key-1', {} as CryptoKey]]),
@@ -250,14 +225,9 @@ describe('smime-store', () => {
expect(useSmimeStore.getState().isKeyUnlocked('key-1')).toBe(false);
expect(useSmimeStore.getState().unlockedDecryptionKeys.has('key-1')).toBe(false);
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
});
it('locks all keys', () => {
sessionStorage.setItem(
'smime-unlocked-session',
JSON.stringify({ 'key-1': 'one', 'key-2': 'two' }),
);
useSmimeStore.setState({
unlockedKeys: new Map([
['key-1', {} as CryptoKey],
@@ -273,7 +243,6 @@ describe('smime-store', () => {
expect(useSmimeStore.getState().unlockedKeys.size).toBe(0);
expect(useSmimeStore.getState().unlockedDecryptionKeys.size).toBe(0);
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
});
});
@@ -365,16 +334,11 @@ describe('smime-store', () => {
expect(useSmimeStore.getState().defaultEncrypt).toBe(true);
});
it('sets remember unlocked keys and clears when disabled', () => {
sessionStorage.setItem('smime-unlocked-session', JSON.stringify({ 'key-1': 'passphrase' }));
useSmimeStore.setState({
unlockedKeys: new Map([['key-1', {} as CryptoKey]]),
});
useSmimeStore.getState().setRememberUnlockedKeys(false);
expect(useSmimeStore.getState().rememberUnlockedKeys).toBe(false);
expect(useSmimeStore.getState().unlockedKeys.size).toBe(0);
it('wipes any legacy persisted passphrases on module load', () => {
// Module already loaded by the import above; simulate a stale entry and
// re-import to confirm the cleanup runs. We use the same key the legacy
// build used and assert it stays absent because the store's module-level
// cleanup has already executed.
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
});
+34
View File
@@ -8,6 +8,25 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Strip embedded Basic-auth credentials from a URL for display.
* `https://user:pass@host/path` `https://host/path`. Falls back to
* regex stripping if URL parsing fails.
*/
export function redactUrlCredentials(rawUrl: string): string {
try {
const parsed = new URL(rawUrl);
if (parsed.username || parsed.password) {
parsed.username = '';
parsed.password = '';
return parsed.toString();
}
return rawUrl;
} catch {
return rawUrl.replace(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/@\s]+@/, '$1');
}
}
export function generateUUID(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
@@ -78,6 +97,21 @@ export function formatDateTime(
return d.toLocaleString(undefined, localeOptions);
}
// Marketing emails pad the preheader with whitespace, format chars (soft
// hyphens, zero-width chars, BOM, directional marks) and combining marks
// (e.g. U+034F) to push real content past the preview window. Strip them all.
// \p{Cf} = Format, \p{Mn} = combining marks; \s covers figure space, NBSP, etc.
const LEADING_INVISIBLE_RE = /^[\s\p{Cf}\p{Mn}]+/u;
// After stripping, a server-side truncation indicator like "..." may be all
// that's left. Treat that as no preview so callers can fall back.
const ONLY_PUNCTUATION_RE = /^[.\u2026\s]+$/;
export function stripInvisibleLeading(text: string): string {
const stripped = text.replace(LEADING_INVISIBLE_RE, '');
if (ONLY_PUNCTUATION_RE.test(stripped)) return '';
return stripped;
}
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength).trim() + "...";
+364 -37
View File
@@ -48,7 +48,96 @@ function grammaticalGenderToVcardSex(gender: string): string {
}
function unfoldLines(vcf: string): string {
return vcf.replace(/\r\n[ \t]/g, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// Normalize line endings first, then unfold continuation lines (RFC 6350 §3.2).
// Continuation lines start with a single SPACE or TAB; we must handle both
// CRLF (RFC-canonical) and LF-only files (common from Unix exporters).
return vcf
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.replace(/\n[ \t]/g, "");
}
// RFC 6868 parameter value encoding — used inside parameter values only.
// Caret-encoded sequences: ^n → LF, ^^ → ^, ^' → DQUOTE.
function decodeParamValue(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "^" && i + 1 < s.length) {
const next = s[i + 1];
if (next === "n") { out += "\n"; i++; continue; }
if (next === "^") { out += "^"; i++; continue; }
if (next === "'") { out += '"'; i++; continue; }
}
out += s[i];
}
return out;
}
// Split on delim, respecting DQUOTE-quoted spans (RFC 6350 §3.3 / §5).
function splitRespectingQuotes(s: string, delim: string): string[] {
const out: string[] = [];
let buf = "";
let inQuote = false;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === '"') {
inQuote = !inQuote;
buf += ch;
continue;
}
if (ch === delim && !inQuote) {
out.push(buf);
buf = "";
continue;
}
buf += ch;
}
out.push(buf);
return out;
}
// Find the first ":" outside of a DQUOTE-quoted parameter value.
// Returns -1 when none. Needed because property params may carry quoted
// values that contain colons (e.g. ADR;LABEL="Suite 100:..." or X- params).
function findValueColon(line: string): number {
let inQuote = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') { inQuote = !inQuote; continue; }
if (ch === ":" && !inQuote) return i;
}
return -1;
}
// vCard properties may carry a group prefix: "item1.EMAIL:foo@bar".
// Strip the prefix and return the bare property name + params component.
function stripGroupPrefix(keyPart: string): string {
const dot = keyPart.indexOf(".");
if (dot < 0) return keyPart;
const before = keyPart.substring(0, dot);
// Only treat as group if the segment before the dot has no ";" (which would
// indicate it's actually a param boundary) and matches the RFC 6350 group
// grammar (ALPHA / DIGIT / "-").
if (before.includes(";")) return keyPart;
if (!/^[A-Za-z0-9-]+$/.test(before)) return keyPart;
return keyPart.substring(dot + 1);
}
// Strip URI scheme prefix from a value (e.g. "tel:+1-555" → "+1-555").
function stripUriScheme(val: string, scheme: string): string {
const prefix = `${scheme}:`;
if (val.toLowerCase().startsWith(prefix)) return val.substring(prefix.length);
return val;
}
function parsePrefParam(params: Record<string, string>): number | undefined {
if (params.PREF) {
const n = parseInt(params.PREF, 10);
if (!Number.isNaN(n)) return n;
}
// vCard 3.0 style: TYPE=PREF (no numeric value)
if (params.TYPE && /\bPREF\b/i.test(params.TYPE)) return 1;
return undefined;
}
// vCard 2.1 quoted-printable soft line breaks: a line ending in `=` continues
@@ -117,11 +206,18 @@ function encodeValue(val: string): string {
function parseParams(paramStr: string): Record<string, string> {
const params: Record<string, string> = {};
if (!paramStr) return params;
const parts = paramStr.split(";");
const parts = splitRespectingQuotes(paramStr, ";");
for (const part of parts) {
if (!part) continue;
const eq = part.indexOf("=");
if (eq > 0) {
params[part.substring(0, eq).toUpperCase()] = part.substring(eq + 1).replace(/"/g, "");
const name = part.substring(0, eq).toUpperCase();
// Strip surrounding quotes then RFC 6868 caret-decode.
// Strip surrounding DQUOTE if present (RFC 6350 §3.3). Pre-decode there
// are no literal LFs in a parameter value (those arrive as "^n" via
// RFC 6868), so we don't need the dotAll flag.
const rawVal = part.substring(eq + 1).replace(/^"(.*)"$/, "$1");
params[name] = decodeParamValue(rawVal);
} else {
const upper = part.toUpperCase();
if (upper === "QUOTED-PRINTABLE" || upper === "BASE64") {
@@ -190,9 +286,9 @@ export function parseVCard(vcfString: string): ContactCard[] {
}
if (current) {
const colonIdx = trimmed.indexOf(":");
const colonIdx = findValueColon(trimmed);
if (colonIdx < 1) continue;
const keyPart = trimmed.substring(0, colonIdx);
const keyPart = stripGroupPrefix(trimmed.substring(0, colonIdx));
const value = trimmed.substring(colonIdx + 1);
if (!current[keyPart]) current[keyPart] = [];
current[keyPart].push(value);
@@ -205,12 +301,18 @@ export function parseVCard(vcfString: string): ContactCard[] {
function buildContact(raw: Record<string, string[]>): ContactCard | null {
const id = `import-${generateUUID()}`;
const card: ContactCard = { id, addressBookIds: {} };
// Deferred BIRTHPLACE/DEATHPLACE values — attach to anniversary at end,
// because the BDAY/DEATHDATE entry may appear in any order.
let birthPlace: string | undefined;
let deathPlace: string | undefined;
for (const [fullKey, values] of Object.entries(raw)) {
const semiIdx = fullKey.indexOf(";");
const propName = (semiIdx > 0 ? fullKey.substring(0, semiIdx) : fullKey).toUpperCase();
const paramStr = semiIdx > 0 ? fullKey.substring(semiIdx + 1) : "";
// splitRespectingQuotes so a quoted param value containing ";" survives.
const segments = splitRespectingQuotes(fullKey, ";");
const propName = (segments.shift() || "").toUpperCase();
const paramStr = segments.join(";");
const params = parseParams(paramStr);
const pref = parsePrefParam(params);
const isQuotedPrintable = params.ENCODING?.toUpperCase() === "QUOTED-PRINTABLE";
@@ -257,8 +359,10 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
if (!card.emails) card.emails = {};
const idx = Object.keys(card.emails).length;
card.emails[`e${idx}`] = {
address: val,
address: stripUriScheme(val, "mailto"),
contexts: typeToContext(params.TYPE),
label: params["X-ABLABEL"] || undefined,
pref,
};
break;
}
@@ -267,9 +371,13 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
if (!card.phones) card.phones = {};
const idx = Object.keys(card.phones).length;
card.phones[`p${idx}`] = {
number: val,
// vCard 4.0 TEL is a URI value (RFC 6350 §6.4.1); strip the
// "tel:" scheme for storage as a bare number.
number: stripUriScheme(val, "tel"),
contexts: typeToContext(params.TYPE),
features: typeToPhoneFeatures(params.TYPE),
label: params["X-ABLABEL"] || undefined,
pref,
};
break;
}
@@ -295,7 +403,15 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
region: adrParts[4] || undefined,
postcode: adrParts[5] || undefined,
country: adrParts[6] || undefined,
// vCard 4.0 (RFC 9554 §3.2): CC param carries ISO country code,
// and LABEL/GEO/TZ params attach directly to the ADR.
countryCode: params.CC || undefined,
fullAddress: params.LABEL || undefined,
coordinates: params.GEO ? stripUriScheme(params.GEO, "geo") : undefined,
timeZone: params.TZ || undefined,
contexts: typeToContext(params.TYPE),
label: params["X-ABLABEL"] || undefined,
pref,
};
break;
}
@@ -318,8 +434,10 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
break;
case "KIND": {
// RFC 6350 §6.1.4 plus RFC 6473 (application).
const k = val.toLowerCase();
if (k === "group" || k === "individual" || k === "org") {
if (k === "group" || k === "individual" || k === "org" ||
k === "location" || k === "device" || k === "application") {
card.kind = k;
}
break;
@@ -336,7 +454,8 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
if (!card.media) card.media = {};
const idx = Object.keys(card.media).length;
const encoding = params.ENCODING?.toUpperCase();
const mediaType = params.TYPE || params.MEDIATYPE || "";
// vCard 4.0 uses MEDIATYPE; 3.0 reuses TYPE for the image kind.
const mediaType = params.MEDIATYPE || (params.TYPE && params.TYPE.includes("/") ? params.TYPE : (params.TYPE && /^(JPEG|JPG|PNG|GIF|WEBP|HEIC|BMP|SVG)$/i.test(params.TYPE) ? params.TYPE : "")) || "";
if (encoding === "B" || encoding === "BASE64") {
// Inline base64 photo - construct a data URI
const mime = mediaType.includes("/") ? mediaType : mediaType ? `image/${mediaType.toLowerCase()}` : "image/jpeg";
@@ -346,7 +465,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
mediaType: mime,
};
} else if (val.startsWith("data:") || val.startsWith("http://") || val.startsWith("https://")) {
// URI value (data URI or URL)
// vCard 4.0 URI value (data URI or URL) — no ENCODING param.
card.media[`m${idx}`] = {
kind: "photo",
uri: val,
@@ -376,28 +495,36 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
card.onlineServices[`u${idx}`] = {
uri: val,
contexts: typeToContext(params.TYPE),
label: params.TYPE?.toLowerCase() === "home" || params.TYPE?.toLowerCase() === "work" ? undefined : params.TYPE,
label: params["X-ABLABEL"] ||
(params.TYPE?.toLowerCase() === "home" || params.TYPE?.toLowerCase() === "work" ? undefined : params.TYPE),
pref,
};
break;
}
case "IMPP":
case "X-SOCIALPROFILE": {
case "X-SOCIALPROFILE":
case "SOCIALPROFILE": {
// RFC 9554 §3.7 introduces SOCIALPROFILE; treat the same as IMPP/X-SOCIALPROFILE.
if (!card.onlineServices) card.onlineServices = {};
const idx = Object.keys(card.onlineServices).length;
const svc: ContactOnlineService = {
uri: val,
contexts: typeToContext(params.TYPE),
pref,
};
if (params["X-SERVICE-TYPE"]) {
svc.service = params["X-SERVICE-TYPE"];
} else if (propName === "X-SOCIALPROFILE" && params.TYPE) {
} else if (params.SERVICE) {
svc.service = params.SERVICE;
} else if ((propName === "X-SOCIALPROFILE" || propName === "SOCIALPROFILE") && params.TYPE) {
const typeVal = params.TYPE.toLowerCase();
if (typeVal !== "work" && typeVal !== "home") {
svc.service = params.TYPE;
}
}
if (params["X-USER"]) svc.user = params["X-USER"];
if (params["X-ABLABEL"]) svc.label = params["X-ABLABEL"];
card.onlineServices[`u${idx}`] = svc;
break;
}
@@ -408,6 +535,14 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
break;
}
case "BIRTHPLACE": {
// RFC 6474 §2.1. Stash the location and attach to the birth
// anniversary at the end of buildContact, since BDAY may appear
// either before or after BIRTHPLACE in the vCard.
birthPlace = val;
break;
}
case "ANNIVERSARY":
case "X-ANNIVERSARY": {
if (!card.anniversaries) card.anniversaries = {};
@@ -424,6 +559,12 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
break;
}
case "DEATHPLACE": {
// RFC 6474 §2.2.
deathPlace = val;
break;
}
case "CATEGORIES": {
if (!card.keywords) card.keywords = {};
const cats = val.split(",").map(c => c.trim()).filter(Boolean);
@@ -438,6 +579,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
const idx = Object.keys(card.cryptoKeys).length;
card.cryptoKeys[`k${idx}`] = {
uri: val,
mediaType: params.MEDIATYPE || (params.TYPE && params.TYPE.includes("/") ? params.TYPE : undefined),
contexts: typeToContext(params.TYPE),
};
break;
@@ -445,9 +587,15 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
case "RELATED": {
if (!card.relatedTo) card.relatedTo = {};
const relType = params.TYPE?.toLowerCase();
// RFC 6350 §6.6.6: TYPE may be a comma-separated list (or
// multi-valued via repeated params); convert to relation map.
const relation: Record<string, boolean> = {};
if (relType) relation[relType] = true;
if (params.TYPE) {
for (const t of params.TYPE.split(",")) {
const norm = t.trim().toLowerCase();
if (norm) relation[norm] = true;
}
}
card.relatedTo[val] = { relation: Object.keys(relation).length > 0 ? relation : undefined };
break;
}
@@ -458,6 +606,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
card.preferredLanguages[`l${idx}`] = {
language: val,
contexts: typeToContext(params.TYPE),
pref,
};
break;
}
@@ -494,16 +643,22 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
}
case "GENDER": {
// vCard 4.0 §6.2.7: sex-component[;identity-component]. We map the
// sex letter to JSContact's grammaticalGender and stuff the free-
// form identity into pronouns (a coarse approximation; RFC 9554's
// PRONOUNS / GRAMGENDER, handled below, are preferred when present).
const gParts = val.split(";");
const sexCode = gParts[0]?.toUpperCase();
const identityText = gParts[1];
if (sexCode || identityText) {
card.speakToAs = {};
if (!card.speakToAs) card.speakToAs = {};
if (sexCode) {
card.speakToAs.grammaticalGender = vcardSexToGrammaticalGender(sexCode);
}
if (identityText) {
card.speakToAs.pronouns = { p0: { pronouns: identityText } };
if (!card.speakToAs.pronouns) card.speakToAs.pronouns = {};
const pkey = `p${Object.keys(card.speakToAs.pronouns).length}`;
card.speakToAs.pronouns[pkey] = { pronouns: identityText };
}
}
break;
@@ -513,7 +668,9 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
if (!card.media) card.media = {};
const idx = Object.keys(card.media).length;
const encoding = params.ENCODING?.toUpperCase();
const mediaType = params.TYPE || params.MEDIATYPE || "";
// Prefer MEDIATYPE (vCard 4.0); fall back to TYPE only when it's a
// MIME type or a known image format token (vCard 3.0 idiom).
const mediaType = params.MEDIATYPE || (params.TYPE && (params.TYPE.includes("/") || /^(JPEG|JPG|PNG|GIF|WEBP|SVG)$/i.test(params.TYPE)) ? params.TYPE : "");
if (encoding === "B" || encoding === "BASE64") {
const mime = mediaType.includes("/") ? mediaType : mediaType ? `image/${mediaType.toLowerCase()}` : "image/png";
card.media[`m${idx}`] = {
@@ -535,7 +692,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
if (!card.media) card.media = {};
const idx = Object.keys(card.media).length;
const encoding = params.ENCODING?.toUpperCase();
const mediaType = params.TYPE || params.MEDIATYPE || "";
const mediaType = params.MEDIATYPE || (params.TYPE && (params.TYPE.includes("/") || /^(OGG|MP3|WAV|AAC|FLAC)$/i.test(params.TYPE)) ? params.TYPE : "");
if (encoding === "B" || encoding === "BASE64") {
const mime = mediaType.includes("/") ? mediaType : mediaType ? `audio/${mediaType.toLowerCase()}` : "audio/ogg";
card.media[`m${idx}`] = {
@@ -581,10 +738,110 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
case "SOURCE":
card.source = val;
break;
// ---- RFC 6715 (EXPERTISE / HOBBY / INTEREST / ORG-DIRECTORY) ----
case "EXPERTISE":
case "HOBBY":
case "INTEREST": {
if (!card.personalInfo) card.personalInfo = {};
const idx = Object.keys(card.personalInfo).length;
const kind = propName.toLowerCase() as "expertise" | "hobby" | "interest";
const rawLevel = params.LEVEL?.toLowerCase();
// RFC 6715 levels: expertise uses beginner/average/expert; hobby/
// interest use high/medium/low. Normalize all into JSContact's
// high/medium/low triplet.
const levelMap: Record<string, "high" | "medium" | "low"> = {
beginner: "low", average: "medium", expert: "high",
low: "low", medium: "medium", high: "high",
};
const level = rawLevel ? levelMap[rawLevel] : undefined;
card.personalInfo[`i${idx}`] = { kind, value: val, level };
break;
}
case "ORG-DIRECTORY": {
// RFC 6715 §2.4 — directory URI for the contact's organization.
if (!card.directories) card.directories = {};
const idx = Object.keys(card.directories).length;
card.directories[`d${idx}`] = {
uri: val,
kind: "directory",
mediaType: params.MEDIATYPE || undefined,
};
break;
}
// ---- RFC 8605 (CONTACT-URI) ----
case "CONTACT-URI": {
if (!card.links) card.links = {};
const idx = Object.keys(card.links).length;
card.links[`l${idx}`] = {
uri: val,
kind: "contact",
pref,
};
break;
}
// ---- RFC 9554 vCard 4.0 extensions ----
case "CREATED":
card.created = val;
break;
case "GRAMGENDER": {
// RFC 9554 §3.4 — grammatical gender (animate/common/feminine/masculine/neuter).
if (!card.speakToAs) card.speakToAs = {};
card.speakToAs.grammaticalGender = val.toLowerCase();
break;
}
case "PRONOUNS": {
// RFC 9554 §3.5 — free-form pronouns. May appear multiple times.
if (!card.speakToAs) card.speakToAs = {};
if (!card.speakToAs.pronouns) card.speakToAs.pronouns = {};
const pkey = `p${Object.keys(card.speakToAs.pronouns).length}`;
card.speakToAs.pronouns[pkey] = {
pronouns: val,
pref,
contexts: typeToContext(params.TYPE),
};
break;
}
// Silently swallow purely structural / sync metadata properties so
// they don't appear in any catch-all default.
case "VERSION":
case "XML":
case "CLIENTPIDMAP":
case "X-ABLABEL":
break;
}
}
}
// Attach BIRTHPLACE/DEATHPLACE to the matching anniversary, creating an
// anniversary entry if no BDAY/DEATHDATE was present.
if (birthPlace || deathPlace) {
if (!card.anniversaries) card.anniversaries = {};
if (birthPlace) {
let birth = Object.values(card.anniversaries).find(a => a.kind === "birth");
if (!birth) {
card.anniversaries.a0 = { kind: "birth", date: "" };
birth = card.anniversaries.a0;
}
birth.place = { fullAddress: birthPlace };
}
if (deathPlace) {
let death = Object.values(card.anniversaries).find(a => a.kind === "death");
if (!death) {
const key = `a${Object.keys(card.anniversaries).length}`;
card.anniversaries[key] = { kind: "death", date: "" };
death = card.anniversaries[key];
}
death.place = { fullAddress: deathPlace };
}
}
const hasName = card.name && (card.name.components?.length ?? 0) > 0 || !!card.name?.full;
const hasEmail = card.emails && Object.keys(card.emails).length > 0;
if (!hasName && !hasEmail && card.kind !== "group") return null;
@@ -640,8 +897,11 @@ function generateSingleVCard(contact: ContactCard): string {
if (contact.emails) {
for (const email of Object.values(contact.emails)) {
const type = contextToType(email.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
lines.push(`EMAIL${typeParam}:${email.address}`);
const params: string[] = [];
if (type) params.push(`TYPE=${type}`);
if (email.pref) params.push(`PREF=${email.pref}`);
const paramStr = params.length > 0 ? `;${params.join(";")}` : "";
lines.push(`EMAIL${paramStr}:${email.address}`);
}
}
@@ -655,8 +915,11 @@ function generateSingleVCard(contact: ContactCard): string {
if (phone.features[feat]) typeParts.push(feat.toUpperCase());
}
}
const typeParam = typeParts.length > 0 ? `;TYPE=${typeParts.join(",")}` : "";
lines.push(`TEL${typeParam}:${phone.number}`);
const params: string[] = [];
if (typeParts.length > 0) params.push(`TYPE=${typeParts.join(",")}`);
if (phone.pref) params.push(`PREF=${phone.pref}`);
const paramStr = params.length > 0 ? `;${params.join(";")}` : "";
lines.push(`TEL${paramStr}:${phone.number}`);
}
}
@@ -681,7 +944,11 @@ function generateSingleVCard(contact: ContactCard): string {
if (contact.addresses) {
for (const addr of Object.values(contact.addresses)) {
const type = contextToType(addr.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
const adrParams: string[] = [];
if (type) adrParams.push(`TYPE=${type}`);
if (addr.countryCode) adrParams.push(`CC=${addr.countryCode}`);
if (addr.pref) adrParams.push(`PREF=${addr.pref}`);
const paramStr = adrParams.length > 0 ? `;${adrParams.join(";")}` : "";
let street = addr.street || "";
let locality = addr.locality || "";
let region = addr.region || "";
@@ -707,7 +974,7 @@ function generateSingleVCard(contact: ContactCard): string {
postcode,
country,
];
lines.push(`ADR${typeParam}:${parts.map(encodeValue).join(";")}`);
lines.push(`ADR${paramStr}:${parts.map(encodeValue).join(";")}`);
}
}
@@ -715,11 +982,17 @@ function generateSingleVCard(contact: ContactCard): string {
for (const ann of Object.values(contact.anniversaries)) {
const dateStr = anniversaryDateToVcardString(ann.date);
if (ann.kind === "birth") {
lines.push(`BDAY:${dateStr}`);
if (dateStr) lines.push(`BDAY:${dateStr}`);
if (ann.place?.fullAddress) {
lines.push(`BIRTHPLACE:${encodeValue(ann.place.fullAddress)}`);
}
} else if (ann.kind === "wedding") {
lines.push(`ANNIVERSARY:${dateStr}`);
if (dateStr) lines.push(`ANNIVERSARY:${dateStr}`);
} else if (ann.kind === "death") {
lines.push(`DEATHDATE:${dateStr}`);
if (dateStr) lines.push(`DEATHDATE:${dateStr}`);
if (ann.place?.fullAddress) {
lines.push(`DEATHPLACE:${encodeValue(ann.place.fullAddress)}`);
}
}
}
}
@@ -732,13 +1005,17 @@ function generateSingleVCard(contact: ContactCard): string {
if (svc.service) params.push(`X-SERVICE-TYPE=${svc.service}`);
const ctxType = contextToType(svc.contexts);
if (ctxType) params.push(`TYPE=${ctxType}`);
if (svc.pref) params.push(`PREF=${svc.pref}`);
const paramStr = params.length > 0 ? `;${params.join(";")}` : "";
lines.push(`IMPP${paramStr}:${svc.uri}`);
} else {
// Output as URL for plain web links
const type = contextToType(svc.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
lines.push(`URL${typeParam}:${svc.uri}`);
const params: string[] = [];
if (type) params.push(`TYPE=${type}`);
if (svc.pref) params.push(`PREF=${svc.pref}`);
const paramStr = params.length > 0 ? `;${params.join(";")}` : "";
lines.push(`URL${paramStr}:${svc.uri}`);
}
}
}
@@ -753,8 +1030,11 @@ function generateSingleVCard(contact: ContactCard): string {
if (contact.preferredLanguages) {
for (const lang of Object.values(contact.preferredLanguages)) {
const type = contextToType(lang.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
lines.push(`LANG${typeParam}:${lang.language}`);
const params: string[] = [];
if (type) params.push(`TYPE=${type}`);
if (lang.pref) params.push(`PREF=${lang.pref}`);
const paramStr = params.length > 0 ? `;${params.join(";")}` : "";
lines.push(`LANG${paramStr}:${lang.language}`);
}
}
@@ -769,8 +1049,50 @@ function generateSingleVCard(contact: ContactCard): string {
if (contact.cryptoKeys) {
for (const key of Object.values(contact.cryptoKeys)) {
const type = contextToType(key.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
lines.push(`KEY${typeParam}:${key.uri}`);
const params: string[] = [];
if (type) params.push(`TYPE=${type}`);
if (key.mediaType) params.push(`MEDIATYPE=${key.mediaType}`);
const paramStr = params.length > 0 ? `;${params.join(";")}` : "";
lines.push(`KEY${paramStr}:${key.uri}`);
}
}
if (contact.personalInfo) {
// RFC 6715 — emit EXPERTISE / HOBBY / INTEREST with LEVEL.
const levelOut: Record<string, Record<string, string>> = {
expertise: { high: "expert", medium: "average", low: "beginner" },
hobby: { high: "high", medium: "medium", low: "low" },
interest: { high: "high", medium: "medium", low: "low" },
};
for (const info of Object.values(contact.personalInfo)) {
const propMap: Record<string, string> = {
expertise: "EXPERTISE", hobby: "HOBBY", interest: "INTEREST",
};
const prop = propMap[info.kind];
if (!prop) continue;
const levelParam = info.level && levelOut[info.kind]?.[info.level]
? `;LEVEL=${levelOut[info.kind][info.level]}` : "";
lines.push(`${prop}${levelParam}:${encodeValue(info.value)}`);
}
}
if (contact.directories) {
for (const dir of Object.values(contact.directories)) {
const mt = dir.mediaType ? `;MEDIATYPE=${dir.mediaType}` : "";
lines.push(`ORG-DIRECTORY${mt}:${dir.uri}`);
}
}
if (contact.links) {
// RFC 8605 CONTACT-URI for kind=contact; everything else falls back to URL.
for (const link of Object.values(contact.links)) {
const params: string[] = [];
const type = contextToType(link.contexts);
if (type) params.push(`TYPE=${type}`);
if (link.pref) params.push(`PREF=${link.pref}`);
const paramStr = params.length > 0 ? `;${params.join(";")}` : "";
const prop = link.kind === "contact" ? "CONTACT-URI" : "URL";
lines.push(`${prop}${paramStr}:${link.uri}`);
}
}
@@ -844,6 +1166,11 @@ function generateSingleVCard(contact: ContactCard): string {
lines.push(`SOURCE:${contact.source}`);
}
if (contact.created) {
// RFC 9554 §3.1 — CREATED is a timestamp; emit as-is for round-trip.
lines.push(`CREATED:${contact.created}`);
}
lines.push("END:VCARD");
return lines.join("\r\n");
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Lenient semver comparison for the marketplace's `minAppVersion` gate.
*
* Parses "major.minor.patch" (any segment may be missing treated as 0)
* and ignores pre-release / build metadata. Returns negative, zero or
* positive in the same shape as Array.prototype.sort comparators.
*
* We intentionally do NOT pull in a full semver dependency: plugins
* declare minimum app versions as simple "X.Y.Z" strings and we only
* need a >= check.
*/
export function compareVersions(a: string, b: string): number {
const pa = parseVersion(a);
const pb = parseVersion(b);
for (let i = 0; i < 3; i++) {
if (pa[i] !== pb[i]) return pa[i] - pb[i];
}
return 0;
}
function parseVersion(v: string): [number, number, number] {
const cleaned = String(v || '').trim().replace(/^v/i, '');
// Drop pre-release / build metadata.
const core = cleaned.split(/[-+]/)[0];
const parts = core.split('.').map((p) => {
const n = parseInt(p, 10);
return Number.isFinite(n) ? n : 0;
});
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
}
/**
* True when `current` satisfies `required` (i.e. current >= required).
* Empty / null / undefined `required` is treated as no requirement.
*/
export function isVersionSatisfied(current: string, required: string | null | undefined): boolean {
if (!required) return true;
return compareVersions(current, required) >= 0;
}
+55 -17
View File
@@ -6,8 +6,21 @@
import type { IJMAPClient } from '@/lib/jmap/client-interface';
const DEVICE_CLIENT_ID_KEY = 'bulwark.push.deviceClientId.v1';
const SUBSCRIPTION_ID_KEY = 'bulwark.push.subscriptionId.v1';
// Per-account keys: a single browser may be signed in to multiple accounts,
// each with its own JMAP PushSubscription and its own relay record. Scoping
// the deviceClientId per account is what makes per-account notifications work
// at all - the relay keys subscriptions on subscriptionId (= deviceClientId),
// so a globally-shared key meant re-registering account B overwrote A.
const DEVICE_CLIENT_ID_PREFIX = 'bulwark.push.deviceClientId.v1.';
const SUBSCRIPTION_ID_PREFIX = 'bulwark.push.subscriptionId.v1.';
function deviceClientIdKey(accountId: string): string {
return DEVICE_CLIENT_ID_PREFIX + accountId;
}
function subscriptionIdKey(accountId: string): string {
return SUBSCRIPTION_ID_PREFIX + accountId;
}
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '');
const SW_SCOPE = `${BASE_PATH}/`;
@@ -79,14 +92,24 @@ function randomDeviceClientId(): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
function getOrCreateDeviceClientId(): string {
const existing = localStorage.getItem(DEVICE_CLIENT_ID_KEY);
function getOrCreateDeviceClientId(accountId: string): string {
const key = deviceClientIdKey(accountId);
const existing = localStorage.getItem(key);
if (existing) return existing;
const next = randomDeviceClientId();
localStorage.setItem(DEVICE_CLIENT_ID_KEY, next);
localStorage.setItem(key, next);
return next;
}
function anyOtherAccountHasSubscription(accountId: string): boolean {
const skip = subscriptionIdKey(accountId);
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k !== skip && k.startsWith(SUBSCRIPTION_ID_PREFIX)) return true;
}
return false;
}
// PushManager.subscribe wants the VAPID public key as a BufferSource.
// Returning a Uint8Array<ArrayBuffer> (not the wider ArrayBufferLike that
// includes SharedArrayBuffer) keeps strict TS happy on lib.dom 2024+.
@@ -260,7 +283,8 @@ export async function enableWebPush(
});
}
const deviceClientId = getOrCreateDeviceClientId();
const accountId = params.client.getAccountId();
const deviceClientId = getOrCreateDeviceClientId(accountId);
await registerWithRelay({
relayBaseUrl,
@@ -278,7 +302,8 @@ export async function enableWebPush(
// Reuse the JMAP-side PushSubscription if the server still has it, just
// refreshing the expiry so it doesn't time out between sessions.
const existingSubs = await params.client.listPushSubscriptions().catch(() => []);
const storedServerId = localStorage.getItem(SUBSCRIPTION_ID_KEY);
const subIdKey = subscriptionIdKey(accountId);
const storedServerId = localStorage.getItem(subIdKey);
if (storedServerId) {
const match = existingSubs.find((s) => s.id === storedServerId);
if (match) {
@@ -286,7 +311,7 @@ export async function enableWebPush(
if (refreshed) return { subscriptionId: storedServerId };
await params.client.destroyPushSubscription(storedServerId).catch(() => undefined);
}
localStorage.removeItem(SUBSCRIPTION_ID_KEY);
localStorage.removeItem(subIdKey);
}
// Reap any leftover subscriptions still bound to this device. These pile
@@ -309,7 +334,7 @@ export async function enableWebPush(
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
await params.client.verifyPushSubscription(serverAssignedId, verificationCode);
localStorage.setItem(SUBSCRIPTION_ID_KEY, serverAssignedId);
localStorage.setItem(subIdKey, serverAssignedId);
return { subscriptionId: serverAssignedId };
}
@@ -320,37 +345,50 @@ export interface DisableWebPushParams {
}
// Best-effort teardown: clear the JMAP subscription, the relay mapping, and
// the browser PushSubscription. Any single failure is swallowed so the user
// always ends up in a "disabled" state locally.
// (only when no other accounts still need it) the browser-wide
// PushSubscription. Any single failure is swallowed so the user always ends
// up in a "disabled" state locally.
export async function disableWebPush(params: DisableWebPushParams): Promise<void> {
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
const accountId = params.client.getAccountId();
const storedServerId = localStorage.getItem(SUBSCRIPTION_ID_KEY);
const subIdKey = subscriptionIdKey(accountId);
const devIdKey = deviceClientIdKey(accountId);
const storedServerId = localStorage.getItem(subIdKey);
if (storedServerId) {
await params.client.destroyPushSubscription(storedServerId).catch(() => undefined);
localStorage.removeItem(SUBSCRIPTION_ID_KEY);
localStorage.removeItem(subIdKey);
}
const deviceClientId = localStorage.getItem(DEVICE_CLIENT_ID_KEY);
const deviceClientId = localStorage.getItem(devIdKey);
if (deviceClientId && relayBaseUrl) {
await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
{ method: 'DELETE' },
).catch(() => undefined);
}
// Keep the deviceClientId around so a later re-enable for this account
// reuses the same relay subscriptionId rather than scattering orphans.
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
// The browser-wide PushSubscription is shared by every account on this
// origin, so only tear it down if no other account is still using it.
if (
!anyOtherAccountHasSubscription(accountId)
&& typeof navigator !== 'undefined'
&& 'serviceWorker' in navigator
) {
const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
const sub = await registration?.pushManager.getSubscription();
if (sub) await sub.unsubscribe().catch(() => undefined);
}
}
export async function isWebPushEnabled(): Promise<boolean> {
export async function isWebPushEnabled(accountId: string): Promise<boolean> {
if (!isWebPushSupported()) return false;
if (Notification.permission !== 'granted') return false;
const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
if (!registration) return false;
const sub = await registration.pushManager.getSubscription();
return sub !== null && localStorage.getItem(SUBSCRIPTION_ID_KEY) !== null;
return sub !== null && localStorage.getItem(subscriptionIdKey(accountId)) !== null;
}