Merge branch 'main' into feature/scheduled-send

This commit is contained in:
Lucas Gaitzsch
2026-05-20 08:17:46 +02:00
244 changed files with 20839 additions and 3479 deletions
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { plainTextToComposerBody } from "../email-composer-utils";
describe("plainTextToComposerBody", () => {
it("returns an empty string for empty input", () => {
expect(plainTextToComposerBody("")).toBe("");
});
it("escapes HTML before building composer paragraphs", () => {
expect(plainTextToComposerBody("<script>alert('x') & \"q\"</script>")).toBe(
"<p>&lt;script&gt;alert(&#39;x&#39;) &amp; &quot;q&quot;&lt;/script&gt;</p>"
);
});
it("normalizes line endings and preserves single line breaks", () => {
expect(plainTextToComposerBody("line1\r\nline2\rline3")).toBe(
"<p>line1<br>line2<br>line3</p>"
);
});
it("splits paragraphs on blank lines", () => {
expect(plainTextToComposerBody("first\n\nsecond\nthird")).toBe(
"<p>first</p><p>second<br>third</p>"
);
});
});
+79 -9
View File
@@ -78,11 +78,67 @@ describe('email-sanitization', () => {
expect(clean).toContain('John Doe');
});
it('should remove images from signatures', () => {
const signature = '<p>John</p><img src="logo.png" alt="Logo">';
it('should allow img with https src', () => {
const signature = '<p>John</p><img src="https://cdn.example.com/logo.png" alt="Logo" width="120" height="40">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).toContain('<img');
expect(clean).toContain('src="https://cdn.example.com/logo.png"');
expect(clean).toContain('alt="Logo"');
expect(clean).toContain('width="120"');
expect(clean).toContain('height="40"');
});
it('should allow img with data:image/png;base64 src', () => {
const dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/AAAZ4gk3AAAAAXRSTlPM0jRW/QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAA1JREFUCNdjYGBgAAAABAABc7Rs9wAAAABJRU5ErkJggg==';
const signature = `<img src="${dataUri}" alt="Logo">`;
const clean = sanitizeSignatureHtml(signature);
expect(clean).toContain('<img');
expect(clean).toContain('data:image/png;base64,');
});
it('should allow img with data:image/jpeg, gif, webp', () => {
const cases = ['data:image/jpeg;base64,AAA', 'data:image/jpg;base64,AAA', 'data:image/gif;base64,AAA', 'data:image/webp;base64,AAA'];
for (const src of cases) {
const clean = sanitizeSignatureHtml(`<img src="${src}" alt="x">`);
expect(clean).toContain('<img');
expect(clean).toContain(src);
}
});
it('should strip img with http: src (https only)', () => {
const signature = '<img src="http://insecure.example.com/logo.png" alt="Logo">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('http://insecure.example.com');
expect(clean).not.toContain('<img');
expect(clean).toContain('John');
});
it('should strip img with javascript: src', () => {
const signature = '<img src="javascript:alert(1)" alt="x">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('javascript:');
expect(clean).not.toContain('<img');
});
it('should strip img with data:image/svg+xml src (SVG forbidden)', () => {
const signature = '<img src="data:image/svg+xml;base64,PHN2Zy8+" alt="x">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('data:image/svg');
expect(clean).not.toContain('<img');
});
it('should strip img with non-image data: URI', () => {
const signature = '<img src="data:text/html;base64,PHA+aGk8L3A+" alt="x">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('data:text/html');
expect(clean).not.toContain('<img');
});
it('should strip event handlers on img', () => {
const signature = '<img src="https://cdn.example.com/logo.png" alt="x" onerror="alert(1)" onload="alert(2)">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('onerror');
expect(clean).not.toContain('onload');
expect(clean).toContain('https://cdn.example.com/logo.png');
});
it('should remove video and audio tags', () => {
@@ -112,17 +168,31 @@ describe('email-sanitization', () => {
expect(sanitizeSignatureHtml(' ')).toBe('');
});
it('should be stricter than email sanitization', () => {
const html = '<p>Text</p><img src="pic.jpg"><table><tr><td>Data</td></tr></table>';
it('should be stricter than email sanitization for script-bearing tags', () => {
const html = '<p>Text</p><table><tr><td>Data</td></tr></table><video src="v.mp4"></video><iframe src="x"></iframe>';
const emailClean = sanitizeEmailHtml(html);
const signatureClean = sanitizeSignatureHtml(html);
// Email allows img and table
expect(emailClean).toContain('<img');
// Both preserve tables (signatures are universally table-based)
expect(emailClean).toContain('<table>');
expect(signatureClean).toContain('<table');
expect(signatureClean).toContain('Data');
// Signature blocks img but may allow some tables (verify in implementation)
expect(signatureClean).not.toContain('<img');
// Signature still blocks media and frames
expect(signatureClean).not.toContain('<video');
expect(signatureClean).not.toContain('<iframe');
expect(signatureClean).toContain('Text');
});
it('should preserve table layout attributes used by email signatures', () => {
const signature = '<table cellpadding="0" cellspacing="0" border="0"><tr><td valign="top" align="left" bgcolor="#fafafa" colspan="2">Name</td></tr></table>';
const clean = sanitizeSignatureHtml(signature);
expect(clean).toContain('cellpadding');
expect(clean).toContain('cellspacing');
expect(clean).toContain('valign');
expect(clean).toContain('align');
expect(clean).toContain('bgcolor');
expect(clean).toContain('colspan');
});
});
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { createHmac } from 'node:crypto';
import {
ImpersonationJwtError,
verifyImpersonationJwt,
impersonationReplayCache,
} from '@/lib/impersonation/jwt';
const SECRET = 'a'.repeat(64);
const ISSUER = 'platform-api/webmail';
function base64Url(input: Buffer | string): string {
return Buffer.from(input)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
function sign(payload: Record<string, unknown>, secret: string = SECRET, header: Record<string, unknown> = { alg: 'HS256', typ: 'JWT' }): string {
const h = base64Url(JSON.stringify(header));
const p = base64Url(JSON.stringify(payload));
const sig = createHmac('sha256', secret).update(`${h}.${p}`).digest();
return `${h}.${p}.${base64Url(sig)}`;
}
function basePayload(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
const now = Math.floor(Date.now() / 1000);
return {
iss: ISSUER,
iat: now,
exp: now + 120,
jti: 'jti-' + Math.random().toString(36).slice(2),
mailbox: 'alice@example.test',
...overrides,
};
}
describe('verifyImpersonationJwt', () => {
beforeEach(() => {
impersonationReplayCache.clear();
});
it('accepts a valid HS256 token', () => {
const token = sign(basePayload());
const claims = verifyImpersonationJwt(token, SECRET, { expectedIssuer: ISSUER });
expect(claims.mailbox).toBe('alice@example.test');
});
it('rejects non-HS256 algorithms', () => {
const header = { alg: 'none', typ: 'JWT' };
const h = base64Url(JSON.stringify(header));
const p = base64Url(JSON.stringify(basePayload()));
const token = `${h}.${p}.`;
expect(() => verifyImpersonationJwt(token, SECRET)).toThrow(ImpersonationJwtError);
});
it('rejects tokens with a forged signature', () => {
const token = sign(basePayload(), 'a-different-secret-that-is-also-long-enough-32');
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/signature/i);
});
it('rejects when secret is too short', () => {
const token = sign(basePayload());
expect(() => verifyImpersonationJwt(token, 'short')).toThrowError(/32 characters/);
});
it('rejects expired tokens', () => {
const now = Math.floor(Date.now() / 1000);
const token = sign(basePayload({ iat: now - 600, exp: now - 300 }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/expired/i);
});
it('rejects tokens with lifetime over the 300s ceiling', () => {
const now = Math.floor(Date.now() / 1000);
const token = sign(basePayload({ iat: now, exp: now + 3600 }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/lifetime/i);
});
it('rejects tokens with iss mismatch when expectedIssuer is set', () => {
const token = sign(basePayload({ iss: 'someone-else' }));
expect(() =>
verifyImpersonationJwt(token, SECRET, { expectedIssuer: ISSUER }),
).toThrowError(/issuer/i);
});
it("rejects mailbox containing '%'", () => {
const token = sign(basePayload({ mailbox: 'a%b@example.test' }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/'%'/);
});
it("rejects mailbox containing ':'", () => {
const token = sign(basePayload({ mailbox: 'a:b@example.test' }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/':'/);
});
it('rejects malformed tokens', () => {
expect(() => verifyImpersonationJwt('not.a.jwt.extra', SECRET)).toThrow();
expect(() => verifyImpersonationJwt('', SECRET)).toThrow();
});
it('honours nbf with skew', () => {
const now = Math.floor(Date.now() / 1000);
const token = sign(basePayload({ nbf: now + 600 }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/not yet valid/i);
});
});
describe('impersonationReplayCache', () => {
beforeEach(() => {
impersonationReplayCache.clear();
});
it('accepts a jti once and rejects it on second use', () => {
const now = Math.floor(Date.now() / 1000);
expect(impersonationReplayCache.consume('jti-1', now + 60, now)).toBe(true);
expect(impersonationReplayCache.consume('jti-1', now + 60, now)).toBe(false);
});
it('prunes expired jtis on next consume', () => {
const now = Math.floor(Date.now() / 1000);
impersonationReplayCache.consume('jti-old', now - 600, now - 600);
// Far in the future — pruning should clear the old entry.
expect(impersonationReplayCache.consume('jti-new', now + 60, now + 1000)).toBe(true);
// Re-using the old jti is allowed after pruning (security irrelevant since
// the token would fail signature/exp validation upstream).
expect(impersonationReplayCache.consume('jti-old', now + 60, now + 1000)).toBe(true);
});
});
+19
View File
@@ -155,6 +155,25 @@ describe('JMAPClient.sendEmail threading headers', () => {
expect(draft.references).toBeUndefined();
});
it('omits cc/bcc when arrays are empty so the server does not emit a bare Cc: header', async () => {
const client = createClient();
const captured = mockSendEmailFlow();
await client.sendEmail(
['recipient@example.com'],
'No copies',
'body',
[], [], 'identity-1', 'user@example.com',
);
const setCall = captured[2].methodCalls[0];
const create = setCall[1].create as Record<string, Record<string, unknown>>;
const draft = Object.values(create)[0];
expect(draft.cc).toBeUndefined();
expect(draft.bcc).toBeUndefined();
});
it('drops empty / whitespace-only ids rather than sending blank entries', async () => {
const client = createClient();
const captured = mockSendEmailFlow();
+61 -7
View File
@@ -1,6 +1,21 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { OAuthMetadata } from '../oauth/discovery';
const validateEndpoint = async (urlString: string) => {
try {
const url = new URL(urlString);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
if (url.username || url.password) return false;
const host = url.hostname.toLowerCase();
if (host === 'localhost' || host.endsWith('.local') || host.endsWith('.internal')) return false;
if (/^(127\.|169\.254\.|10\.|192\.168\.)/.test(host)) return false;
if (host === '::1' || host === '0.0.0.0') return false;
return true;
} catch {
return false;
}
};
const VALID_METADATA: OAuthMetadata = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
@@ -26,7 +41,7 @@ describe('oauth/discovery', () => {
json: () => Promise.resolve(VALID_METADATA),
}));
const result = await discoverOAuth('https://mail.example.com');
const result = await discoverOAuth('https://mail.example.com', { validateEndpoint });
expect(result).toEqual(VALID_METADATA);
expect(fetch).toHaveBeenCalledTimes(1);
@@ -43,7 +58,7 @@ describe('oauth/discovery', () => {
json: () => Promise.resolve(VALID_METADATA),
}));
const result = await discoverOAuth('https://fallback.example.com');
const result = await discoverOAuth('https://fallback.example.com', { validateEndpoint });
expect(result).toEqual(VALID_METADATA);
expect(fetch).toHaveBeenCalledTimes(2);
@@ -59,7 +74,7 @@ describe('oauth/discovery', () => {
.mockResolvedValueOnce({ ok: false, status: 404 })
.mockResolvedValueOnce({ ok: false, status: 404 }));
const result = await discoverOAuth('https://fail.example.com');
const result = await discoverOAuth('https://fail.example.com', { validateEndpoint });
expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
@@ -71,7 +86,7 @@ describe('oauth/discovery', () => {
json: () => Promise.resolve(VALID_METADATA),
}));
const result = await discoverOAuth('https://optional.example.com');
const result = await discoverOAuth('https://optional.example.com', { validateEndpoint });
expect(result?.revocation_endpoint).toBe('https://auth.example.com/revoke');
expect(result?.end_session_endpoint).toBe('https://auth.example.com/logout');
@@ -86,7 +101,46 @@ describe('oauth/discovery', () => {
})
.mockResolvedValueOnce({ ok: false, status: 404 }));
const result = await discoverOAuth('https://incomplete.example.com');
const result = await discoverOAuth('https://incomplete.example.com', { validateEndpoint });
expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
});
it('rejects metadata pointing at loopback / link-local hosts (SSRF guard)', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
issuer: 'https://evil.example.com',
authorization_endpoint: 'https://evil.example.com/authorize',
token_endpoint: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/',
}),
})
.mockResolvedValueOnce({ ok: false, status: 404 }));
const result = await discoverOAuth('https://evil.example.com', { validateEndpoint });
expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
});
it('rejects metadata pointing at private RFC1918 hosts (SSRF guard)', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
issuer: 'https://evil.example.com',
authorization_endpoint: 'https://evil.example.com/authorize',
token_endpoint: 'https://evil.example.com/token',
revocation_endpoint: 'http://127.0.0.1:9200/_cluster/state',
}),
})
.mockResolvedValueOnce({ ok: false, status: 404 }));
const result = await discoverOAuth('https://private-revoke.example.com', { validateEndpoint });
expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
@@ -98,8 +152,8 @@ describe('oauth/discovery', () => {
json: () => Promise.resolve(VALID_METADATA),
}));
const first = await discoverOAuth('https://cached.example.com');
const second = await discoverOAuth('https://cached.example.com');
const first = await discoverOAuth('https://cached.example.com', { validateEndpoint });
const second = await discoverOAuth('https://cached.example.com', { validateEndpoint });
expect(first).toEqual(VALID_METADATA);
expect(second).toEqual(VALID_METADATA);
-193
View File
@@ -1,193 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createPluginAPI, setSlotRegistrationBridge } from '../plugin-api';
import type { InstalledPlugin } from '../plugin-types';
import { clearAllHooks } from '../plugin-hooks';
function makePlugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
return {
id: 'test-plugin',
name: 'Test Plugin',
version: '1.0.0',
author: 'Test',
description: '',
type: 'ui-extension',
entrypoint: 'index.js',
permissions: [],
enabled: true,
status: 'running',
settings: {},
...overrides,
};
}
beforeEach(() => {
clearAllHooks();
localStorage.clear();
setSlotRegistrationBridge(null);
});
describe('createPluginAPI', () => {
it('exposes plugin info', () => {
const plugin = makePlugin();
const api = createPluginAPI(plugin);
expect(api.plugin.id).toBe('test-plugin');
expect(api.plugin.version).toBe('1.0.0');
});
it('returns a frozen copy of settings', () => {
const plugin = makePlugin({ settings: { key: 'val' } });
const api = createPluginAPI(plugin);
expect(api.plugin.settings).toEqual({ key: 'val' });
});
});
describe('plugin storage (scoped localStorage)', () => {
it('set and get a value', () => {
const api = createPluginAPI(makePlugin());
api.storage.set('foo', 42);
expect(api.storage.get('foo')).toBe(42);
});
it('scopes to plugin id', () => {
const api1 = createPluginAPI(makePlugin({ id: 'p1' }));
const api2 = createPluginAPI(makePlugin({ id: 'p2' }));
api1.storage.set('key', 'a');
api2.storage.set('key', 'b');
expect(api1.storage.get('key')).toBe('a');
expect(api2.storage.get('key')).toBe('b');
});
it('remove deletes a value', () => {
const api = createPluginAPI(makePlugin());
api.storage.set('x', 10);
api.storage.remove('x');
expect(api.storage.get('x')).toBeNull();
});
it('keys lists only plugin-scoped keys', () => {
const api = createPluginAPI(makePlugin({ id: 'kp' }));
api.storage.set('a', 1);
api.storage.set('b', 2);
localStorage.setItem('unrelated', 'val');
expect(api.storage.keys()).toContain('a');
expect(api.storage.keys()).toContain('b');
expect(api.storage.keys()).not.toContain('unrelated');
});
});
describe('plugin logger', () => {
it('prefixes log messages with plugin id', () => {
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
const api = createPluginAPI(makePlugin({ id: 'log-test' }));
api.log.info('hello');
expect(infoSpy).toHaveBeenCalledWith('[plugin:log-test]', 'hello');
infoSpy.mockRestore();
});
});
describe('hooks permission gating', () => {
it('returns no-op disposable without permission', () => {
const plugin = makePlugin({ permissions: [] }); // no email:read
const api = createPluginAPI(plugin);
const d = api.hooks.onEmailOpen(vi.fn());
expect(d).toBeDefined();
expect(d.dispose).toBeInstanceOf(Function);
});
it('registers handler when permission is granted', () => {
const plugin = makePlugin({ permissions: ['email:read'] });
const api = createPluginAPI(plugin);
const fn = vi.fn();
const d = api.hooks.onEmailOpen(fn);
expect(d).toBeDefined();
d.dispose(); // should not throw
});
});
describe('ui permission requirement', () => {
it('throws without ui:toolbar permission', () => {
const plugin = makePlugin({ permissions: [] });
const api = createPluginAPI(plugin);
expect(() => api.ui.registerToolbarAction({
id: 'test',
label: 'Test',
onClick: () => {},
})).toThrow('lacks permission');
});
it('does not throw with correct permission (slot bridge not set, returns no-op)', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const plugin = makePlugin({ permissions: ['ui:toolbar'] });
const api = createPluginAPI(plugin);
const d = api.ui.registerToolbarAction({ id: 'test', label: 'Test', onClick: () => {} });
expect(d.dispose).toBeInstanceOf(Function);
consoleSpy.mockRestore();
});
});
describe('slot registration bridge', () => {
it('calls bridge when set', () => {
const bridge = vi.fn((_name, _reg) => ({ dispose: () => {} }));
setSlotRegistrationBridge(bridge);
const plugin = makePlugin({ permissions: ['ui:email-footer'] });
const api = createPluginAPI(plugin);
const DummyComponent = () => null;
api.ui.registerEmailFooter(DummyComponent);
expect(bridge).toHaveBeenCalled();
});
});
describe('toast bridge', () => {
it('exposes success/error/info/warning methods', () => {
const plugin = makePlugin();
const api = createPluginAPI(plugin);
expect(api.toast.success).toBeInstanceOf(Function);
expect(api.toast.error).toBeInstanceOf(Function);
expect(api.toast.info).toBeInstanceOf(Function);
expect(api.toast.warning).toBeInstanceOf(Function);
});
});
describe('http.post path validation', () => {
function makeApi(permissions: string[] = ['http:post']) {
return createPluginAPI(makePlugin({ permissions }));
}
it('rejects protocol-relative URLs like //evil.example', async () => {
const api = makeApi();
await expect(api.http.post('//evil.example/collect', {})).rejects.toThrow('must start with /api/');
});
it('rejects absolute URLs to other origins', async () => {
const api = makeApi();
await expect(api.http.post('https://evil.example/steal', {})).rejects.toThrow('must start with /api/');
});
it('rejects paths not under /api/', async () => {
const api = makeApi();
await expect(api.http.post('/other/path', {})).rejects.toThrow('must start with /api/');
});
it('rejects paths that use backslash to bypass the check', async () => {
const api = makeApi();
await expect(api.http.post('/api/\\@evil.example', {})).rejects.toThrow();
});
it('throws without http:post permission', async () => {
const api = makeApi([]);
await expect(api.http.post('/api/jitsi', {})).rejects.toThrow('lacks permission');
});
it('accepts a valid /api/ path', async () => {
const api = makeApi();
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ url: 'https://meet.example.com/room' }),
});
const result = await api.http.post('/api/jitsi', { eventTitle: 'test' });
expect(result.ok).toBe(true);
expect(result.data).toEqual({ url: 'https://meet.example.com/room' });
});
});
+1 -45
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { SlotRegistration, InstalledPlugin } from '@/lib/plugin-types';
import type { InstalledPlugin } from '@/lib/plugin-types';
// We test the raw store by directly invoking Zustand
// Mock the external dependencies the store imports
@@ -28,10 +28,6 @@ vi.mock('@/lib/plugin-loader', () => ({
setupAutoDisable: vi.fn(),
}));
vi.mock('@/lib/plugin-api', () => ({
setSlotRegistrationBridge: vi.fn(),
}));
vi.mock('@/lib/plugin-hooks', () => ({
removeAllPluginHooks: vi.fn(),
}));
@@ -42,21 +38,6 @@ import { usePluginStore } from '@/stores/plugin-store';
function resetStore() {
usePluginStore.setState({
plugins: [],
slots: {
'toolbar-actions': [],
'email-banner': [],
'email-footer': [],
'composer-toolbar': [],
'composer-sidebar': [],
'composer-sidebar-right': [],
'sidebar-widget': [],
'email-detail-sidebar': [],
'settings-section': [],
'context-menu-email': [],
'navigation-rail-bottom': [],
'calendar-event-actions': [],
'admin-plugin-page': [],
},
initialized: false,
});
}
@@ -84,31 +65,6 @@ beforeEach(() => {
});
describe('usePluginStore', () => {
describe('registerSlot / dispose', () => {
it('adds registration to slot and removes on dispose', () => {
const { registerSlot } = usePluginStore.getState();
const reg: SlotRegistration = {
pluginId: 'p1',
component: () => null,
order: 100,
};
const disposable = registerSlot('toolbar-actions', reg);
expect(usePluginStore.getState().slots['toolbar-actions']).toHaveLength(1);
disposable.dispose();
expect(usePluginStore.getState().slots['toolbar-actions']).toHaveLength(0);
});
it('sorts registrations by order', () => {
const { registerSlot } = usePluginStore.getState();
registerSlot('email-banner', { pluginId: 'p1', component: () => null, order: 200 });
registerSlot('email-banner', { pluginId: 'p2', component: () => null, order: 50 });
registerSlot('email-banner', { pluginId: 'p3', component: () => null, order: 100 });
const regs = usePluginStore.getState().slots['email-banner'];
expect(regs.map(r => r.pluginId)).toEqual(['p2', 'p3', 'p1']);
});
});
describe('setPluginStatus', () => {
it('updates status for existing plugin', () => {
usePluginStore.setState({ plugins: [mockPlugin()] });
+170
View File
@@ -0,0 +1,170 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { parseMailto } from "../protocol-handlers/mailto";
import { listenForMailtoRequests } from "../protocol-handlers/session";
import { parseWebcal } from "../protocol-handlers/webcal";
const originalServiceWorkerDescriptor = Object.getOwnPropertyDescriptor(navigator, "serviceWorker");
function installServiceWorkerMock() {
const listeners = new Set<(event: MessageEvent) => void>();
const worker = { postMessage: vi.fn() };
const serviceWorker = {
ready: Promise.resolve({ active: worker }),
controller: worker,
addEventListener: vi.fn((type: string, listener: EventListener) => {
if (type === "message") listeners.add(listener as (event: MessageEvent) => void);
}),
removeEventListener: vi.fn((type: string, listener: EventListener) => {
if (type === "message") listeners.delete(listener as (event: MessageEvent) => void);
}),
};
Object.defineProperty(navigator, "serviceWorker", {
configurable: true,
value: serviceWorker,
});
return {
dispatch(data: unknown) {
listeners.forEach((listener) => listener(new MessageEvent("message", { data })));
},
};
}
afterEach(() => {
vi.restoreAllMocks();
if (originalServiceWorkerDescriptor) {
Object.defineProperty(navigator, "serviceWorker", originalServiceWorkerDescriptor);
return;
}
Reflect.deleteProperty(navigator, "serviceWorker");
});
describe("protocol handlers", () => {
describe("parseMailto", () => {
it("parses a single path recipient", () => {
expect(parseMailto("mailto:alice@example.com")).toEqual({
to: ["alice@example.com"],
cc: [],
bcc: [],
subject: "",
body: "",
});
});
it("parses multiple recipients with subject and body", () => {
expect(parseMailto("mailto:alice@example.com,bob@example.com?subject=Hello&body=Hi")).toMatchObject({
to: ["alice@example.com", "bob@example.com"],
subject: "Hello",
body: "Hi",
});
});
it("parses to, cc, and bcc query recipients", () => {
expect(parseMailto("mailto:?to=alice@example.com&cc=bob@example.com&bcc=eve@example.com")).toMatchObject({
to: ["alice@example.com"],
cc: ["bob@example.com"],
bcc: ["eve@example.com"],
});
});
it("decodes subject and body values", () => {
expect(parseMailto("mailto:alice@example.com?subject=Hello%20World&body=line1%0Aline2")).toMatchObject({
subject: "Hello World",
body: "line1\nline2",
});
});
it("preserves literal plus signs in query values", () => {
expect(parseMailto("mailto:?to=user+tag@example.com&subject=C++&body=a+b")).toMatchObject({
to: ["user+tag@example.com"],
subject: "C++",
body: "a+b",
});
});
it("rejects non-mailto URLs", () => {
expect(parseMailto("https://example.com")).toBeNull();
});
it("allows an empty mailto URL", () => {
expect(parseMailto("mailto:")).toEqual({
to: [],
cc: [],
bcc: [],
subject: "",
body: "",
});
});
it("removes control characters and caps recipients", () => {
const recipients = Array.from({ length: 250 }, (_, index) => `user${index}@example.com`).join(",");
const parsed = parseMailto(`mailto:${recipients}?subject=Hi%0ABcc:evil@example.com`);
expect(parsed?.to).toHaveLength(200);
expect(parsed?.subject).toBe("HiBcc:evil@example.com");
});
});
describe("parseWebcal", () => {
it("normalizes webcal to https", () => {
expect(parseWebcal("webcal://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
});
it("normalizes webcals to https", () => {
expect(parseWebcal("webcals://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
});
it("accepts https URLs", () => {
expect(parseWebcal("https://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
});
it("rejects unsupported protocols", () => {
expect(parseWebcal("ftp://example.com/calendar.ics")).toBeNull();
});
it("suggests a name from the path", () => {
expect(parseWebcal("webcal://example.com/team.ics")?.suggestedName).toBe("team");
});
it("falls back to hostname for suggested name", () => {
expect(parseWebcal("webcal://example.com/")?.suggestedName).toBe("example.com");
});
it("prefers a name query parameter", () => {
expect(parseWebcal("webcal://example.com/team.ics?name=Team%20Calendar")?.suggestedName).toBe("Team Calendar");
});
});
describe("listenForMailtoRequests", () => {
const mailtoValue = {
to: ["alice@example.com"],
cc: [],
bcc: [],
subject: "Hello",
body: "Hi",
};
it("accepts legacy service-worker mailto messages without a client id", () => {
const serviceWorker = installServiceWorkerMock();
const onMailto = vi.fn();
vi.spyOn(window, "focus").mockImplementation(() => undefined);
const cleanup = listenForMailtoRequests(onMailto, () => ({ path: "/", standalone: false }));
serviceWorker.dispatch({ type: "mailto-request", id: "legacy", value: mailtoValue });
expect(onMailto).toHaveBeenCalledWith(mailtoValue);
cleanup();
});
it("ignores service-worker mailto messages for another client", () => {
const serviceWorker = installServiceWorkerMock();
const onMailto = vi.fn();
const cleanup = listenForMailtoRequests(onMailto, () => ({ path: "/", standalone: false }));
serviceWorker.dispatch({ type: "mailto-request", id: "targeted", clientId: "other-client", value: mailtoValue });
expect(onMailto).not.toHaveBeenCalled();
cleanup();
});
});
});
+36 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { findReplyIdentityId } from '../reply-identity';
import { findReplyIdentityId, resolveReplyFrom } from '../reply-identity';
import type { Identity } from '../jmap/types';
const identities: Identity[] = [
@@ -49,4 +49,39 @@ describe('findReplyIdentityId', () => {
expect(selected).toBeNull();
});
});
describe('resolveReplyFrom', () => {
it('returns the matching identity with no override when exact match', () => {
expect(resolveReplyFrom(identities, { to: [{ email: 'harry@secondary.com' }] }))
.toEqual({ identityId: 'secondary' });
});
it('strips +tag before matching identities', () => {
expect(resolveReplyFrom(identities, { to: [{ email: 'harry+news@primary.com' }] }))
.toEqual({ identityId: 'primary' });
});
it('surfaces catch-all override when recipient is on an identity domain but not an identity', () => {
const result = resolveReplyFrom(identities, {
to: [{ email: 'stripe@primary.com', name: 'Stripe' }],
});
expect(result).toEqual({
identityId: 'primary',
overrideEmail: 'stripe@primary.com',
overrideName: 'Stripe',
});
});
it('prefers identity match over catch-all override when both appear', () => {
const result = resolveReplyFrom(identities, {
to: [{ email: 'harry@primary.com' }, { email: 'stripe@primary.com' }],
});
expect(result).toEqual({ identityId: 'primary' });
});
it('returns null when recipients are on foreign domains', () => {
expect(resolveReplyFrom(identities, { to: [{ email: 'nobody@elsewhere.com' }] }))
.toBeNull();
});
});
+252
View File
@@ -481,6 +481,258 @@ describe("round-trip: parse → generate → parse", () => {
});
});
describe("vCard 4.0 parsing (issue #289)", () => {
it("strips group prefix from property names (item1.EMAIL)", () => {
// Evolution / Apple Contacts emit grouped properties so an X-ABLABEL line
// can attach a label. We must still parse the EMAIL itself.
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Ada Lovelace",
"item1.EMAIL:ada@example.com",
"item1.X-ABLABEL:Personal",
"item2.TEL:tel:+1-555-0100",
"item2.X-ABLABEL:Mobile",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
const card = result[0];
expect(card.emails?.e0?.address).toBe("ada@example.com");
expect(card.phones?.p0?.number).toBe("+1-555-0100");
});
it("strips tel:/mailto: URI scheme from TEL/EMAIL values", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Alan Turing",
"EMAIL:mailto:alan@example.com",
"TEL;VALUE=uri:tel:+44-20-1234-5678",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].emails?.e0?.address).toBe("alan@example.com");
expect(result[0].phones?.p0?.number).toBe("+44-20-1234-5678");
});
it("maps PREF=n parameter to pref field", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Grace Hopper",
"EMAIL;PREF=1:grace@home.example",
"EMAIL;PREF=2:grace@work.example",
"TEL;PREF=1:+1-555-9999",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].emails?.e0?.pref).toBe(1);
expect(result[0].emails?.e1?.pref).toBe(2);
expect(result[0].phones?.p0?.pref).toBe(1);
});
it("decodes RFC 6868 caret-encoded parameter values", () => {
// ^n → LF, ^^ → ^, ^' → DQUOTE
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Test",
'ADR;LABEL="Line 1^nLine 2";TYPE=HOME:;;Sub St;Town;;;US',
"EMAIL:t@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].addresses?.a0?.fullAddress).toBe("Line 1\nLine 2");
expect(result[0].addresses?.a0?.contexts).toEqual({ private: true });
});
it("survives quoted parameter values containing semicolons", () => {
// Without quote-aware param splitting, the ; inside LABEL would shred
// the param list and the ADR would lose its TYPE.
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Lev",
'ADR;LABEL="Building A; Suite 12";TYPE=WORK:;;1 Plaza;NYC;NY;10001;US',
"EMAIL:lev@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].addresses?.a0?.fullAddress).toBe("Building A; Suite 12");
expect(result[0].addresses?.a0?.contexts).toEqual({ work: true });
expect(result[0].addresses?.a0?.locality).toBe("NYC");
});
it("parses BIRTHPLACE and DEATHPLACE (RFC 6474)", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Marie Curie",
"BDAY:18671107",
"BIRTHPLACE:Warsaw\\, Poland",
"DEATHDATE:19340704",
"DEATHPLACE:Passy\\, France",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
const annivs = Object.values(result[0].anniversaries || {});
const birth = annivs.find((a) => a.kind === "birth");
const death = annivs.find((a) => a.kind === "death");
expect(birth?.place?.fullAddress).toBe("Warsaw, Poland");
expect(death?.place?.fullAddress).toBe("Passy, France");
});
it("parses EXPERTISE / HOBBY / INTEREST with LEVEL (RFC 6715)", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Polymath",
"EXPERTISE;LEVEL=expert:cryptography",
"EXPERTISE;LEVEL=beginner:welding",
"HOBBY;LEVEL=high:gardening",
"INTEREST;LEVEL=medium:opera",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
const info = Object.values(result[0].personalInfo || {});
expect(info).toEqual(expect.arrayContaining([
{ kind: "expertise", value: "cryptography", level: "high" },
{ kind: "expertise", value: "welding", level: "low" },
{ kind: "hobby", value: "gardening", level: "high" },
{ kind: "interest", value: "opera", level: "medium" },
]));
});
it("parses ORG-DIRECTORY (RFC 6715) and CONTACT-URI (RFC 8605)", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Corp Person",
"ORG-DIRECTORY:https://example.com/staff/",
"CONTACT-URI;PREF=1:https://example.com/contact",
"EMAIL:c@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(Object.values(result[0].directories || {})[0]).toMatchObject({
uri: "https://example.com/staff/",
kind: "directory",
});
const links = Object.values(result[0].links || {});
expect(links[0]).toMatchObject({
uri: "https://example.com/contact",
kind: "contact",
pref: 1,
});
});
it("parses RFC 9554 CREATED, GRAMGENDER, PRONOUNS", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Modern Person",
"CREATED:20250101T120000Z",
"GRAMGENDER:neuter",
"PRONOUNS:they/them",
"PRONOUNS;PREF=2:ze/zir",
"EMAIL:m@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].created).toBe("20250101T120000Z");
expect(result[0].speakToAs?.grammaticalGender).toBe("neuter");
const pronouns = Object.values(result[0].speakToAs?.pronouns || {});
expect(pronouns).toEqual(expect.arrayContaining([
expect.objectContaining({ pronouns: "they/them" }),
expect.objectContaining({ pronouns: "ze/zir", pref: 2 }),
]));
});
it("accepts vCard 4.0 KIND values (location, device, application)", () => {
for (const k of ["location", "device", "application"] as const) {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
`KIND:${k}`,
"FN:Thing",
"END:VCARD",
].join("\r\n");
expect(parseVCard(vcf)[0].kind).toBe(k);
}
});
it("handles ADR with LABEL/GEO/TZ/CC parameters (RFC 9554)", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:GeoPerson",
'ADR;CC=DE;GEO="geo:52.5,13.4";TZ=Europe/Berlin;LABEL="Unter den Linden 1\\nBerlin":;;Unter den Linden 1;Berlin;;10117;Germany',
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
const addr = result[0].addresses?.a0;
expect(addr?.countryCode).toBe("DE");
expect(addr?.coordinates).toBe("52.5,13.4");
expect(addr?.timeZone).toBe("Europe/Berlin");
expect(addr?.fullAddress).toContain("Unter den Linden 1");
expect(addr?.locality).toBe("Berlin");
});
it("unfolds LF-only continuation lines (no CR)", () => {
// Unix exporters often use LF only; we must still unfold.
const vcf = "BEGIN:VCARD\nVERSION:4.0\nFN:John\n Doe\nEMAIL:j@d.com\nEND:VCARD";
const result = parseVCard(vcf);
expect(result[0].name?.components).toEqual(
expect.arrayContaining([{ kind: "given", value: "JohnDoe" }])
);
});
it("round-trips vCard 4.0-only properties through generateVCard", () => {
const original = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Round Trip",
"EMAIL;PREF=1:rt@example.com",
"BDAY:19700101",
"BIRTHPLACE:Somewhere",
"EXPERTISE;LEVEL=expert:vCard",
"HOBBY;LEVEL=medium:reading",
"ORG-DIRECTORY:https://example.com/dir",
"CONTACT-URI:https://example.com/contact",
"CREATED:20240101T000000Z",
"END:VCARD",
].join("\r\n");
const exported = generateVCard(parseVCard(original));
const reparsed = parseVCard(exported)[0];
expect(reparsed.emails?.e0?.pref).toBe(1);
expect(Object.values(reparsed.anniversaries || {}).find(a => a.kind === "birth")?.place?.fullAddress).toBe("Somewhere");
const info = Object.values(reparsed.personalInfo || {});
expect(info).toEqual(expect.arrayContaining([
{ kind: "expertise", value: "vCard", level: "high" },
{ kind: "hobby", value: "reading", level: "medium" },
]));
expect(Object.values(reparsed.directories || {})[0]?.uri).toBe("https://example.com/dir");
expect(Object.values(reparsed.links || {})[0]).toMatchObject({
uri: "https://example.com/contact",
kind: "contact",
});
expect(reparsed.created).toBe("20240101T000000Z");
});
});
describe("detectDuplicates", () => {
it("detects duplicates by matching email (case-insensitive)", () => {
const existing: ContactCard[] = [
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
describe('compareVersions', () => {
it('orders by major, minor, patch', () => {
expect(compareVersions('1.0.0', '1.0.0')).toBe(0);
expect(compareVersions('1.0.1', '1.0.0')).toBeGreaterThan(0);
expect(compareVersions('1.0.0', '1.0.1')).toBeLessThan(0);
expect(compareVersions('2.0.0', '1.9.9')).toBeGreaterThan(0);
expect(compareVersions('1.10.0', '1.9.0')).toBeGreaterThan(0);
});
it('treats missing segments as 0', () => {
expect(compareVersions('1', '1.0.0')).toBe(0);
expect(compareVersions('1.2', '1.2.0')).toBe(0);
});
it('tolerates a leading v', () => {
expect(compareVersions('v1.6.7', '1.6.7')).toBe(0);
});
it('ignores pre-release / build metadata', () => {
expect(compareVersions('1.6.7-rc.1', '1.6.7')).toBe(0);
expect(compareVersions('1.6.7+build.5', '1.6.7')).toBe(0);
});
});
describe('isVersionSatisfied', () => {
it('returns true when current >= required', () => {
expect(isVersionSatisfied('1.6.7', '1.6.7')).toBe(true);
expect(isVersionSatisfied('1.6.8', '1.6.7')).toBe(true);
expect(isVersionSatisfied('2.0.0', '1.9.9')).toBe(true);
});
it('returns false when current < required', () => {
expect(isVersionSatisfied('1.6.6', '1.6.7')).toBe(false);
expect(isVersionSatisfied('1.5.0', '1.6.0')).toBe(false);
expect(isVersionSatisfied('0.0.0', '1.0.0')).toBe(false);
});
it('treats empty / null / undefined required as no requirement', () => {
expect(isVersionSatisfied('1.0.0', '')).toBe(true);
expect(isVersionSatisfied('1.0.0', null)).toBe(true);
expect(isVersionSatisfied('1.0.0', undefined)).toBe(true);
});
});