@@ -862,6 +906,7 @@ export function EmailViewer({
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
+ const dragOutActive = useMemo(() => isDragOutSupported(), []);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const isFocusedMailLayout = mailLayout === 'focus';
@@ -958,8 +1003,8 @@ export function EmailViewer({
const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false);
// Plugin detail sidebar state
- const detailSlots = usePluginStore(s => s.slots['email-detail-sidebar']);
- const hasDetailSidebar = detailSlots && detailSlots.length > 0;
+ const detailSlots = usePluginSlotOffers('email-detail-sidebar');
+ const hasDetailSidebar = detailSlots.length > 0;
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(false);
const [detailSidebarWidth, setDetailSidebarWidth] = useState(280);
const detailSidebarWidthRef = useRef(280);
@@ -2327,17 +2372,24 @@ export function EmailViewer({
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
- // Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting).
- // Server-generated HTML from text/plain emails often lacks
tags, collapsing newlines.
- // Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody —
- // in that case there is no real plain-text alternative, so always render the HTML.
- const textPartId = email.textBody?.[0]?.partId;
- const htmlPartId = email.htmlBody[0].partId;
- const hasDistinctTextBody = !!textPartId && textPartId !== htmlPartId && !!email.bodyValues[textPartId];
- if (hasDistinctTextBody && htmlContent) {
- useHtmlVersion = hasMeaningfulHtmlBody(htmlContent);
+ // Per RFC 8621 § 4.1.4, when a message has only one alternative the server
+ // exposes the same part in both htmlBody and textBody. The shared part may
+ // actually be text/plain (plain-text-only mail) - rendering that as HTML
+ // collapses newlines and skips linkification, so route by the part's type.
+ const htmlPart = email.htmlBody[0];
+ if (htmlPart.type && htmlPart.type.toLowerCase() !== 'text/html') {
+ useHtmlVersion = false;
} else {
- useHtmlVersion = !!htmlContent;
+ // Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting).
+ // Server-generated HTML from text/plain emails often lacks
tags, collapsing newlines.
+ const textPartId = email.textBody?.[0]?.partId;
+ const htmlPartId = htmlPart.partId;
+ const hasDistinctTextBody = !!textPartId && textPartId !== htmlPartId && !!email.bodyValues[textPartId];
+ if (hasDistinctTextBody && htmlContent) {
+ useHtmlVersion = hasMeaningfulHtmlBody(htmlContent);
+ } else {
+ useHtmlVersion = !!htmlContent;
+ }
}
}
@@ -2470,7 +2522,7 @@ export function EmailViewer({
}
return {
- html: '
No content available
',
+ html: `
${t('no_body_content')}
`,
isHtml: false,
hasStyleTag: false,
};
@@ -2478,7 +2530,7 @@ export function EmailViewer({
// toggling permission imperatively unblocks content via restoreBlockedContent
// in an effect below, so the iframe srcDoc stays stable and doesn't reload/flash.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [email, externalContentPolicy, cidBlobUrls]);
+ }, [email, externalContentPolicy, cidBlobUrls, t]);
// Override email content with S/MIME decrypted content when available
const effectiveEmailContent = useMemo(() => {
@@ -2516,7 +2568,12 @@ export function EmailViewer({
const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => {
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
- const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
+ // Blob URLs inherit our origin; script-bearing MIME types (text/html,
+ // image/svg+xml, etc.) would execute as the webmail origin if opened
+ // top-level. Force the download path for anything not on the inert allowlist.
+ const opensPreview = isPreviewable
+ && mailAttachmentAction === 'preview'
+ && isMimeTypeSafeForInlinePreview(attachment.type);
const info: AttachmentInfo = {
name: attachment.name || '',
@@ -2715,9 +2772,14 @@ export function EmailViewer({
// Re-invert leaf media elements so they appear normal.
// Container selectors (bgcolor, background, etc.) use :not(:has(...)) to avoid
// double re-inverting images nested inside those containers.
+ // Nested bgcolor containers must NOT add another invert layer: each filter
+ // toggles the inversion, so an odd number of stacked filters (e.g. body +
+ // outer bgcolor table + inner bgcolor table) produces an inverted result -
+ // i.e. light-on-light. The second rule disables filter on bgcolor-like
+ // elements that are descendants of another bgcolor-like element.
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
- html { background: #1a1a1a; }
- body { filter: invert(1) hue-rotate(180deg); }
+ html { background: #121212; }
+ body { filter: invert(1) hue-rotate(180deg); background: #ededed; }
img, video, svg, canvas, object, embed, input[type="image"] {
filter: invert(1) hue-rotate(180deg);
}
@@ -2729,6 +2791,10 @@ export function EmailViewer({
table[background]:not(:has(img, video, svg, canvas, object, embed)) {
filter: invert(1) hue-rotate(180deg);
}
+ :where([style*="background-image"], [style*="background:"], [background], [bgcolor])
+ :where([style*="background-image"], [style*="background:"], [background], [bgcolor]):not(:has(img, video, svg, canvas, object, embed)) {
+ filter: none !important;
+ }
` : '';
const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light';
@@ -2748,10 +2814,20 @@ export function EmailViewer({
p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0 0 6px; }
` : '';
+ // Defense-in-depth CSP inside srcDoc: even if the sanitizer ever lets a
+ // ")).toBe(
+ "
<script>alert('x') & "q"</script>
"
+ );
+ });
+
+ it("normalizes line endings and preserves single line breaks", () => {
+ expect(plainTextToComposerBody("line1\r\nline2\rline3")).toBe(
+ "
line1
line2
line3
"
+ );
+ });
+
+ it("splits paragraphs on blank lines", () => {
+ expect(plainTextToComposerBody("first\n\nsecond\nthird")).toBe(
+ "
first
second
third
"
+ );
+ });
+});
diff --git a/lib/__tests__/email-sanitization.test.ts b/lib/__tests__/email-sanitization.test.ts
index f36c5b50..b2426a68 100644
--- a/lib/__tests__/email-sanitization.test.ts
+++ b/lib/__tests__/email-sanitization.test.ts
@@ -78,11 +78,67 @@ describe('email-sanitization', () => {
expect(clean).toContain('John Doe');
});
- it('should remove images from signatures', () => {
- const signature = '
John

';
+ it('should allow img with https src', () => {
+ const signature = '
John

';
const clean = sanitizeSignatureHtml(signature);
+ expect(clean).toContain('
![]()
{
+ const dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/AAAZ4gk3AAAAAXRSTlPM0jRW/QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAA1JREFUCNdjYGBgAAAABAABc7Rs9wAAAABJRU5ErkJggg==';
+ const signature = `

`;
+ const clean = sanitizeSignatureHtml(signature);
+ expect(clean).toContain('
![]()
{
+ 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(`

`);
+ expect(clean).toContain('
![]()
{
+ const signature = '

';
+ const clean = sanitizeSignatureHtml(signature);
+ expect(clean).not.toContain('http://insecure.example.com');
expect(clean).not.toContain('
![]()
{
+ const signature = '
)
';
+ const clean = sanitizeSignatureHtml(signature);
+ expect(clean).not.toContain('javascript:');
+ expect(clean).not.toContain('
![]()
{
+ const signature = '

';
+ const clean = sanitizeSignatureHtml(signature);
+ expect(clean).not.toContain('data:image/svg');
+ expect(clean).not.toContain('
![]()
{
+ const signature = '

';
+ const clean = sanitizeSignatureHtml(signature);
+ expect(clean).not.toContain('data:text/html');
+ expect(clean).not.toContain('
![]()
{
+ const signature = '

';
+ 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 = '
Text

';
+ it('should be stricter than email sanitization for script-bearing tags', () => {
+ const html = '
Text
';
const emailClean = sanitizeEmailHtml(html);
const signatureClean = sanitizeSignatureHtml(html);
- // Email allows img and table
- expect(emailClean).toContain('
![]()
');
+ expect(signatureClean).toContain('
{
+ const signature = '';
+ 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');
});
});
diff --git a/lib/__tests__/impersonation-jwt.test.ts b/lib/__tests__/impersonation-jwt.test.ts
new file mode 100644
index 00000000..9bba40fd
--- /dev/null
+++ b/lib/__tests__/impersonation-jwt.test.ts
@@ -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, secret: string = SECRET, header: Record = { 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 {
+ 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);
+ });
+});
diff --git a/lib/__tests__/jmap-send-threading.test.ts b/lib/__tests__/jmap-send-threading.test.ts
index 6c6b06ec..342990b2 100644
--- a/lib/__tests__/jmap-send-threading.test.ts
+++ b/lib/__tests__/jmap-send-threading.test.ts
@@ -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>;
+ 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();
diff --git a/lib/__tests__/oauth-discovery.test.ts b/lib/__tests__/oauth-discovery.test.ts
index a330377d..609a9294 100644
--- a/lib/__tests__/oauth-discovery.test.ts
+++ b/lib/__tests__/oauth-discovery.test.ts
@@ -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);
diff --git a/lib/__tests__/plugin-api.test.ts b/lib/__tests__/plugin-api.test.ts
deleted file mode 100644
index 74ee7d90..00000000
--- a/lib/__tests__/plugin-api.test.ts
+++ /dev/null
@@ -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 {
- 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' });
- });
-});
diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts
index 72105892..80dad0bd 100644
--- a/lib/__tests__/plugin-store.test.ts
+++ b/lib/__tests__/plugin-store.test.ts
@@ -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()] });
diff --git a/lib/__tests__/protocol-handlers.test.ts b/lib/__tests__/protocol-handlers.test.ts
new file mode 100644
index 00000000..ba83f2bc
--- /dev/null
+++ b/lib/__tests__/protocol-handlers.test.ts
@@ -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();
+ });
+ });
+});
diff --git a/lib/__tests__/reply-identity.test.ts b/lib/__tests__/reply-identity.test.ts
index 532571de..4cb3bdf8 100644
--- a/lib/__tests__/reply-identity.test.ts
+++ b/lib/__tests__/reply-identity.test.ts
@@ -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();
+ });
});
\ No newline at end of file
diff --git a/lib/__tests__/vcard.test.ts b/lib/__tests__/vcard.test.ts
index 42fe627c..bc502023 100644
--- a/lib/__tests__/vcard.test.ts
+++ b/lib/__tests__/vcard.test.ts
@@ -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[] = [
diff --git a/lib/__tests__/version-compare.test.ts b/lib/__tests__/version-compare.test.ts
new file mode 100644
index 00000000..8d937398
--- /dev/null
+++ b/lib/__tests__/version-compare.test.ts
@@ -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);
+ });
+});
diff --git a/lib/account-utils.ts b/lib/account-utils.ts
index b9e95cf6..ea63020f 100644
--- a/lib/account-utils.ts
+++ b/lib/account-utils.ts
@@ -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;
diff --git a/lib/admin/audit.ts b/lib/admin/audit.ts
index 9b3a6ded..0c0bb73e 100644
--- a/lib/admin/audit.ts
+++ b/lib/admin/audit.ts
@@ -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, ip: string): Promise {
- 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 {
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 };
diff --git a/lib/admin/config-manager.ts b/lib/admin/config-manager.ts
index fa7df72c..1e3e278d 100644
--- a/lib/admin/config-manager.ts
+++ b/lib/admin/config-manager.ts
@@ -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): Promise {
+ 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 {
+ 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 {
+ 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 {
+ assertWritable('update settings policy');
this.policyCache = {
...DEFAULT_POLICY,
...policy,
@@ -167,7 +183,7 @@ class ConfigManager {
}
private async readJsonFile(filename: string): Promise | 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): Promise {
- 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());
diff --git a/lib/admin/csp-frame-origins.ts b/lib/admin/csp-frame-origins.ts
index 4b6f69af..ebbfe9ad 100644
--- a/lib/admin/csp-frame-origins.ts
+++ b/lib/admin/csp-frame-origins.ts
@@ -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();
+ 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
diff --git a/lib/admin/migrate.ts b/lib/admin/migrate.ts
new file mode 100644
index 00000000..39d7b2be
--- /dev/null
+++ b/lib/admin/migrate.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 };
+}
diff --git a/lib/admin/password.ts b/lib/admin/password.ts
index d1043df4..4f4fd7d7 100644
--- a/lib/admin/password.ts
+++ b/lib/admin/password.ts
@@ -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 {
return new Promise((resolve, reject) => {
@@ -33,10 +33,8 @@ function hashPassword(password: string): Promise {
function verifyPassword(password: string, stored: string): Promise {
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 {
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 {
- const filePath = getAdminJsonPath();
+// ─── Disk I/O ───────────────────────────────────────────────────────────────
+
+async function readJson(filePath: string): Promise {
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 {
- 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 {
+ return readJson(getConfigPath(ADMIN_CONFIG_FILE));
}
-let cachedAdminData: AdminData | null = null;
+async function readStateData(): Promise {
+ return readJson(getStatePath(ADMIN_STATE_FILE));
+}
+
+async function writeConfigData(data: AdminConfigData): Promise {
+ 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 {
+ 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 {
- 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 {
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 {
* Verify a password against the stored admin hash.
*/
export async function verifyAdminPassword(password: string): Promise {
- 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 {
+ 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 {
- 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();
}
diff --git a/lib/admin/paths.ts b/lib/admin/paths.ts
new file mode 100644
index 00000000..b791ecaa
--- /dev/null
+++ b/lib/admin/paths.ts
@@ -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. /data/admin
+ *
+ * getStateDir()
+ * 1. ADMIN_STATE_DIR
+ * 2. /state - if config dir was set explicitly
+ * 3. /state - back-compat: stays on the legacy volume
+ * 4. /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 {
+ const dir = getConfigDir();
+ if (!existsSync(dir)) {
+ await mkdir(dir, { recursive: true });
+ }
+}
+
+export async function ensureStateDir(): Promise {
+ 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 {
+ 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);
+}
diff --git a/lib/admin/plugin-approvals.ts b/lib/admin/plugin-approvals.ts
new file mode 100644
index 00000000..98e736b3
--- /dev/null
+++ b/lib/admin/plugin-approvals.ts
@@ -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 | null = null;
+
+async function loadFromDisk(): Promise {
+ 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 {
+ if (cached !== null) return;
+ if (!loadPromise) {
+ loadPromise = (async () => { cached = await loadFromDisk(); })();
+ }
+ await loadPromise;
+}
+
+async function flushToDisk(): Promise {
+ 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;
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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;
+}
diff --git a/lib/admin/plugin-config.ts b/lib/admin/plugin-config.ts
index bd2d8cfc..68c7e68d 100644
--- a/lib/admin/plugin-config.ts
+++ b/lib/admin/plugin-config.ts
@@ -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 {
+ 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 {
+ 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 {
+ assertWritable('delete plugin config');
try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ }
}
diff --git a/lib/admin/plugin-dev.ts b/lib/admin/plugin-dev.ts
index d00b7178..079182ef 100644
--- a/lib/admin/plugin-dev.ts
+++ b/lib/admin/plugin-dev.ts
@@ -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 {
+ 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 {
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
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
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
: {}),
...(frameOrigins.length > 0 ? { frameOrigins } : {}),
...(httpOrigins.length > 0 ? { httpOrigins } : {}),
+ ...(apiPostPaths.length > 0 ? { apiPostPaths } : {}),
installedAt,
updatedAt: new Date().toISOString(),
bundleHash,
diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts
index e700eece..e1754f87 100644
--- a/lib/admin/plugin-registry.ts
+++ b/lib/admin/plugin-registry.ts
@@ -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 {
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 {
+ 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 {
- return readJsonFile(pluginRegistryPath(), { plugins: [] });
+ const registry = await readJsonFile(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 {
@@ -137,10 +172,16 @@ export async function getPlugin(id: string): Promise {
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 {
+ 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>): Promise {
+ 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 {
+ 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 {
+ 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>): Promise {
+ 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 {
+ assertWritable('delete theme');
const registry = await getThemeRegistry();
const idx = registry.themes.findIndex(t => t.id === id);
if (idx < 0) return false;
diff --git a/lib/admin/plugin-signing.ts b/lib/admin/plugin-signing.ts
new file mode 100644
index 00000000..9f5f2ff7
--- /dev/null
+++ b/lib/admin/plugin-signing.ts
@@ -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 | 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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;
+}
diff --git a/lib/admin/session.ts b/lib/admin/session.ts
index ecde855e..4f1a73d5 100644
--- a/lib/admin/session.ts
+++ b/lib/admin/session.ts
@@ -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;
diff --git a/lib/admin/types.ts b/lib/admin/types.ts
index 76fd247e..98f994a8 100644
--- a/lib/admin/types.ts
+++ b/lib/admin/types.ts
@@ -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('sessionSecret', '');
+ return fromAdmin || '';
+}
+
+export function hasSessionSecret(): boolean {
+ return getSessionSecret().length > 0;
+}
diff --git a/lib/auth/verify-jmap-auth.ts b/lib/auth/verify-jmap-auth.ts
index 1d2d1616..06a55708 100644
--- a/lib/auth/verify-jmap-auth.ts
+++ b/lib/auth/verify-jmap-auth.ts
@@ -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,
diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts
index f2199e44..23a21e94 100644
--- a/lib/demo/demo-client.ts
+++ b/lib/demo/demo-client.ts
@@ -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 {
// 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 {
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 {
+ async updateIdentity(identityId: string, updates: { name?: string | null; replyTo?: EmailAddress[] | null; bcc?: EmailAddress[] | null; textSignature?: string | null; htmlSignature?: string | null }): Promise {
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 {
diff --git a/lib/demo/fixtures/contacts.ts b/lib/demo/fixtures/contacts.ts
index b227e78c..145ab4b6 100644
--- a/lib/demo/fixtures/contacts.ts
+++ b/lib/demo/fixtures/contacts.ts
@@ -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),
},
];
}
diff --git a/lib/demo/fixtures/emails.ts b/lib/demo/fixtures/emails.ts
index 5a8771f6..fd5b7cfe 100644
--- a/lib/demo/fixtures/emails.ts
+++ b/lib/demo/fixtures/emails.ts
@@ -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: 'Welcome to Bulwark Mail!
Thanks for trying out Bulwark Mail!
This is a demo environment where you can explore all features without connecting to a real server. All data stays on your device.
Feel free to:
- Read, compose, and organize emails
- Manage contacts and calendars
- Configure filters and settings
- Try keyboard shortcuts (press ? to see them)
Enjoy exploring!
' },
- },
+ ...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!',
+ 'Welcome to Bulwark Mail!
Thanks for trying out Bulwark Mail!
This is a demo environment where you can explore all features without connecting to a real server. All data stays on your device.
Feel free to:
- Read, compose, and organize emails
- Manage contacts and calendars
- Configure filters and settings
- Try keyboard shortcuts (press ? to see them)
Enjoy exploring!
',
+ ),
messageId: '',
},
+
+ // 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.',
+ '| @demo-user requested your review on this pull request. |
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 rate-limit.toml. |
| Three files changed, +312 −47 |
| View on GitHub |
',
+ ),
+ messageId: '',
+ },
+
+ // 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: 'This Week in Tech
1. AI-Powered Code Review Tools
New tools are making code reviews faster and more thorough, with several open-source options gaining traction.
2. Open Source Licensing Update
The OSI has published new guidelines for AI-generated code contributions to open source projects.
3. WebAssembly 2.0 Draft
The W3C has released the first draft of WebAssembly 2.0, promising improved memory management.
' },
- },
- messageId: '',
+ ...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.',
+ 'TechDigest · Issue #218
RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla
The week in standards
1. 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.
2. 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.
3. 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.
Tools
- Datasette 1.0 is out. Ten years from the first commit.
- Fly.io published their object store, Tigris-style, written in Go.
- Linear added an SSO migration tool that actually handles the IdP-initiated case.
Essays
"Postgres is enough" by E. Tan - a long-form rebuttal to the microservices-by-default pattern.
"I rewrote my home network in TypeScript so you don\'t have to" - exactly what it sounds like.
',
+ ),
+ messageId: '',
},
- // 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: 'Hi team,
I wanted to share the updated timeline for our Q4 deliverables:
- Phase 1: Design review - Oct 15
- Phase 2: Development - Nov 1-30
- Phase 3: Testing - Dec 1-15
- Phase 4: Launch - Dec 20
Please review and let me know if you see any conflicts.
Best,
Alice
' },
- },
+ ...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',
+ 'Hi team,
I wanted to share the updated timeline for our Q4 deliverables:
- Phase 1: Design review - Oct 15
- Phase 2: Development - Nov 1-30
- Phase 3: Testing - Dec 1-15
- Phase 4: Launch - Dec 20
Please review and let me know if you see any conflicts.
Best,
Alice
',
+ ),
messageId: '',
},
{
@@ -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: '',
inReplyTo: [''],
references: [''],
@@ -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: '',
inReplyTo: [''],
references: ['', ''],
},
- // 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.',
+ '| Amount | $16.00 |
| Payment method | Visa •••• 4242 |
| Receipt number | 2451-9928 |
Description: Linear Standard (monthly). This charge will appear on your statement as LINEAR INC.
',
+ ),
+ messageId: '',
+ },
+
+ // 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: '',
},
- // 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: '',
},
+ // 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",
+ 'Linear · BUL-2031 Compose: drag-and-drop attachments duplicated on slow networks Priya Sharma assigned this issue to you · Priority Medium |
| 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. |
| Open in Linear |
',
+ ),
+ messageId: '',
+ },
+
+ // 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: '',
+ },
+
+ // 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: '',
+ },
+
+ // 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: '',
+ },
+
+ // 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: '',
+ },
+
+ // 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: '',
+ },
+
+ // 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:00–18: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: '',
+ },
+
+ // 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: '',
+ },
+
+ // 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: '',
+ },
+
+ // 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 22–25)',
+ 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: '',
+ },
+
+ // 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: '',
+ },
+
+ // 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: '',
+ },
+
+ // 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: '',
+ },
+
// ── 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: '',
},
{
@@ -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: '',
},
+ {
+ 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: '',
+ 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: '',
},
+ {
+ 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: '',
+ },
// ── 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: '',
},
{
@@ -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: '',
},
@@ -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: '',
},
{
@@ -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: '',
},
+ {
+ 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: '',
+ },
// ── 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: '',
},
+ {
+ 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: '',
+ },
// ── 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: '',
+ },
+ {
+ 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: '',
+ ...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: '',
},
// ── 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: '',
},
+ {
+ 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: '',
+ },
+ {
+ 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: '',
+ },
];
}
diff --git a/lib/demo/fixtures/mailboxes.ts b/lib/demo/fixtures/mailboxes.ts
index dd11a92d..328d9418 100644
--- a/lib/demo/fixtures/mailboxes.ts
+++ b/lib/demo/fixtures/mailboxes.ts
@@ -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 },
];
}
diff --git a/lib/email-composer-utils.ts b/lib/email-composer-utils.ts
new file mode 100644
index 00000000..8141cc42
--- /dev/null
+++ b/lib/email-composer-utils.ts
@@ -0,0 +1,23 @@
+const HTML_ESCAPE_MAP = {
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'",
+} 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) => `${escapeHtml(paragraph).replace(/\n/g, "
")}
`)
+ .join("");
+}
diff --git a/lib/email-sanitization.ts b/lib/email-sanitization.ts
index 60c8076e..f1452c98 100644
--- a/lib/email-sanitization.ts
+++ b/lib/email-sanitization.ts
@@ -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